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