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    /// Open an encrypted `.xlsx` file using a password.
353    ///
354    /// The file must be in OLE/CFB container format. Supports both Standard
355    /// Encryption (Office 2007, AES-128-ECB) and Agile Encryption (Office
356    /// 2010+, AES-256-CBC).
357    #[cfg(feature = "encryption")]
358    pub fn open_with_password<P: AsRef<Path>>(path: P, password: &str) -> Result<Self> {
359        let data = std::fs::read(path.as_ref())?;
360        let decrypted_zip = crate::crypt::decrypt_xlsx(&data, password)?;
361        let cursor = std::io::Cursor::new(decrypted_zip);
362        let mut archive = zip::ZipArchive::new(cursor).map_err(|e| Error::Zip(e.to_string()))?;
363        Self::from_archive(&mut archive)
364    }
365
366    /// Save the workbook as an encrypted `.xlsx` file using Agile Encryption
367    /// (AES-256-CBC + SHA-512, 100K iterations).
368    #[cfg(feature = "encryption")]
369    pub fn save_with_password<P: AsRef<Path>>(&self, path: P, password: &str) -> Result<()> {
370        // First, serialize to an in-memory ZIP buffer
371        let mut zip_buf = Vec::new();
372        {
373            let cursor = std::io::Cursor::new(&mut zip_buf);
374            let mut zip = zip::ZipWriter::new(cursor);
375            let options =
376                SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
377            self.write_zip_contents(&mut zip, options)?;
378            zip.finish().map_err(|e| Error::Zip(e.to_string()))?;
379        }
380
381        // Encrypt and write to CFB container
382        let cfb_data = crate::crypt::encrypt_xlsx(&zip_buf, password)?;
383        std::fs::write(path.as_ref(), &cfb_data)?;
384        Ok(())
385    }
386
387    /// Write all workbook parts into the given ZIP writer.
388    fn write_zip_contents<W: std::io::Write + std::io::Seek>(
389        &self,
390        zip: &mut zip::ZipWriter<W>,
391        options: SimpleFileOptions,
392    ) -> Result<()> {
393        let mut content_types = self.content_types.clone();
394        let mut worksheet_rels = self.worksheet_rels.clone();
395
396        // Synchronize comment and VML parts with worksheet relationships/content types.
397        // Per-sheet VML bytes to write: (sheet_idx, zip_path, bytes).
398        let mut vml_parts_to_write: Vec<(usize, String, Vec<u8>)> = Vec::new();
399        // Per-sheet legacy drawing relationship IDs for worksheet XML serialization.
400        let mut legacy_drawing_rids: HashMap<usize, String> = HashMap::new();
401
402        // Ensure the vml extension default content type is present if any VML exists.
403        let mut has_any_vml = false;
404
405        for sheet_idx in 0..self.worksheets.len() {
406            let has_comments = self
407                .sheet_comments
408                .get(sheet_idx)
409                .and_then(|c| c.as_ref())
410                .is_some();
411            if let Some(rels) = worksheet_rels.get_mut(&sheet_idx) {
412                rels.relationships
413                    .retain(|r| r.rel_type != rel_types::COMMENTS);
414                rels.relationships
415                    .retain(|r| r.rel_type != rel_types::VML_DRAWING);
416            }
417            if !has_comments {
418                continue;
419            }
420
421            let comment_path = format!("xl/comments{}.xml", sheet_idx + 1);
422            let part_name = format!("/{}", comment_path);
423            if !content_types
424                .overrides
425                .iter()
426                .any(|o| o.part_name == part_name && o.content_type == mime_types::COMMENTS)
427            {
428                content_types.overrides.push(ContentTypeOverride {
429                    part_name,
430                    content_type: mime_types::COMMENTS.to_string(),
431                });
432            }
433
434            let sheet_path = self.sheet_part_path(sheet_idx);
435            let target = relative_relationship_target(&sheet_path, &comment_path);
436            let rels = worksheet_rels
437                .entry(sheet_idx)
438                .or_insert_with(default_relationships);
439            let rid = crate::sheet::next_rid(&rels.relationships);
440            rels.relationships.push(Relationship {
441                id: rid,
442                rel_type: rel_types::COMMENTS.to_string(),
443                target,
444                target_mode: None,
445            });
446
447            // Determine VML bytes: use preserved bytes if available, otherwise generate.
448            let vml_path = format!("xl/drawings/vmlDrawing{}.vml", sheet_idx + 1);
449            let vml_bytes =
450                if let Some(bytes) = self.sheet_vml.get(sheet_idx).and_then(|v| v.as_ref()) {
451                    bytes.clone()
452                } else {
453                    // Generate VML from comment cell references.
454                    let comments = self.sheet_comments[sheet_idx].as_ref().unwrap();
455                    let cells: Vec<&str> = comments
456                        .comment_list
457                        .comments
458                        .iter()
459                        .map(|c| c.r#ref.as_str())
460                        .collect();
461                    crate::vml::build_vml_drawing(&cells).into_bytes()
462                };
463
464            let vml_part_name = format!("/{}", vml_path);
465            if !content_types
466                .overrides
467                .iter()
468                .any(|o| o.part_name == vml_part_name && o.content_type == mime_types::VML_DRAWING)
469            {
470                content_types.overrides.push(ContentTypeOverride {
471                    part_name: vml_part_name,
472                    content_type: mime_types::VML_DRAWING.to_string(),
473                });
474            }
475
476            let vml_target = relative_relationship_target(&sheet_path, &vml_path);
477            let vml_rid = crate::sheet::next_rid(&rels.relationships);
478            rels.relationships.push(Relationship {
479                id: vml_rid.clone(),
480                rel_type: rel_types::VML_DRAWING.to_string(),
481                target: vml_target,
482                target_mode: None,
483            });
484
485            legacy_drawing_rids.insert(sheet_idx, vml_rid);
486            vml_parts_to_write.push((sheet_idx, vml_path, vml_bytes));
487            has_any_vml = true;
488        }
489
490        // Add vml extension default content type if needed.
491        if has_any_vml && !content_types.defaults.iter().any(|d| d.extension == "vml") {
492            content_types.defaults.push(ContentTypeDefault {
493                extension: "vml".to_string(),
494                content_type: mime_types::VML_DRAWING.to_string(),
495            });
496        }
497
498        // [Content_Types].xml
499        write_xml_part(zip, "[Content_Types].xml", &content_types, options)?;
500
501        // _rels/.rels
502        write_xml_part(zip, "_rels/.rels", &self.package_rels, options)?;
503
504        // xl/workbook.xml
505        write_xml_part(zip, "xl/workbook.xml", &self.workbook_xml, options)?;
506
507        // xl/_rels/workbook.xml.rels
508        write_xml_part(
509            zip,
510            "xl/_rels/workbook.xml.rels",
511            &self.workbook_rels,
512            options,
513        )?;
514
515        // xl/worksheets/sheet{N}.xml
516        for (i, (_name, ws)) in self.worksheets.iter().enumerate() {
517            let entry_name = self.sheet_part_path(i);
518            let sparklines = self.sheet_sparklines.get(i).cloned().unwrap_or_default();
519            let needs_legacy_drawing = legacy_drawing_rids.contains_key(&i);
520
521            if !needs_legacy_drawing && sparklines.is_empty() {
522                write_xml_part(zip, &entry_name, ws, options)?;
523            } else {
524                let mut ws_clone = ws.clone();
525                if let Some(rid) = legacy_drawing_rids.get(&i) {
526                    ws_clone.legacy_drawing =
527                        Some(sheetkit_xml::worksheet::LegacyDrawingRef { r_id: rid.clone() });
528                }
529                if sparklines.is_empty() {
530                    write_xml_part(zip, &entry_name, &ws_clone, options)?;
531                } else {
532                    let xml = serialize_worksheet_with_sparklines(&ws_clone, &sparklines)?;
533                    zip.start_file(&entry_name, options)
534                        .map_err(|e| Error::Zip(e.to_string()))?;
535                    zip.write_all(xml.as_bytes())?;
536                }
537            }
538        }
539
540        // xl/styles.xml
541        write_xml_part(zip, "xl/styles.xml", &self.stylesheet, options)?;
542
543        // xl/sharedStrings.xml -- write from the runtime SST
544        let sst_xml = self.sst_runtime.to_sst();
545        write_xml_part(zip, "xl/sharedStrings.xml", &sst_xml, options)?;
546
547        // xl/comments{N}.xml -- write per-sheet comments
548        for (i, comments) in self.sheet_comments.iter().enumerate() {
549            if let Some(ref c) = comments {
550                let entry_name = format!("xl/comments{}.xml", i + 1);
551                write_xml_part(zip, &entry_name, c, options)?;
552            }
553        }
554
555        // xl/drawings/vmlDrawing{N}.vml -- write VML drawing parts
556        for (_sheet_idx, vml_path, vml_bytes) in &vml_parts_to_write {
557            zip.start_file(vml_path, options)
558                .map_err(|e| Error::Zip(e.to_string()))?;
559            zip.write_all(vml_bytes)?;
560        }
561
562        // xl/drawings/drawing{N}.xml -- write drawing parts
563        for (path, drawing) in &self.drawings {
564            write_xml_part(zip, path, drawing, options)?;
565        }
566
567        // xl/charts/chart{N}.xml -- write chart parts
568        for (path, chart) in &self.charts {
569            write_xml_part(zip, path, chart, options)?;
570        }
571        for (path, data) in &self.raw_charts {
572            if self.charts.iter().any(|(p, _)| p == path) {
573                continue;
574            }
575            zip.start_file(path, options)
576                .map_err(|e| Error::Zip(e.to_string()))?;
577            zip.write_all(data)?;
578        }
579
580        // xl/media/image{N}.{ext} -- write image data
581        for (path, data) in &self.images {
582            zip.start_file(path, options)
583                .map_err(|e| Error::Zip(e.to_string()))?;
584            zip.write_all(data)?;
585        }
586
587        // xl/worksheets/_rels/sheet{N}.xml.rels -- write worksheet relationships
588        for (sheet_idx, rels) in &worksheet_rels {
589            let sheet_path = self.sheet_part_path(*sheet_idx);
590            let path = relationship_part_path(&sheet_path);
591            write_xml_part(zip, &path, rels, options)?;
592        }
593
594        // xl/drawings/_rels/drawing{N}.xml.rels -- write drawing relationships
595        for (drawing_idx, rels) in &self.drawing_rels {
596            if let Some((drawing_path, _)) = self.drawings.get(*drawing_idx) {
597                let path = relationship_part_path(drawing_path);
598                write_xml_part(zip, &path, rels, options)?;
599            }
600        }
601
602        // xl/pivotTables/pivotTable{N}.xml
603        for (path, pt) in &self.pivot_tables {
604            write_xml_part(zip, path, pt, options)?;
605        }
606
607        // xl/pivotCache/pivotCacheDefinition{N}.xml
608        for (path, pcd) in &self.pivot_cache_defs {
609            write_xml_part(zip, path, pcd, options)?;
610        }
611
612        // xl/pivotCache/pivotCacheRecords{N}.xml
613        for (path, pcr) in &self.pivot_cache_records {
614            write_xml_part(zip, path, pcr, options)?;
615        }
616
617        // xl/theme/theme1.xml
618        {
619            let default_theme = crate::theme::default_theme_xml();
620            let theme_bytes = self.theme_xml.as_deref().unwrap_or(&default_theme);
621            zip.start_file("xl/theme/theme1.xml", options)
622                .map_err(|e| Error::Zip(e.to_string()))?;
623            zip.write_all(theme_bytes)?;
624        }
625
626        // docProps/core.xml
627        if let Some(ref props) = self.core_properties {
628            let xml_str = sheetkit_xml::doc_props::serialize_core_properties(props);
629            zip.start_file("docProps/core.xml", options)
630                .map_err(|e| Error::Zip(e.to_string()))?;
631            zip.write_all(xml_str.as_bytes())?;
632        }
633
634        // docProps/app.xml
635        if let Some(ref props) = self.app_properties {
636            write_xml_part(zip, "docProps/app.xml", props, options)?;
637        }
638
639        // docProps/custom.xml
640        if let Some(ref props) = self.custom_properties {
641            let xml_str = sheetkit_xml::doc_props::serialize_custom_properties(props);
642            zip.start_file("docProps/custom.xml", options)
643                .map_err(|e| Error::Zip(e.to_string()))?;
644            zip.write_all(xml_str.as_bytes())?;
645        }
646
647        Ok(())
648    }
649}
650
651impl Default for Workbook {
652    fn default() -> Self {
653        Self::new()
654    }
655}
656
657/// Serialize a value to XML with the standard XML declaration prepended.
658pub(crate) fn serialize_xml<T: Serialize>(value: &T) -> Result<String> {
659    let body = quick_xml::se::to_string(value).map_err(|e| Error::XmlParse(e.to_string()))?;
660    Ok(format!("{XML_DECLARATION}\n{body}"))
661}
662
663/// Read a ZIP entry and deserialize it from XML.
664pub(crate) fn read_xml_part<T: serde::de::DeserializeOwned, R: std::io::Read + std::io::Seek>(
665    archive: &mut zip::ZipArchive<R>,
666    name: &str,
667) -> Result<T> {
668    let mut entry = archive
669        .by_name(name)
670        .map_err(|e| Error::Zip(e.to_string()))?;
671    let mut content = String::new();
672    entry
673        .read_to_string(&mut content)
674        .map_err(|e| Error::Zip(e.to_string()))?;
675    quick_xml::de::from_str(&content).map_err(|e| Error::XmlDeserialize(e.to_string()))
676}
677
678/// Read a ZIP entry as a raw string (no serde deserialization).
679pub(crate) fn read_string_part<R: std::io::Read + std::io::Seek>(
680    archive: &mut zip::ZipArchive<R>,
681    name: &str,
682) -> Result<String> {
683    let mut entry = archive
684        .by_name(name)
685        .map_err(|e| Error::Zip(e.to_string()))?;
686    let mut content = String::new();
687    entry
688        .read_to_string(&mut content)
689        .map_err(|e| Error::Zip(e.to_string()))?;
690    Ok(content)
691}
692
693/// Read a ZIP entry as raw bytes.
694pub(crate) fn read_bytes_part<R: std::io::Read + std::io::Seek>(
695    archive: &mut zip::ZipArchive<R>,
696    name: &str,
697) -> Result<Vec<u8>> {
698    let mut entry = archive
699        .by_name(name)
700        .map_err(|e| Error::Zip(e.to_string()))?;
701    let mut content = Vec::new();
702    entry
703        .read_to_end(&mut content)
704        .map_err(|e| Error::Zip(e.to_string()))?;
705    Ok(content)
706}
707
708/// Serialize a worksheet with sparkline extension list appended.
709pub(crate) fn serialize_worksheet_with_sparklines(
710    ws: &WorksheetXml,
711    sparklines: &[crate::sparkline::SparklineConfig],
712) -> Result<String> {
713    let body = quick_xml::se::to_string(ws).map_err(|e| Error::XmlParse(e.to_string()))?;
714
715    let closing = "</worksheet>";
716    let ext_xml = build_sparkline_ext_xml(sparklines);
717    if let Some(pos) = body.rfind(closing) {
718        let mut result =
719            String::with_capacity(XML_DECLARATION.len() + 1 + body.len() + ext_xml.len());
720        result.push_str(XML_DECLARATION);
721        result.push('\n');
722        result.push_str(&body[..pos]);
723        result.push_str(&ext_xml);
724        result.push_str(closing);
725        Ok(result)
726    } else {
727        Ok(format!("{XML_DECLARATION}\n{body}"))
728    }
729}
730
731/// Build the extLst XML block for sparklines using manual string construction.
732pub(crate) fn build_sparkline_ext_xml(sparklines: &[crate::sparkline::SparklineConfig]) -> String {
733    use std::fmt::Write;
734    let mut xml = String::new();
735    let _ = write!(
736        xml,
737        "<extLst>\
738         <ext xmlns:x14=\"http://schemas.microsoft.com/office/spreadsheetml/2009/9/main\" \
739         uri=\"{{05C60535-1F16-4fd2-B633-F4F36F0B64E0}}\">\
740         <x14:sparklineGroups \
741         xmlns:xm=\"http://schemas.microsoft.com/office/excel/2006/main\">"
742    );
743    for config in sparklines {
744        let group = crate::sparkline::config_to_xml_group(config);
745        let _ = write!(xml, "<x14:sparklineGroup");
746        if let Some(ref t) = group.sparkline_type {
747            let _ = write!(xml, " type=\"{t}\"");
748        }
749        if group.markers == Some(true) {
750            let _ = write!(xml, " markers=\"1\"");
751        }
752        if group.high == Some(true) {
753            let _ = write!(xml, " high=\"1\"");
754        }
755        if group.low == Some(true) {
756            let _ = write!(xml, " low=\"1\"");
757        }
758        if group.first == Some(true) {
759            let _ = write!(xml, " first=\"1\"");
760        }
761        if group.last == Some(true) {
762            let _ = write!(xml, " last=\"1\"");
763        }
764        if group.negative == Some(true) {
765            let _ = write!(xml, " negative=\"1\"");
766        }
767        if group.display_x_axis == Some(true) {
768            let _ = write!(xml, " displayXAxis=\"1\"");
769        }
770        if let Some(w) = group.line_weight {
771            let _ = write!(xml, " lineWeight=\"{w}\"");
772        }
773        let _ = write!(xml, "><x14:sparklines>");
774        for sp in &group.sparklines.items {
775            let _ = write!(
776                xml,
777                "<x14:sparkline><xm:f>{}</xm:f><xm:sqref>{}</xm:sqref></x14:sparkline>",
778                sp.formula, sp.sqref
779            );
780        }
781        let _ = write!(xml, "</x14:sparklines></x14:sparklineGroup>");
782    }
783    let _ = write!(xml, "</x14:sparklineGroups></ext></extLst>");
784    xml
785}
786
787/// Parse sparkline configurations from raw worksheet XML content.
788pub(crate) fn parse_sparklines_from_xml(xml: &str) -> Vec<crate::sparkline::SparklineConfig> {
789    use crate::sparkline::{SparklineConfig, SparklineType};
790
791    let mut sparklines = Vec::new();
792
793    // Find all sparklineGroup elements and parse their attributes and children.
794    let mut search_from = 0;
795    while let Some(group_start) = xml[search_from..].find("<x14:sparklineGroup") {
796        let abs_start = search_from + group_start;
797        let group_end_tag = "</x14:sparklineGroup>";
798        let abs_end = match xml[abs_start..].find(group_end_tag) {
799            Some(pos) => abs_start + pos + group_end_tag.len(),
800            None => break,
801        };
802        let group_xml = &xml[abs_start..abs_end];
803
804        // Parse group-level attributes.
805        let sparkline_type = extract_xml_attr(group_xml, "type")
806            .and_then(|s| SparklineType::parse(&s))
807            .unwrap_or_default();
808        let markers = extract_xml_bool_attr(group_xml, "markers");
809        let high_point = extract_xml_bool_attr(group_xml, "high");
810        let low_point = extract_xml_bool_attr(group_xml, "low");
811        let first_point = extract_xml_bool_attr(group_xml, "first");
812        let last_point = extract_xml_bool_attr(group_xml, "last");
813        let negative_points = extract_xml_bool_attr(group_xml, "negative");
814        let show_axis = extract_xml_bool_attr(group_xml, "displayXAxis");
815        let line_weight =
816            extract_xml_attr(group_xml, "lineWeight").and_then(|s| s.parse::<f64>().ok());
817
818        // Parse individual sparkline entries within this group.
819        let mut sp_from = 0;
820        while let Some(sp_start) = group_xml[sp_from..].find("<x14:sparkline>") {
821            let sp_abs = sp_from + sp_start;
822            let sp_end_tag = "</x14:sparkline>";
823            let sp_abs_end = match group_xml[sp_abs..].find(sp_end_tag) {
824                Some(pos) => sp_abs + pos + sp_end_tag.len(),
825                None => break,
826            };
827            let sp_xml = &group_xml[sp_abs..sp_abs_end];
828
829            let formula = extract_xml_element(sp_xml, "xm:f").unwrap_or_default();
830            let sqref = extract_xml_element(sp_xml, "xm:sqref").unwrap_or_default();
831
832            if !formula.is_empty() && !sqref.is_empty() {
833                sparklines.push(SparklineConfig {
834                    data_range: formula,
835                    location: sqref,
836                    sparkline_type: sparkline_type.clone(),
837                    markers,
838                    high_point,
839                    low_point,
840                    first_point,
841                    last_point,
842                    negative_points,
843                    show_axis,
844                    line_weight,
845                    style: None,
846                });
847            }
848            sp_from = sp_abs_end;
849        }
850        search_from = abs_end;
851    }
852    sparklines
853}
854
855/// Extract an XML attribute value from an element's opening tag.
856pub(crate) fn extract_xml_attr(xml: &str, attr: &str) -> Option<String> {
857    // Look for attr="value" or attr='value' patterns.
858    let patterns = [format!(" {attr}=\""), format!(" {attr}='")];
859    for pat in &patterns {
860        if let Some(start) = xml.find(pat.as_str()) {
861            let val_start = start + pat.len();
862            let quote = pat.chars().last().unwrap();
863            if let Some(end) = xml[val_start..].find(quote) {
864                return Some(xml[val_start..val_start + end].to_string());
865            }
866        }
867    }
868    None
869}
870
871/// Extract a boolean attribute from an XML element (true for "1" or "true").
872pub(crate) fn extract_xml_bool_attr(xml: &str, attr: &str) -> bool {
873    extract_xml_attr(xml, attr)
874        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
875        .unwrap_or(false)
876}
877
878/// Extract the text content of an XML element like `<tag>content</tag>`.
879pub(crate) fn extract_xml_element(xml: &str, tag: &str) -> Option<String> {
880    let open = format!("<{tag}>");
881    let close = format!("</{tag}>");
882    let start = xml.find(&open)?;
883    let content_start = start + open.len();
884    let end = xml[content_start..].find(&close)?;
885    Some(xml[content_start..content_start + end].to_string())
886}
887
888/// Serialize a value to XML and write it as a ZIP entry.
889pub(crate) fn write_xml_part<T: Serialize, W: std::io::Write + std::io::Seek>(
890    zip: &mut zip::ZipWriter<W>,
891    name: &str,
892    value: &T,
893    options: SimpleFileOptions,
894) -> Result<()> {
895    let xml = serialize_xml(value)?;
896    zip.start_file(name, options)
897        .map_err(|e| Error::Zip(e.to_string()))?;
898    zip.write_all(xml.as_bytes())?;
899    Ok(())
900}
901
902#[cfg(test)]
903mod tests {
904    use super::*;
905    use tempfile::TempDir;
906
907    #[test]
908    fn test_new_workbook_has_sheet1() {
909        let wb = Workbook::new();
910        assert_eq!(wb.sheet_names(), vec!["Sheet1"]);
911    }
912
913    #[test]
914    fn test_new_workbook_save_creates_file() {
915        let dir = TempDir::new().unwrap();
916        let path = dir.path().join("test.xlsx");
917        let wb = Workbook::new();
918        wb.save(&path).unwrap();
919        assert!(path.exists());
920    }
921
922    #[test]
923    fn test_save_and_open_roundtrip() {
924        let dir = TempDir::new().unwrap();
925        let path = dir.path().join("roundtrip.xlsx");
926
927        let wb = Workbook::new();
928        wb.save(&path).unwrap();
929
930        let wb2 = Workbook::open(&path).unwrap();
931        assert_eq!(wb2.sheet_names(), vec!["Sheet1"]);
932    }
933
934    #[test]
935    fn test_saved_file_is_valid_zip() {
936        let dir = TempDir::new().unwrap();
937        let path = dir.path().join("valid.xlsx");
938        let wb = Workbook::new();
939        wb.save(&path).unwrap();
940
941        // Verify it's a valid ZIP with expected entries
942        let file = std::fs::File::open(&path).unwrap();
943        let mut archive = zip::ZipArchive::new(file).unwrap();
944
945        let expected_files = [
946            "[Content_Types].xml",
947            "_rels/.rels",
948            "xl/workbook.xml",
949            "xl/_rels/workbook.xml.rels",
950            "xl/worksheets/sheet1.xml",
951            "xl/styles.xml",
952            "xl/sharedStrings.xml",
953        ];
954
955        for name in &expected_files {
956            assert!(archive.by_name(name).is_ok(), "Missing ZIP entry: {}", name);
957        }
958    }
959
960    #[test]
961    fn test_open_nonexistent_file_returns_error() {
962        let result = Workbook::open("/nonexistent/path.xlsx");
963        assert!(result.is_err());
964    }
965
966    #[test]
967    fn test_saved_xml_has_declarations() {
968        let dir = TempDir::new().unwrap();
969        let path = dir.path().join("decl.xlsx");
970        let wb = Workbook::new();
971        wb.save(&path).unwrap();
972
973        let file = std::fs::File::open(&path).unwrap();
974        let mut archive = zip::ZipArchive::new(file).unwrap();
975
976        let mut content = String::new();
977        std::io::Read::read_to_string(
978            &mut archive.by_name("[Content_Types].xml").unwrap(),
979            &mut content,
980        )
981        .unwrap();
982        assert!(content.starts_with("<?xml"));
983    }
984
985    #[test]
986    fn test_default_trait() {
987        let wb = Workbook::default();
988        assert_eq!(wb.sheet_names(), vec!["Sheet1"]);
989    }
990
991    #[test]
992    fn test_serialize_xml_helper() {
993        let ct = ContentTypes::default();
994        let xml = serialize_xml(&ct).unwrap();
995        assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"));
996        assert!(xml.contains("<Types"));
997    }
998
999    #[cfg(feature = "encryption")]
1000    #[test]
1001    fn test_save_and_open_with_password_roundtrip() {
1002        let dir = TempDir::new().unwrap();
1003        let path = dir.path().join("encrypted.xlsx");
1004
1005        // Create a workbook with some data
1006        let mut wb = Workbook::new();
1007        wb.set_cell_value("Sheet1", "A1", CellValue::String("Hello".to_string()))
1008            .unwrap();
1009        wb.set_cell_value("Sheet1", "B2", CellValue::Number(42.0))
1010            .unwrap();
1011
1012        // Save with password
1013        wb.save_with_password(&path, "test123").unwrap();
1014
1015        // Verify it's a CFB file, not a ZIP
1016        let data = std::fs::read(&path).unwrap();
1017        assert_eq!(
1018            &data[..8],
1019            &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]
1020        );
1021
1022        // Open without password should fail
1023        let result = Workbook::open(&path);
1024        assert!(matches!(result, Err(Error::FileEncrypted)));
1025
1026        // Open with wrong password should fail
1027        let result = Workbook::open_with_password(&path, "wrong");
1028        assert!(matches!(result, Err(Error::IncorrectPassword)));
1029
1030        // Open with correct password should succeed
1031        let wb2 = Workbook::open_with_password(&path, "test123").unwrap();
1032        assert_eq!(
1033            wb2.get_cell_value("Sheet1", "A1").unwrap(),
1034            CellValue::String("Hello".to_string())
1035        );
1036        assert_eq!(
1037            wb2.get_cell_value("Sheet1", "B2").unwrap(),
1038            CellValue::Number(42.0)
1039        );
1040    }
1041
1042    #[cfg(feature = "encryption")]
1043    #[test]
1044    fn test_open_encrypted_file_without_password_returns_file_encrypted() {
1045        let dir = TempDir::new().unwrap();
1046        let path = dir.path().join("encrypted2.xlsx");
1047
1048        let wb = Workbook::new();
1049        wb.save_with_password(&path, "secret").unwrap();
1050
1051        let result = Workbook::open(&path);
1052        assert!(matches!(result, Err(Error::FileEncrypted)))
1053    }
1054}