Skip to main content

sheetkit_core/workbook/
io.rs

1use super::*;
2
3impl Workbook {
4    /// Create a new empty workbook containing a single empty sheet named "Sheet1".
5    pub fn new() -> Self {
6        let sst_runtime = SharedStringTable::new();
7        let mut sheet_name_index = HashMap::new();
8        sheet_name_index.insert("Sheet1".to_string(), 0);
9        Self {
10            content_types: ContentTypes::default(),
11            package_rels: relationships::package_rels(),
12            workbook_xml: WorkbookXml::default(),
13            workbook_rels: relationships::workbook_rels(),
14            worksheets: vec![("Sheet1".to_string(), WorksheetXml::default())],
15            stylesheet: StyleSheet::default(),
16            sst_runtime,
17            sheet_comments: vec![None],
18            charts: vec![],
19            raw_charts: vec![],
20            drawings: vec![],
21            images: vec![],
22            worksheet_drawings: HashMap::new(),
23            worksheet_rels: HashMap::new(),
24            drawing_rels: HashMap::new(),
25            core_properties: None,
26            app_properties: None,
27            custom_properties: None,
28            pivot_tables: vec![],
29            pivot_cache_defs: vec![],
30            pivot_cache_records: vec![],
31            theme_xml: None,
32            theme_colors: crate::theme::default_theme_colors(),
33            sheet_sparklines: vec![vec![]],
34            sheet_vml: vec![None],
35            sheet_name_index,
36        }
37    }
38
39    /// Open an existing `.xlsx` file from disk.
40    ///
41    /// If the file is encrypted (CFB container), returns
42    /// [`Error::FileEncrypted`]. Use [`Workbook::open_with_password`] instead.
43    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
44        let data = std::fs::read(path.as_ref())?;
45
46        // Detect encrypted files (CFB container)
47        #[cfg(feature = "encryption")]
48        if data.len() >= 8 {
49            if let Ok(crate::crypt::ContainerFormat::Cfb) =
50                crate::crypt::detect_container_format(&data)
51            {
52                return Err(Error::FileEncrypted);
53            }
54        }
55
56        let cursor = std::io::Cursor::new(data);
57        let mut archive = zip::ZipArchive::new(cursor).map_err(|e| Error::Zip(e.to_string()))?;
58        Self::from_archive(&mut archive)
59    }
60
61    /// Build a Workbook from an already-opened ZIP archive.
62    fn from_archive<R: std::io::Read + std::io::Seek>(
63        archive: &mut zip::ZipArchive<R>,
64    ) -> Result<Self> {
65        // Parse [Content_Types].xml
66        let content_types: ContentTypes = read_xml_part(archive, "[Content_Types].xml")?;
67
68        // Parse _rels/.rels
69        let package_rels: Relationships = read_xml_part(archive, "_rels/.rels")?;
70
71        // Parse xl/workbook.xml
72        let workbook_xml: WorkbookXml = read_xml_part(archive, "xl/workbook.xml")?;
73
74        // Parse xl/_rels/workbook.xml.rels
75        let workbook_rels: Relationships = read_xml_part(archive, "xl/_rels/workbook.xml.rels")?;
76
77        // Parse each worksheet referenced in the workbook.
78        let sheet_count = workbook_xml.sheets.sheets.len();
79        let mut worksheets = Vec::with_capacity(sheet_count);
80        let mut worksheet_paths = Vec::with_capacity(sheet_count);
81        for sheet_entry in &workbook_xml.sheets.sheets {
82            // Find the relationship target for this sheet's rId.
83            let rel = workbook_rels
84                .relationships
85                .iter()
86                .find(|r| r.id == sheet_entry.r_id && r.rel_type == rel_types::WORKSHEET);
87
88            let rel = rel.ok_or_else(|| {
89                Error::Internal(format!(
90                    "missing worksheet relationship for sheet '{}'",
91                    sheet_entry.name
92                ))
93            })?;
94
95            let sheet_path = resolve_relationship_target("xl/workbook.xml", &rel.target);
96            let ws: WorksheetXml = read_xml_part(archive, &sheet_path)?;
97            worksheets.push((sheet_entry.name.clone(), ws));
98            worksheet_paths.push(sheet_path);
99        }
100
101        // Parse xl/styles.xml
102        let stylesheet: StyleSheet = read_xml_part(archive, "xl/styles.xml")?;
103
104        // Parse xl/sharedStrings.xml (optional -- may not exist for workbooks with no strings)
105        let shared_strings: Sst =
106            read_xml_part(archive, "xl/sharedStrings.xml").unwrap_or_default();
107
108        let sst_runtime = SharedStringTable::from_sst(shared_strings);
109
110        // Parse xl/theme/theme1.xml (optional -- preserved as raw bytes for round-trip).
111        let (theme_xml, theme_colors) = match read_bytes_part(archive, "xl/theme/theme1.xml") {
112            Ok(bytes) => {
113                let colors = sheetkit_xml::theme::parse_theme_colors(&bytes);
114                (Some(bytes), colors)
115            }
116            Err(_) => (None, crate::theme::default_theme_colors()),
117        };
118
119        // Parse per-sheet worksheet relationship files (optional).
120        let mut worksheet_rels: HashMap<usize, Relationships> = HashMap::with_capacity(sheet_count);
121        for (i, sheet_path) in worksheet_paths.iter().enumerate() {
122            let rels_path = relationship_part_path(sheet_path);
123            if let Ok(rels) = read_xml_part::<Relationships, _>(archive, &rels_path) {
124                worksheet_rels.insert(i, rels);
125            }
126        }
127
128        // Parse comments, VML drawings, drawings, drawing rels, charts, and images.
129        let mut sheet_comments: Vec<Option<Comments>> = vec![None; worksheets.len()];
130        let mut sheet_vml: Vec<Option<Vec<u8>>> = vec![None; worksheets.len()];
131        let mut drawings: Vec<(String, WsDr)> = Vec::new();
132        let mut worksheet_drawings: HashMap<usize, usize> = HashMap::new();
133        let mut drawing_path_to_idx: HashMap<String, usize> = HashMap::new();
134
135        for (sheet_idx, sheet_path) in worksheet_paths.iter().enumerate() {
136            let Some(rels) = worksheet_rels.get(&sheet_idx) else {
137                continue;
138            };
139
140            if let Some(comment_rel) = rels
141                .relationships
142                .iter()
143                .find(|r| r.rel_type == rel_types::COMMENTS)
144            {
145                let comment_path = resolve_relationship_target(sheet_path, &comment_rel.target);
146                if let Ok(comments) = read_xml_part::<Comments, _>(archive, &comment_path) {
147                    sheet_comments[sheet_idx] = Some(comments);
148                }
149            }
150
151            if let Some(vml_rel) = rels
152                .relationships
153                .iter()
154                .find(|r| r.rel_type == rel_types::VML_DRAWING)
155            {
156                let vml_path = resolve_relationship_target(sheet_path, &vml_rel.target);
157                if let Ok(bytes) = read_bytes_part(archive, &vml_path) {
158                    sheet_vml[sheet_idx] = Some(bytes);
159                }
160            }
161
162            if let Some(drawing_rel) = rels
163                .relationships
164                .iter()
165                .find(|r| r.rel_type == rel_types::DRAWING)
166            {
167                let drawing_path = resolve_relationship_target(sheet_path, &drawing_rel.target);
168                let drawing_idx = if let Some(idx) = drawing_path_to_idx.get(&drawing_path) {
169                    *idx
170                } else if let Ok(drawing) = read_xml_part::<WsDr, _>(archive, &drawing_path) {
171                    let idx = drawings.len();
172                    drawings.push((drawing_path.clone(), drawing));
173                    drawing_path_to_idx.insert(drawing_path.clone(), idx);
174                    idx
175                } else {
176                    continue;
177                };
178                worksheet_drawings.insert(sheet_idx, drawing_idx);
179            }
180        }
181
182        // Fallback: load drawing parts listed in content types even when they
183        // are not discoverable via worksheet rel parsing.
184        for ovr in &content_types.overrides {
185            if ovr.content_type != mime_types::DRAWING {
186                continue;
187            }
188            let drawing_path = ovr.part_name.trim_start_matches('/').to_string();
189            if drawing_path_to_idx.contains_key(&drawing_path) {
190                continue;
191            }
192            if let Ok(drawing) = read_xml_part::<WsDr, _>(archive, &drawing_path) {
193                let idx = drawings.len();
194                drawings.push((drawing_path.clone(), drawing));
195                drawing_path_to_idx.insert(drawing_path, idx);
196            }
197        }
198
199        let mut drawing_rels: HashMap<usize, Relationships> = HashMap::new();
200        let mut charts: Vec<(String, ChartSpace)> = Vec::new();
201        let mut raw_charts: Vec<(String, Vec<u8>)> = Vec::new();
202        let mut images: Vec<(String, Vec<u8>)> = Vec::new();
203        let mut seen_chart_paths: HashSet<String> = HashSet::new();
204        let mut seen_image_paths: HashSet<String> = HashSet::new();
205
206        for (drawing_idx, (drawing_path, _)) in drawings.iter().enumerate() {
207            let drawing_rels_path = relationship_part_path(drawing_path);
208            let Ok(rels) = read_xml_part::<Relationships, _>(archive, &drawing_rels_path) else {
209                continue;
210            };
211
212            for rel in &rels.relationships {
213                if rel.rel_type == rel_types::CHART {
214                    let chart_path = resolve_relationship_target(drawing_path, &rel.target);
215                    if seen_chart_paths.insert(chart_path.clone()) {
216                        match read_xml_part::<ChartSpace, _>(archive, &chart_path) {
217                            Ok(chart) => charts.push((chart_path, chart)),
218                            Err(_) => {
219                                if let Ok(bytes) = read_bytes_part(archive, &chart_path) {
220                                    raw_charts.push((chart_path, bytes));
221                                }
222                            }
223                        }
224                    }
225                } else if rel.rel_type == rel_types::IMAGE {
226                    let image_path = resolve_relationship_target(drawing_path, &rel.target);
227                    if seen_image_paths.insert(image_path.clone()) {
228                        if let Ok(bytes) = read_bytes_part(archive, &image_path) {
229                            images.push((image_path, bytes));
230                        }
231                    }
232                }
233            }
234
235            drawing_rels.insert(drawing_idx, rels);
236        }
237
238        // Fallback: load chart parts listed in content types even when no
239        // drawing relationship was read.
240        for ovr in &content_types.overrides {
241            if ovr.content_type != mime_types::CHART {
242                continue;
243            }
244            let chart_path = ovr.part_name.trim_start_matches('/').to_string();
245            if seen_chart_paths.insert(chart_path.clone()) {
246                match read_xml_part::<ChartSpace, _>(archive, &chart_path) {
247                    Ok(chart) => charts.push((chart_path, chart)),
248                    Err(_) => {
249                        if let Ok(bytes) = read_bytes_part(archive, &chart_path) {
250                            raw_charts.push((chart_path, bytes));
251                        }
252                    }
253                }
254            }
255        }
256
257        // Parse docProps/core.xml (optional - uses manual XML parsing)
258        let core_properties = read_string_part(archive, "docProps/core.xml")
259            .ok()
260            .and_then(|xml_str| {
261                sheetkit_xml::doc_props::deserialize_core_properties(&xml_str).ok()
262            });
263
264        // Parse docProps/app.xml (optional - uses serde)
265        let app_properties: Option<sheetkit_xml::doc_props::ExtendedProperties> =
266            read_xml_part(archive, "docProps/app.xml").ok();
267
268        // Parse docProps/custom.xml (optional - uses manual XML parsing)
269        let custom_properties = read_string_part(archive, "docProps/custom.xml")
270            .ok()
271            .and_then(|xml_str| {
272                sheetkit_xml::doc_props::deserialize_custom_properties(&xml_str).ok()
273            });
274
275        // Parse pivot cache definitions, pivot tables, and pivot cache records.
276        let mut pivot_cache_defs = Vec::new();
277        let mut pivot_tables = Vec::new();
278        let mut pivot_cache_records = Vec::new();
279        for ovr in &content_types.overrides {
280            let path = ovr.part_name.trim_start_matches('/');
281            if ovr.content_type == mime_types::PIVOT_CACHE_DEFINITION {
282                if let Ok(pcd) = read_xml_part::<sheetkit_xml::pivot_cache::PivotCacheDefinition, _>(
283                    archive, path,
284                ) {
285                    pivot_cache_defs.push((path.to_string(), pcd));
286                }
287            } else if ovr.content_type == mime_types::PIVOT_TABLE {
288                if let Ok(pt) = read_xml_part::<sheetkit_xml::pivot_table::PivotTableDefinition, _>(
289                    archive, path,
290                ) {
291                    pivot_tables.push((path.to_string(), pt));
292                }
293            } else if ovr.content_type == mime_types::PIVOT_CACHE_RECORDS {
294                if let Ok(pcr) =
295                    read_xml_part::<sheetkit_xml::pivot_cache::PivotCacheRecords, _>(archive, path)
296                {
297                    pivot_cache_records.push((path.to_string(), pcr));
298                }
299            }
300        }
301
302        // Parse sparklines from worksheet extension lists.
303        let mut sheet_sparklines: Vec<Vec<crate::sparkline::SparklineConfig>> =
304            vec![vec![]; worksheets.len()];
305        for (i, ws_path) in worksheet_paths.iter().enumerate() {
306            if let Ok(raw) = read_string_part(archive, ws_path) {
307                let parsed = parse_sparklines_from_xml(&raw);
308                if !parsed.is_empty() {
309                    sheet_sparklines[i] = parsed;
310                }
311            }
312        }
313
314        // Build sheet name -> index lookup.
315        let mut sheet_name_index = HashMap::with_capacity(worksheets.len());
316        for (i, (name, _)) in worksheets.iter().enumerate() {
317            sheet_name_index.insert(name.clone(), i);
318        }
319
320        // Populate cached column numbers on all cells and ensure sorted order
321        // for binary search correctness.
322        for (_name, ws) in &mut worksheets {
323            // Ensure rows are sorted by row number (some writers output unsorted data).
324            ws.sheet_data.rows.sort_unstable_by_key(|r| r.r);
325            for row in &mut ws.sheet_data.rows {
326                for cell in &mut row.cells {
327                    cell.col = fast_col_number(cell.r.as_str());
328                }
329                // Ensure cells within a row are sorted by column number.
330                row.cells.sort_unstable_by_key(|c| c.col);
331            }
332        }
333
334        Ok(Self {
335            content_types,
336            package_rels,
337            workbook_xml,
338            workbook_rels,
339            worksheets,
340            stylesheet,
341            sst_runtime,
342            sheet_comments,
343            charts,
344            raw_charts,
345            drawings,
346            images,
347            worksheet_drawings,
348            worksheet_rels,
349            drawing_rels,
350            core_properties,
351            app_properties,
352            custom_properties,
353            pivot_tables,
354            pivot_cache_defs,
355            pivot_cache_records,
356            theme_xml,
357            theme_colors,
358            sheet_sparklines,
359            sheet_vml,
360            sheet_name_index,
361        })
362    }
363
364    /// Save the workbook to a `.xlsx` file at the given path.
365    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
366        let file = std::fs::File::create(path)?;
367        let mut zip = zip::ZipWriter::new(file);
368        let options = SimpleFileOptions::default()
369            .compression_method(CompressionMethod::Deflated)
370            .compression_level(Some(1));
371        self.write_zip_contents(&mut zip, options)?;
372        zip.finish().map_err(|e| Error::Zip(e.to_string()))?;
373        Ok(())
374    }
375
376    /// Serialize the workbook to an in-memory `.xlsx` buffer.
377    pub fn save_to_buffer(&self) -> Result<Vec<u8>> {
378        // Estimate compressed output size to reduce reallocations.
379        let estimated = self.worksheets.len() * 4000
380            + self.sst_runtime.len() * 60
381            + self.images.iter().map(|(_, d)| d.len()).sum::<usize>()
382            + 32_000;
383        let mut buf = Vec::with_capacity(estimated);
384        {
385            let cursor = std::io::Cursor::new(&mut buf);
386            let mut zip = zip::ZipWriter::new(cursor);
387            let options =
388                SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
389            self.write_zip_contents(&mut zip, options)?;
390            zip.finish().map_err(|e| Error::Zip(e.to_string()))?;
391        }
392        Ok(buf)
393    }
394
395    /// Open a workbook from an in-memory `.xlsx` buffer.
396    pub fn open_from_buffer(data: &[u8]) -> Result<Self> {
397        // Detect encrypted files (CFB container)
398        #[cfg(feature = "encryption")]
399        if data.len() >= 8 {
400            if let Ok(crate::crypt::ContainerFormat::Cfb) =
401                crate::crypt::detect_container_format(data)
402            {
403                return Err(Error::FileEncrypted);
404            }
405        }
406
407        let cursor = std::io::Cursor::new(data);
408        let mut archive = zip::ZipArchive::new(cursor).map_err(|e| Error::Zip(e.to_string()))?;
409        Self::from_archive(&mut archive)
410    }
411
412    /// Open an encrypted `.xlsx` file using a password.
413    ///
414    /// The file must be in OLE/CFB container format. Supports both Standard
415    /// Encryption (Office 2007, AES-128-ECB) and Agile Encryption (Office
416    /// 2010+, AES-256-CBC).
417    #[cfg(feature = "encryption")]
418    pub fn open_with_password<P: AsRef<Path>>(path: P, password: &str) -> Result<Self> {
419        let data = std::fs::read(path.as_ref())?;
420        let decrypted_zip = crate::crypt::decrypt_xlsx(&data, password)?;
421        let cursor = std::io::Cursor::new(decrypted_zip);
422        let mut archive = zip::ZipArchive::new(cursor).map_err(|e| Error::Zip(e.to_string()))?;
423        Self::from_archive(&mut archive)
424    }
425
426    /// Save the workbook as an encrypted `.xlsx` file using Agile Encryption
427    /// (AES-256-CBC + SHA-512, 100K iterations).
428    #[cfg(feature = "encryption")]
429    pub fn save_with_password<P: AsRef<Path>>(&self, path: P, password: &str) -> Result<()> {
430        // First, serialize to an in-memory ZIP buffer
431        let mut zip_buf = Vec::new();
432        {
433            let cursor = std::io::Cursor::new(&mut zip_buf);
434            let mut zip = zip::ZipWriter::new(cursor);
435            let options =
436                SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
437            self.write_zip_contents(&mut zip, options)?;
438            zip.finish().map_err(|e| Error::Zip(e.to_string()))?;
439        }
440
441        // Encrypt and write to CFB container
442        let cfb_data = crate::crypt::encrypt_xlsx(&zip_buf, password)?;
443        std::fs::write(path.as_ref(), &cfb_data)?;
444        Ok(())
445    }
446
447    /// Write all workbook parts into the given ZIP writer.
448    fn write_zip_contents<W: std::io::Write + std::io::Seek>(
449        &self,
450        zip: &mut zip::ZipWriter<W>,
451        options: SimpleFileOptions,
452    ) -> Result<()> {
453        let mut content_types = self.content_types.clone();
454        let mut worksheet_rels = self.worksheet_rels.clone();
455
456        // Synchronize comment and VML parts with worksheet relationships/content types.
457        let comment_count = self.sheet_comments.iter().filter(|c| c.is_some()).count();
458        // Per-sheet VML bytes to write: (sheet_idx, zip_path, bytes).
459        let mut vml_parts_to_write: Vec<(usize, String, Vec<u8>)> =
460            Vec::with_capacity(comment_count);
461        // Per-sheet legacy drawing relationship IDs for worksheet XML serialization.
462        let mut legacy_drawing_rids: HashMap<usize, String> = HashMap::with_capacity(comment_count);
463
464        // Ensure the vml extension default content type is present if any VML exists.
465        let mut has_any_vml = false;
466
467        for sheet_idx in 0..self.worksheets.len() {
468            let has_comments = self
469                .sheet_comments
470                .get(sheet_idx)
471                .and_then(|c| c.as_ref())
472                .is_some();
473            if let Some(rels) = worksheet_rels.get_mut(&sheet_idx) {
474                rels.relationships
475                    .retain(|r| r.rel_type != rel_types::COMMENTS);
476                rels.relationships
477                    .retain(|r| r.rel_type != rel_types::VML_DRAWING);
478            }
479            if !has_comments {
480                continue;
481            }
482
483            let comment_path = format!("xl/comments{}.xml", sheet_idx + 1);
484            let part_name = format!("/{}", comment_path);
485            if !content_types
486                .overrides
487                .iter()
488                .any(|o| o.part_name == part_name && o.content_type == mime_types::COMMENTS)
489            {
490                content_types.overrides.push(ContentTypeOverride {
491                    part_name,
492                    content_type: mime_types::COMMENTS.to_string(),
493                });
494            }
495
496            let sheet_path = self.sheet_part_path(sheet_idx);
497            let target = relative_relationship_target(&sheet_path, &comment_path);
498            let rels = worksheet_rels
499                .entry(sheet_idx)
500                .or_insert_with(default_relationships);
501            let rid = crate::sheet::next_rid(&rels.relationships);
502            rels.relationships.push(Relationship {
503                id: rid,
504                rel_type: rel_types::COMMENTS.to_string(),
505                target,
506                target_mode: None,
507            });
508
509            // Determine VML bytes: use preserved bytes if available, otherwise generate.
510            let vml_path = format!("xl/drawings/vmlDrawing{}.vml", sheet_idx + 1);
511            let vml_bytes =
512                if let Some(bytes) = self.sheet_vml.get(sheet_idx).and_then(|v| v.as_ref()) {
513                    bytes.clone()
514                } else {
515                    // Generate VML from comment cell references.
516                    let comments = self.sheet_comments[sheet_idx].as_ref().unwrap();
517                    let cells: Vec<&str> = comments
518                        .comment_list
519                        .comments
520                        .iter()
521                        .map(|c| c.r#ref.as_str())
522                        .collect();
523                    crate::vml::build_vml_drawing(&cells).into_bytes()
524                };
525
526            let vml_part_name = format!("/{}", vml_path);
527            if !content_types
528                .overrides
529                .iter()
530                .any(|o| o.part_name == vml_part_name && o.content_type == mime_types::VML_DRAWING)
531            {
532                content_types.overrides.push(ContentTypeOverride {
533                    part_name: vml_part_name,
534                    content_type: mime_types::VML_DRAWING.to_string(),
535                });
536            }
537
538            let vml_target = relative_relationship_target(&sheet_path, &vml_path);
539            let vml_rid = crate::sheet::next_rid(&rels.relationships);
540            rels.relationships.push(Relationship {
541                id: vml_rid.clone(),
542                rel_type: rel_types::VML_DRAWING.to_string(),
543                target: vml_target,
544                target_mode: None,
545            });
546
547            legacy_drawing_rids.insert(sheet_idx, vml_rid);
548            vml_parts_to_write.push((sheet_idx, vml_path, vml_bytes));
549            has_any_vml = true;
550        }
551
552        // Add vml extension default content type if needed.
553        if has_any_vml && !content_types.defaults.iter().any(|d| d.extension == "vml") {
554            content_types.defaults.push(ContentTypeDefault {
555                extension: "vml".to_string(),
556                content_type: mime_types::VML_DRAWING.to_string(),
557            });
558        }
559
560        // [Content_Types].xml
561        write_xml_part(zip, "[Content_Types].xml", &content_types, options)?;
562
563        // _rels/.rels
564        write_xml_part(zip, "_rels/.rels", &self.package_rels, options)?;
565
566        // xl/workbook.xml
567        write_xml_part(zip, "xl/workbook.xml", &self.workbook_xml, options)?;
568
569        // xl/_rels/workbook.xml.rels
570        write_xml_part(
571            zip,
572            "xl/_rels/workbook.xml.rels",
573            &self.workbook_rels,
574            options,
575        )?;
576
577        // xl/worksheets/sheet{N}.xml
578        for (i, (_name, ws)) in self.worksheets.iter().enumerate() {
579            let entry_name = self.sheet_part_path(i);
580            let empty_sparklines: Vec<crate::sparkline::SparklineConfig> = vec![];
581            let sparklines = self.sheet_sparklines.get(i).unwrap_or(&empty_sparklines);
582            let legacy_rid = legacy_drawing_rids.get(&i).map(|s| s.as_str());
583
584            if legacy_rid.is_none() && sparklines.is_empty() {
585                write_xml_part(zip, &entry_name, ws, options)?;
586            } else {
587                // Serialize worksheet to XML and inject extras via string manipulation,
588                // avoiding a full WorksheetXml clone.
589                let xml = serialize_worksheet_with_extras(ws, sparklines, legacy_rid)?;
590                zip.start_file(&entry_name, options)
591                    .map_err(|e| Error::Zip(e.to_string()))?;
592                zip.write_all(xml.as_bytes())?;
593            }
594        }
595
596        // xl/styles.xml
597        write_xml_part(zip, "xl/styles.xml", &self.stylesheet, options)?;
598
599        // xl/sharedStrings.xml -- write from the runtime SST
600        let sst_xml = self.sst_runtime.to_sst();
601        write_xml_part(zip, "xl/sharedStrings.xml", &sst_xml, options)?;
602
603        // xl/comments{N}.xml -- write per-sheet comments
604        for (i, comments) in self.sheet_comments.iter().enumerate() {
605            if let Some(ref c) = comments {
606                let entry_name = format!("xl/comments{}.xml", i + 1);
607                write_xml_part(zip, &entry_name, c, options)?;
608            }
609        }
610
611        // xl/drawings/vmlDrawing{N}.vml -- write VML drawing parts
612        for (_sheet_idx, vml_path, vml_bytes) in &vml_parts_to_write {
613            zip.start_file(vml_path, options)
614                .map_err(|e| Error::Zip(e.to_string()))?;
615            zip.write_all(vml_bytes)?;
616        }
617
618        // xl/drawings/drawing{N}.xml -- write drawing parts
619        for (path, drawing) in &self.drawings {
620            write_xml_part(zip, path, drawing, options)?;
621        }
622
623        // xl/charts/chart{N}.xml -- write chart parts
624        for (path, chart) in &self.charts {
625            write_xml_part(zip, path, chart, options)?;
626        }
627        for (path, data) in &self.raw_charts {
628            if self.charts.iter().any(|(p, _)| p == path) {
629                continue;
630            }
631            zip.start_file(path, options)
632                .map_err(|e| Error::Zip(e.to_string()))?;
633            zip.write_all(data)?;
634        }
635
636        // xl/media/image{N}.{ext} -- write image data
637        for (path, data) in &self.images {
638            zip.start_file(path, options)
639                .map_err(|e| Error::Zip(e.to_string()))?;
640            zip.write_all(data)?;
641        }
642
643        // xl/worksheets/_rels/sheet{N}.xml.rels -- write worksheet relationships
644        for (sheet_idx, rels) in &worksheet_rels {
645            let sheet_path = self.sheet_part_path(*sheet_idx);
646            let path = relationship_part_path(&sheet_path);
647            write_xml_part(zip, &path, rels, options)?;
648        }
649
650        // xl/drawings/_rels/drawing{N}.xml.rels -- write drawing relationships
651        for (drawing_idx, rels) in &self.drawing_rels {
652            if let Some((drawing_path, _)) = self.drawings.get(*drawing_idx) {
653                let path = relationship_part_path(drawing_path);
654                write_xml_part(zip, &path, rels, options)?;
655            }
656        }
657
658        // xl/pivotTables/pivotTable{N}.xml
659        for (path, pt) in &self.pivot_tables {
660            write_xml_part(zip, path, pt, options)?;
661        }
662
663        // xl/pivotCache/pivotCacheDefinition{N}.xml
664        for (path, pcd) in &self.pivot_cache_defs {
665            write_xml_part(zip, path, pcd, options)?;
666        }
667
668        // xl/pivotCache/pivotCacheRecords{N}.xml
669        for (path, pcr) in &self.pivot_cache_records {
670            write_xml_part(zip, path, pcr, options)?;
671        }
672
673        // xl/theme/theme1.xml
674        {
675            let default_theme = crate::theme::default_theme_xml();
676            let theme_bytes = self.theme_xml.as_deref().unwrap_or(&default_theme);
677            zip.start_file("xl/theme/theme1.xml", options)
678                .map_err(|e| Error::Zip(e.to_string()))?;
679            zip.write_all(theme_bytes)?;
680        }
681
682        // docProps/core.xml
683        if let Some(ref props) = self.core_properties {
684            let xml_str = sheetkit_xml::doc_props::serialize_core_properties(props);
685            zip.start_file("docProps/core.xml", options)
686                .map_err(|e| Error::Zip(e.to_string()))?;
687            zip.write_all(xml_str.as_bytes())?;
688        }
689
690        // docProps/app.xml
691        if let Some(ref props) = self.app_properties {
692            write_xml_part(zip, "docProps/app.xml", props, options)?;
693        }
694
695        // docProps/custom.xml
696        if let Some(ref props) = self.custom_properties {
697            let xml_str = sheetkit_xml::doc_props::serialize_custom_properties(props);
698            zip.start_file("docProps/custom.xml", options)
699                .map_err(|e| Error::Zip(e.to_string()))?;
700            zip.write_all(xml_str.as_bytes())?;
701        }
702
703        Ok(())
704    }
705}
706
707impl Default for Workbook {
708    fn default() -> Self {
709        Self::new()
710    }
711}
712
713/// Serialize a value to XML with the standard XML declaration prepended.
714pub(crate) fn serialize_xml<T: Serialize>(value: &T) -> Result<String> {
715    let body = quick_xml::se::to_string(value).map_err(|e| Error::XmlParse(e.to_string()))?;
716    let mut result = String::with_capacity(XML_DECLARATION.len() + 1 + body.len());
717    result.push_str(XML_DECLARATION);
718    result.push('\n');
719    result.push_str(&body);
720    Ok(result)
721}
722
723/// Read a ZIP entry and deserialize it from XML.
724pub(crate) fn read_xml_part<T: serde::de::DeserializeOwned, R: std::io::Read + std::io::Seek>(
725    archive: &mut zip::ZipArchive<R>,
726    name: &str,
727) -> Result<T> {
728    let mut entry = archive
729        .by_name(name)
730        .map_err(|e| Error::Zip(e.to_string()))?;
731    let size_hint = entry.size() as usize;
732    let mut content = String::with_capacity(size_hint);
733    entry
734        .read_to_string(&mut content)
735        .map_err(|e| Error::Zip(e.to_string()))?;
736    quick_xml::de::from_str(&content).map_err(|e| Error::XmlDeserialize(e.to_string()))
737}
738
739/// Read a ZIP entry as a raw string (no serde deserialization).
740pub(crate) fn read_string_part<R: std::io::Read + std::io::Seek>(
741    archive: &mut zip::ZipArchive<R>,
742    name: &str,
743) -> Result<String> {
744    let mut entry = archive
745        .by_name(name)
746        .map_err(|e| Error::Zip(e.to_string()))?;
747    let size_hint = entry.size() as usize;
748    let mut content = String::with_capacity(size_hint);
749    entry
750        .read_to_string(&mut content)
751        .map_err(|e| Error::Zip(e.to_string()))?;
752    Ok(content)
753}
754
755/// Read a ZIP entry as raw bytes.
756pub(crate) fn read_bytes_part<R: std::io::Read + std::io::Seek>(
757    archive: &mut zip::ZipArchive<R>,
758    name: &str,
759) -> Result<Vec<u8>> {
760    let mut entry = archive
761        .by_name(name)
762        .map_err(|e| Error::Zip(e.to_string()))?;
763    let size_hint = entry.size() as usize;
764    let mut content = Vec::with_capacity(size_hint);
765    entry
766        .read_to_end(&mut content)
767        .map_err(|e| Error::Zip(e.to_string()))?;
768    Ok(content)
769}
770
771/// Serialize a worksheet with optional sparklines and legacy drawing injected
772/// via string manipulation, avoiding a full WorksheetXml clone.
773pub(crate) fn serialize_worksheet_with_extras(
774    ws: &WorksheetXml,
775    sparklines: &[crate::sparkline::SparklineConfig],
776    legacy_drawing_rid: Option<&str>,
777) -> Result<String> {
778    let body = quick_xml::se::to_string(ws).map_err(|e| Error::XmlParse(e.to_string()))?;
779
780    let closing = "</worksheet>";
781    let ext_xml = if sparklines.is_empty() {
782        String::new()
783    } else {
784        build_sparkline_ext_xml(sparklines)
785    };
786    let legacy_xml = if let Some(rid) = legacy_drawing_rid {
787        format!("<legacyDrawing r:id=\"{rid}\"/>")
788    } else {
789        String::new()
790    };
791
792    if let Some(pos) = body.rfind(closing) {
793        // If injecting a legacy drawing, strip any existing one from the serde output
794        // to avoid duplicates (the original ws.legacy_drawing may already be set).
795        let body_prefix = &body[..pos];
796        let stripped;
797        let prefix = if !legacy_xml.is_empty() {
798            if let Some(ld_start) = body_prefix.find("<legacyDrawing ") {
799                // Find the end of the self-closing element.
800                let ld_end = body_prefix[ld_start..]
801                    .find("/>")
802                    .map(|e| ld_start + e + 2)
803                    .unwrap_or(ld_start);
804                stripped = format!("{}{}", &body_prefix[..ld_start], &body_prefix[ld_end..]);
805                stripped.as_str()
806            } else {
807                body_prefix
808            }
809        } else {
810            body_prefix
811        };
812
813        let extra_len = ext_xml.len() + legacy_xml.len();
814        let mut result = String::with_capacity(XML_DECLARATION.len() + 1 + body.len() + extra_len);
815        result.push_str(XML_DECLARATION);
816        result.push('\n');
817        result.push_str(prefix);
818        result.push_str(&legacy_xml);
819        result.push_str(&ext_xml);
820        result.push_str(closing);
821        Ok(result)
822    } else {
823        Ok(format!("{XML_DECLARATION}\n{body}"))
824    }
825}
826
827/// Build the extLst XML block for sparklines using manual string construction.
828pub(crate) fn build_sparkline_ext_xml(sparklines: &[crate::sparkline::SparklineConfig]) -> String {
829    use std::fmt::Write;
830    let mut xml = String::new();
831    let _ = write!(
832        xml,
833        "<extLst>\
834         <ext xmlns:x14=\"http://schemas.microsoft.com/office/spreadsheetml/2009/9/main\" \
835         uri=\"{{05C60535-1F16-4fd2-B633-F4F36F0B64E0}}\">\
836         <x14:sparklineGroups \
837         xmlns:xm=\"http://schemas.microsoft.com/office/excel/2006/main\">"
838    );
839    for config in sparklines {
840        let group = crate::sparkline::config_to_xml_group(config);
841        let _ = write!(xml, "<x14:sparklineGroup");
842        if let Some(ref t) = group.sparkline_type {
843            let _ = write!(xml, " type=\"{t}\"");
844        }
845        if group.markers == Some(true) {
846            let _ = write!(xml, " markers=\"1\"");
847        }
848        if group.high == Some(true) {
849            let _ = write!(xml, " high=\"1\"");
850        }
851        if group.low == Some(true) {
852            let _ = write!(xml, " low=\"1\"");
853        }
854        if group.first == Some(true) {
855            let _ = write!(xml, " first=\"1\"");
856        }
857        if group.last == Some(true) {
858            let _ = write!(xml, " last=\"1\"");
859        }
860        if group.negative == Some(true) {
861            let _ = write!(xml, " negative=\"1\"");
862        }
863        if group.display_x_axis == Some(true) {
864            let _ = write!(xml, " displayXAxis=\"1\"");
865        }
866        if let Some(w) = group.line_weight {
867            let _ = write!(xml, " lineWeight=\"{w}\"");
868        }
869        let _ = write!(xml, "><x14:sparklines>");
870        for sp in &group.sparklines.items {
871            let _ = write!(
872                xml,
873                "<x14:sparkline><xm:f>{}</xm:f><xm:sqref>{}</xm:sqref></x14:sparkline>",
874                sp.formula, sp.sqref
875            );
876        }
877        let _ = write!(xml, "</x14:sparklines></x14:sparklineGroup>");
878    }
879    let _ = write!(xml, "</x14:sparklineGroups></ext></extLst>");
880    xml
881}
882
883/// Parse sparkline configurations from raw worksheet XML content.
884pub(crate) fn parse_sparklines_from_xml(xml: &str) -> Vec<crate::sparkline::SparklineConfig> {
885    use crate::sparkline::{SparklineConfig, SparklineType};
886
887    let mut sparklines = Vec::new();
888
889    // Find all sparklineGroup elements and parse their attributes and children.
890    let mut search_from = 0;
891    while let Some(group_start) = xml[search_from..].find("<x14:sparklineGroup") {
892        let abs_start = search_from + group_start;
893        let group_end_tag = "</x14:sparklineGroup>";
894        let abs_end = match xml[abs_start..].find(group_end_tag) {
895            Some(pos) => abs_start + pos + group_end_tag.len(),
896            None => break,
897        };
898        let group_xml = &xml[abs_start..abs_end];
899
900        // Parse group-level attributes.
901        let sparkline_type = extract_xml_attr(group_xml, "type")
902            .and_then(|s| SparklineType::parse(&s))
903            .unwrap_or_default();
904        let markers = extract_xml_bool_attr(group_xml, "markers");
905        let high_point = extract_xml_bool_attr(group_xml, "high");
906        let low_point = extract_xml_bool_attr(group_xml, "low");
907        let first_point = extract_xml_bool_attr(group_xml, "first");
908        let last_point = extract_xml_bool_attr(group_xml, "last");
909        let negative_points = extract_xml_bool_attr(group_xml, "negative");
910        let show_axis = extract_xml_bool_attr(group_xml, "displayXAxis");
911        let line_weight =
912            extract_xml_attr(group_xml, "lineWeight").and_then(|s| s.parse::<f64>().ok());
913
914        // Parse individual sparkline entries within this group.
915        let mut sp_from = 0;
916        while let Some(sp_start) = group_xml[sp_from..].find("<x14:sparkline>") {
917            let sp_abs = sp_from + sp_start;
918            let sp_end_tag = "</x14:sparkline>";
919            let sp_abs_end = match group_xml[sp_abs..].find(sp_end_tag) {
920                Some(pos) => sp_abs + pos + sp_end_tag.len(),
921                None => break,
922            };
923            let sp_xml = &group_xml[sp_abs..sp_abs_end];
924
925            let formula = extract_xml_element(sp_xml, "xm:f").unwrap_or_default();
926            let sqref = extract_xml_element(sp_xml, "xm:sqref").unwrap_or_default();
927
928            if !formula.is_empty() && !sqref.is_empty() {
929                sparklines.push(SparklineConfig {
930                    data_range: formula,
931                    location: sqref,
932                    sparkline_type: sparkline_type.clone(),
933                    markers,
934                    high_point,
935                    low_point,
936                    first_point,
937                    last_point,
938                    negative_points,
939                    show_axis,
940                    line_weight,
941                    style: None,
942                });
943            }
944            sp_from = sp_abs_end;
945        }
946        search_from = abs_end;
947    }
948    sparklines
949}
950
951/// Extract an XML attribute value from an element's opening tag.
952///
953/// Uses manual search to avoid allocating format strings for patterns.
954pub(crate) fn extract_xml_attr(xml: &str, attr: &str) -> Option<String> {
955    // Search for ` attr="` or ` attr='` without allocating pattern strings.
956    for quote in ['"', '\''] {
957        // Build the search target: " attr=" (space + attr name + = + quote)
958        let haystack = xml.as_bytes();
959        let attr_bytes = attr.as_bytes();
960        let mut pos = 0;
961        while pos + 1 + attr_bytes.len() + 2 <= haystack.len() {
962            if haystack[pos] == b' '
963                && haystack[pos + 1..pos + 1 + attr_bytes.len()] == *attr_bytes
964                && haystack[pos + 1 + attr_bytes.len()] == b'='
965                && haystack[pos + 1 + attr_bytes.len() + 1] == quote as u8
966            {
967                let val_start = pos + 1 + attr_bytes.len() + 2;
968                if let Some(end) = xml[val_start..].find(quote) {
969                    return Some(xml[val_start..val_start + end].to_string());
970                }
971            }
972            pos += 1;
973        }
974    }
975    None
976}
977
978/// Extract a boolean attribute from an XML element (true for "1" or "true").
979pub(crate) fn extract_xml_bool_attr(xml: &str, attr: &str) -> bool {
980    extract_xml_attr(xml, attr)
981        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
982        .unwrap_or(false)
983}
984
985/// Extract the text content of an XML element like `<tag>content</tag>`.
986pub(crate) fn extract_xml_element(xml: &str, tag: &str) -> Option<String> {
987    let open = format!("<{tag}>");
988    let close = format!("</{tag}>");
989    let start = xml.find(&open)?;
990    let content_start = start + open.len();
991    let end = xml[content_start..].find(&close)?;
992    Some(xml[content_start..content_start + end].to_string())
993}
994
995/// Serialize a value to XML and write it as a ZIP entry.
996pub(crate) fn write_xml_part<T: Serialize, W: std::io::Write + std::io::Seek>(
997    zip: &mut zip::ZipWriter<W>,
998    name: &str,
999    value: &T,
1000    options: SimpleFileOptions,
1001) -> Result<()> {
1002    let xml = serialize_xml(value)?;
1003    zip.start_file(name, options)
1004        .map_err(|e| Error::Zip(e.to_string()))?;
1005    zip.write_all(xml.as_bytes())?;
1006    Ok(())
1007}
1008
1009/// Fast column number extraction from a cell reference string like "A1", "BC42".
1010///
1011/// Parses only the alphabetic prefix (column letters) and converts to a
1012/// 1-based column number. Much faster than [`cell_name_to_coordinates`] because
1013/// it skips row parsing and avoids error handling overhead.
1014fn fast_col_number(cell_ref: &str) -> u32 {
1015    let mut col: u32 = 0;
1016    for b in cell_ref.bytes() {
1017        if b.is_ascii_alphabetic() {
1018            col = col * 26 + (b.to_ascii_uppercase() - b'A') as u32 + 1;
1019        } else {
1020            break;
1021        }
1022    }
1023    col
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028    use super::*;
1029    use tempfile::TempDir;
1030
1031    #[test]
1032    fn test_fast_col_number() {
1033        assert_eq!(fast_col_number("A1"), 1);
1034        assert_eq!(fast_col_number("B1"), 2);
1035        assert_eq!(fast_col_number("Z1"), 26);
1036        assert_eq!(fast_col_number("AA1"), 27);
1037        assert_eq!(fast_col_number("AZ1"), 52);
1038        assert_eq!(fast_col_number("BA1"), 53);
1039        assert_eq!(fast_col_number("XFD1"), 16384);
1040    }
1041
1042    #[test]
1043    fn test_extract_xml_attr() {
1044        let xml = r#"<tag type="column" markers="1" weight="2.5">"#;
1045        assert_eq!(extract_xml_attr(xml, "type"), Some("column".to_string()));
1046        assert_eq!(extract_xml_attr(xml, "markers"), Some("1".to_string()));
1047        assert_eq!(extract_xml_attr(xml, "weight"), Some("2.5".to_string()));
1048        assert_eq!(extract_xml_attr(xml, "missing"), None);
1049        // Single-quoted attributes
1050        let xml2 = "<tag name='hello'>";
1051        assert_eq!(extract_xml_attr(xml2, "name"), Some("hello".to_string()));
1052    }
1053
1054    #[test]
1055    fn test_extract_xml_bool_attr() {
1056        let xml = r#"<tag markers="1" hidden="0" visible="true">"#;
1057        assert!(extract_xml_bool_attr(xml, "markers"));
1058        assert!(!extract_xml_bool_attr(xml, "hidden"));
1059        assert!(extract_xml_bool_attr(xml, "visible"));
1060        assert!(!extract_xml_bool_attr(xml, "missing"));
1061    }
1062
1063    #[test]
1064    fn test_new_workbook_has_sheet1() {
1065        let wb = Workbook::new();
1066        assert_eq!(wb.sheet_names(), vec!["Sheet1"]);
1067    }
1068
1069    #[test]
1070    fn test_new_workbook_save_creates_file() {
1071        let dir = TempDir::new().unwrap();
1072        let path = dir.path().join("test.xlsx");
1073        let wb = Workbook::new();
1074        wb.save(&path).unwrap();
1075        assert!(path.exists());
1076    }
1077
1078    #[test]
1079    fn test_save_and_open_roundtrip() {
1080        let dir = TempDir::new().unwrap();
1081        let path = dir.path().join("roundtrip.xlsx");
1082
1083        let wb = Workbook::new();
1084        wb.save(&path).unwrap();
1085
1086        let wb2 = Workbook::open(&path).unwrap();
1087        assert_eq!(wb2.sheet_names(), vec!["Sheet1"]);
1088    }
1089
1090    #[test]
1091    fn test_saved_file_is_valid_zip() {
1092        let dir = TempDir::new().unwrap();
1093        let path = dir.path().join("valid.xlsx");
1094        let wb = Workbook::new();
1095        wb.save(&path).unwrap();
1096
1097        // Verify it's a valid ZIP with expected entries
1098        let file = std::fs::File::open(&path).unwrap();
1099        let mut archive = zip::ZipArchive::new(file).unwrap();
1100
1101        let expected_files = [
1102            "[Content_Types].xml",
1103            "_rels/.rels",
1104            "xl/workbook.xml",
1105            "xl/_rels/workbook.xml.rels",
1106            "xl/worksheets/sheet1.xml",
1107            "xl/styles.xml",
1108            "xl/sharedStrings.xml",
1109        ];
1110
1111        for name in &expected_files {
1112            assert!(archive.by_name(name).is_ok(), "Missing ZIP entry: {}", name);
1113        }
1114    }
1115
1116    #[test]
1117    fn test_open_nonexistent_file_returns_error() {
1118        let result = Workbook::open("/nonexistent/path.xlsx");
1119        assert!(result.is_err());
1120    }
1121
1122    #[test]
1123    fn test_saved_xml_has_declarations() {
1124        let dir = TempDir::new().unwrap();
1125        let path = dir.path().join("decl.xlsx");
1126        let wb = Workbook::new();
1127        wb.save(&path).unwrap();
1128
1129        let file = std::fs::File::open(&path).unwrap();
1130        let mut archive = zip::ZipArchive::new(file).unwrap();
1131
1132        let mut content = String::new();
1133        std::io::Read::read_to_string(
1134            &mut archive.by_name("[Content_Types].xml").unwrap(),
1135            &mut content,
1136        )
1137        .unwrap();
1138        assert!(content.starts_with("<?xml"));
1139    }
1140
1141    #[test]
1142    fn test_default_trait() {
1143        let wb = Workbook::default();
1144        assert_eq!(wb.sheet_names(), vec!["Sheet1"]);
1145    }
1146
1147    #[test]
1148    fn test_serialize_xml_helper() {
1149        let ct = ContentTypes::default();
1150        let xml = serialize_xml(&ct).unwrap();
1151        assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"));
1152        assert!(xml.contains("<Types"));
1153    }
1154
1155    #[test]
1156    fn test_save_to_buffer_and_open_from_buffer_roundtrip() {
1157        let mut wb = Workbook::new();
1158        wb.set_cell_value("Sheet1", "A1", CellValue::String("Hello".to_string()))
1159            .unwrap();
1160        wb.set_cell_value("Sheet1", "B2", CellValue::Number(42.0))
1161            .unwrap();
1162
1163        let buf = wb.save_to_buffer().unwrap();
1164        assert!(!buf.is_empty());
1165
1166        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
1167        assert_eq!(wb2.sheet_names(), vec!["Sheet1"]);
1168        assert_eq!(
1169            wb2.get_cell_value("Sheet1", "A1").unwrap(),
1170            CellValue::String("Hello".to_string())
1171        );
1172        assert_eq!(
1173            wb2.get_cell_value("Sheet1", "B2").unwrap(),
1174            CellValue::Number(42.0)
1175        );
1176    }
1177
1178    #[test]
1179    fn test_save_to_buffer_produces_valid_zip() {
1180        let wb = Workbook::new();
1181        let buf = wb.save_to_buffer().unwrap();
1182
1183        let cursor = std::io::Cursor::new(buf);
1184        let mut archive = zip::ZipArchive::new(cursor).unwrap();
1185
1186        let expected_files = [
1187            "[Content_Types].xml",
1188            "_rels/.rels",
1189            "xl/workbook.xml",
1190            "xl/_rels/workbook.xml.rels",
1191            "xl/worksheets/sheet1.xml",
1192            "xl/styles.xml",
1193            "xl/sharedStrings.xml",
1194        ];
1195
1196        for name in &expected_files {
1197            assert!(archive.by_name(name).is_ok(), "Missing ZIP entry: {}", name);
1198        }
1199    }
1200
1201    #[test]
1202    fn test_open_from_buffer_invalid_data() {
1203        let result = Workbook::open_from_buffer(b"not a zip file");
1204        assert!(result.is_err());
1205    }
1206
1207    #[cfg(feature = "encryption")]
1208    #[test]
1209    fn test_save_and_open_with_password_roundtrip() {
1210        let dir = TempDir::new().unwrap();
1211        let path = dir.path().join("encrypted.xlsx");
1212
1213        // Create a workbook with some data
1214        let mut wb = Workbook::new();
1215        wb.set_cell_value("Sheet1", "A1", CellValue::String("Hello".to_string()))
1216            .unwrap();
1217        wb.set_cell_value("Sheet1", "B2", CellValue::Number(42.0))
1218            .unwrap();
1219
1220        // Save with password
1221        wb.save_with_password(&path, "test123").unwrap();
1222
1223        // Verify it's a CFB file, not a ZIP
1224        let data = std::fs::read(&path).unwrap();
1225        assert_eq!(
1226            &data[..8],
1227            &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]
1228        );
1229
1230        // Open without password should fail
1231        let result = Workbook::open(&path);
1232        assert!(matches!(result, Err(Error::FileEncrypted)));
1233
1234        // Open with wrong password should fail
1235        let result = Workbook::open_with_password(&path, "wrong");
1236        assert!(matches!(result, Err(Error::IncorrectPassword)));
1237
1238        // Open with correct password should succeed
1239        let wb2 = Workbook::open_with_password(&path, "test123").unwrap();
1240        assert_eq!(
1241            wb2.get_cell_value("Sheet1", "A1").unwrap(),
1242            CellValue::String("Hello".to_string())
1243        );
1244        assert_eq!(
1245            wb2.get_cell_value("Sheet1", "B2").unwrap(),
1246            CellValue::Number(42.0)
1247        );
1248    }
1249
1250    #[cfg(feature = "encryption")]
1251    #[test]
1252    fn test_open_encrypted_file_without_password_returns_file_encrypted() {
1253        let dir = TempDir::new().unwrap();
1254        let path = dir.path().join("encrypted2.xlsx");
1255
1256        let wb = Workbook::new();
1257        wb.save_with_password(&path, "secret").unwrap();
1258
1259        let result = Workbook::open(&path);
1260        assert!(matches!(result, Err(Error::FileEncrypted)))
1261    }
1262}