Skip to main content

ooxml_sml/
workbook.rs

1//! Workbook API for reading and writing Excel files.
2//!
3//! This module provides the main entry point for working with XLSX files.
4
5use crate::error::{Error, Result};
6use crate::ext::{
7    Chart as ExtChart, ChartType as ExtChartType, Comment as ExtComment, ResolvedSheet,
8    parse_worksheet,
9};
10use crate::parsers::FromXml;
11use ooxml_opc::{Package, Relationships};
12use quick_xml::Reader;
13use quick_xml::events::Event;
14use std::fs::File;
15use std::io::{BufReader, Cursor, Read, Seek};
16use std::path::Path;
17
18// Relationship types (ECMA-376 Part 1)
19const REL_OFFICE_DOCUMENT: &str =
20    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument";
21const REL_SHARED_STRINGS: &str =
22    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings";
23const REL_STYLES: &str =
24    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles";
25const REL_COMMENTS: &str =
26    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
27const REL_DRAWING: &str =
28    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing";
29const REL_CHART: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart";
30const REL_CHARTSHEET: &str =
31    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet";
32const REL_PIVOT_TABLE: &str =
33    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable";
34const REL_PIVOT_CACHE_DEFINITION: &str =
35    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition";
36
37/// An Excel workbook.
38///
39/// This is the main entry point for reading XLSX files.
40pub struct Workbook<R: Read + Seek> {
41    package: Package<R>,
42    /// Path to the workbook part (e.g., "xl/workbook.xml").
43    workbook_path: String,
44    /// Workbook-level relationships.
45    workbook_rels: Relationships,
46    /// Sheet metadata (name, relationship ID).
47    sheet_info: Vec<SheetInfo>,
48    /// Shared string table.
49    shared_strings: Vec<String>,
50    /// Stylesheet (number formats, fonts, fills, borders, cell formats).
51    styles: crate::types::Stylesheet,
52    /// Defined names (named ranges).
53    defined_names: Vec<crate::types::DefinedName>,
54    /// Workbook protection settings (if any).
55    #[cfg(feature = "sml-protection")]
56    workbook_protection: Option<crate::types::WorkbookProtection>,
57}
58
59/// Metadata about a sheet.
60#[derive(Debug, Clone)]
61struct SheetInfo {
62    name: String,
63    #[allow(dead_code)]
64    sheet_id: u32,
65    rel_id: String,
66}
67
68impl Workbook<BufReader<File>> {
69    /// Open a workbook from a file path.
70    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
71        let file = File::open(path)?;
72        Self::from_reader(BufReader::new(file))
73    }
74}
75
76impl<R: Read + Seek> Workbook<R> {
77    /// Open a workbook from a reader.
78    pub fn from_reader(reader: R) -> Result<Self> {
79        let mut package = Package::open(reader)?;
80
81        // Find the workbook part via root relationships
82        let root_rels = package.read_relationships()?;
83        let workbook_rel = root_rels
84            .get_by_type(REL_OFFICE_DOCUMENT)
85            .ok_or_else(|| Error::Invalid("Missing workbook relationship".into()))?;
86        let workbook_path = workbook_rel.target.clone();
87
88        // Load workbook relationships
89        let workbook_rels = package
90            .read_part_relationships(&workbook_path)
91            .unwrap_or_default();
92
93        // Parse workbook.xml once to get sheet list and defined names
94        let workbook_xml = package.read_part(&workbook_path)?;
95        let wb: crate::types::Workbook = bootstrap(&workbook_xml)?;
96        let sheet_info = wb
97            .sheets
98            .sheet
99            .iter()
100            .map(|s| SheetInfo {
101                name: s.name.to_string(),
102                sheet_id: s.sheet_id,
103                rel_id: s.id.to_string(),
104            })
105            .collect();
106        let defined_names = wb
107            .defined_names
108            .map(|dn| {
109                let inner = *dn;
110                inner.defined_name
111            })
112            .unwrap_or_default();
113        #[cfg(feature = "sml-protection")]
114        let workbook_protection = wb.workbook_protection.map(|b| *b);
115
116        // Load shared strings if present
117        let shared_strings = if let Some(rel) = workbook_rels.get_by_type(REL_SHARED_STRINGS) {
118            let path = resolve_path(&workbook_path, &rel.target);
119            if let Ok(data) = package.read_part(&path) {
120                let sst: crate::types::SharedStrings = bootstrap(&data)?;
121                extract_shared_strings(&sst)
122            } else {
123                Vec::new()
124            }
125        } else {
126            Vec::new()
127        };
128
129        // Load styles if present
130        let styles = if let Some(rel) = workbook_rels.get_by_type(REL_STYLES) {
131            let path = resolve_path(&workbook_path, &rel.target);
132            if let Ok(data) = package.read_part(&path) {
133                bootstrap(&data)?
134            } else {
135                crate::types::Stylesheet::default()
136            }
137        } else {
138            crate::types::Stylesheet::default()
139        };
140
141        Ok(Self {
142            package,
143            workbook_path,
144            workbook_rels,
145            sheet_info,
146            shared_strings,
147            styles,
148            defined_names,
149            #[cfg(feature = "sml-protection")]
150            workbook_protection,
151        })
152    }
153
154    /// Get the number of sheets in the workbook.
155    pub fn sheet_count(&self) -> usize {
156        self.sheet_info.len()
157    }
158
159    /// Get sheet names.
160    pub fn sheet_names(&self) -> Vec<&str> {
161        self.sheet_info.iter().map(|s| s.name.as_str()).collect()
162    }
163
164    /// Get the workbook stylesheet.
165    pub fn styles(&self) -> &crate::types::Stylesheet {
166        &self.styles
167    }
168
169    /// Get the workbook stylesheet (alias for [`styles`](Self::styles)).
170    pub fn stylesheet(&self) -> Option<&crate::types::Stylesheet> {
171        Some(&self.styles)
172    }
173
174    /// Get the workbook protection settings (if any).
175    ///
176    /// Requires the `sml-protection` feature.
177    ///
178    /// ECMA-376 Part 1, Section 18.2.29 (workbookProtection).
179    #[cfg(feature = "sml-protection")]
180    pub fn workbook_protection(&self) -> Option<&crate::types::WorkbookProtection> {
181        self.workbook_protection.as_ref()
182    }
183
184    /// Access the raw workbook protection for testing.
185    ///
186    /// Requires the `sml-protection` feature.
187    #[cfg(feature = "sml-protection")]
188    pub fn raw_workbook_protection(&self) -> Option<&crate::types::WorkbookProtection> {
189        self.workbook_protection.as_ref()
190    }
191
192    /// Get all defined names (named ranges).
193    pub fn defined_names(&self) -> &[crate::types::DefinedName] {
194        &self.defined_names
195    }
196
197    /// Get a defined name by its name.
198    ///
199    /// For names with sheet scope, use `defined_name_in_sheet` instead.
200    pub fn defined_name(&self, name: &str) -> Option<&crate::types::DefinedName> {
201        self.defined_names
202            .iter()
203            .find(|d| d.name.eq_ignore_ascii_case(name) && d.local_sheet_id.is_none())
204    }
205
206    /// Get a defined name by its name within a specific sheet scope.
207    pub fn defined_name_in_sheet(
208        &self,
209        name: &str,
210        sheet_index: u32,
211    ) -> Option<&crate::types::DefinedName> {
212        self.defined_names
213            .iter()
214            .find(|d| d.name.eq_ignore_ascii_case(name) && d.local_sheet_id == Some(sheet_index))
215    }
216
217    /// Get all global defined names (workbook scope).
218    pub fn global_defined_names(&self) -> impl Iterator<Item = &crate::types::DefinedName> {
219        self.defined_names
220            .iter()
221            .filter(|d| d.local_sheet_id.is_none())
222    }
223
224    /// Get all defined names scoped to a specific sheet.
225    pub fn sheet_defined_names(
226        &self,
227        sheet_index: u32,
228    ) -> impl Iterator<Item = &crate::types::DefinedName> {
229        self.defined_names
230            .iter()
231            .filter(move |d| d.local_sheet_id == Some(sheet_index))
232    }
233
234    /// Load all pivot cache definitions from workbook relationships.
235    ///
236    /// Pivot cache definitions are workbook-level parts linked via relationships of type
237    /// `pivotCacheDefinition`. Each `types::CTPivotTableDefinition` references a cache
238    /// by its `cache_id`, which matches the `r:id` attribute on the cache definition.
239    ///
240    /// ECMA-376 Part 1, Section 18.10 (PivotTable).
241    pub fn pivot_caches(&mut self) -> Result<Vec<crate::types::PivotCacheDefinition>> {
242        let mut caches = Vec::new();
243        let rel_targets: Vec<String> = self
244            .workbook_rels
245            .get_all_by_type(REL_PIVOT_CACHE_DEFINITION)
246            .map(|r| r.target.clone())
247            .collect();
248        for target in rel_targets {
249            let path = resolve_path(&self.workbook_path, &target);
250            if let Ok(data) = self.package.read_part(&path)
251                && let Ok(cache) = bootstrap::<crate::types::PivotCacheDefinition>(&data)
252            {
253                caches.push(cache);
254            }
255        }
256        Ok(caches)
257    }
258
259    // =========================================================================
260    // New API using generated types (ADR-003)
261    // =========================================================================
262
263    /// Get a sheet by index using the new generated parser.
264    ///
265    /// Returns a `ResolvedSheet` which wraps the generated `types::Worksheet`
266    /// and provides automatic value resolution via extension traits.
267    ///
268    /// This is the recommended API for new code.
269    pub fn resolved_sheet(&mut self, index: usize) -> Result<ResolvedSheet> {
270        let info = self
271            .sheet_info
272            .get(index)
273            .ok_or_else(|| Error::Invalid(format!("Sheet index {} out of range", index)))?
274            .clone();
275
276        self.load_resolved_sheet(&info)
277    }
278
279    /// Get a sheet by name using the new generated parser.
280    ///
281    /// Returns a `ResolvedSheet` which wraps the generated `types::Worksheet`
282    /// and provides automatic value resolution via extension traits.
283    pub fn resolved_sheet_by_name(&mut self, name: &str) -> Result<ResolvedSheet> {
284        let info = self
285            .sheet_info
286            .iter()
287            .find(|s| s.name == name)
288            .ok_or_else(|| Error::Invalid(format!("Sheet '{}' not found", name)))?
289            .clone();
290
291        self.load_resolved_sheet(&info)
292    }
293
294    /// Load all sheets using the new generated parser.
295    pub fn resolved_sheets(&mut self) -> Result<Vec<ResolvedSheet>> {
296        let infos: Vec<_> = self.sheet_info.clone();
297        infos
298            .iter()
299            .map(|info| self.load_resolved_sheet(info))
300            .collect()
301    }
302
303    /// Get raw worksheet XML bytes for lazy/streaming parsing.
304    ///
305    /// This returns the raw XML data for a worksheet, which can be used with
306    /// `LazyWorksheet` for memory-efficient streaming access without loading
307    /// all rows into memory.
308    ///
309    /// # Example
310    ///
311    /// ```ignore
312    /// use ooxml_sml::{Workbook, LazyWorksheet};
313    ///
314    /// let mut workbook = Workbook::open("large.xlsx")?;
315    /// let xml = workbook.sheet_xml(0)?;
316    /// let lazy = LazyWorksheet::new(&xml);
317    ///
318    /// // Stream rows without loading all into memory
319    /// for row in lazy.rows() {
320    ///     let row = row?;
321    ///     // Process row...
322    /// }
323    /// ```
324    pub fn sheet_xml(&mut self, index: usize) -> Result<Vec<u8>> {
325        let info = self
326            .sheet_info
327            .get(index)
328            .ok_or_else(|| Error::Invalid(format!("Sheet index {} out of range", index)))?
329            .clone();
330
331        let rel = self.workbook_rels.get(&info.rel_id).ok_or_else(|| {
332            Error::Invalid(format!("Missing relationship for sheet '{}'", info.name))
333        })?;
334
335        let path = resolve_path(&self.workbook_path, &rel.target);
336        Ok(self.package.read_part(&path)?)
337    }
338
339    /// Get raw worksheet XML bytes by sheet name.
340    ///
341    /// See `sheet_xml` for usage with `LazyWorksheet`.
342    pub fn sheet_xml_by_name(&mut self, name: &str) -> Result<Vec<u8>> {
343        let info = self
344            .sheet_info
345            .iter()
346            .find(|s| s.name == name)
347            .ok_or_else(|| Error::Invalid(format!("Sheet '{}' not found", name)))?
348            .clone();
349
350        let rel = self.workbook_rels.get(&info.rel_id).ok_or_else(|| {
351            Error::Invalid(format!("Missing relationship for sheet '{}'", info.name))
352        })?;
353
354        let path = resolve_path(&self.workbook_path, &rel.target);
355        Ok(self.package.read_part(&path)?)
356    }
357
358    /// Load a sheet using the generated parser.
359    fn load_resolved_sheet(&mut self, info: &SheetInfo) -> Result<ResolvedSheet> {
360        // Find the sheet path from relationships
361        let rel = self.workbook_rels.get(&info.rel_id).ok_or_else(|| {
362            Error::Invalid(format!("Missing relationship for sheet '{}'", info.name))
363        })?;
364
365        let path = resolve_path(&self.workbook_path, &rel.target);
366        let data = self.package.read_part(&path)?;
367
368        // Check if this is a chartsheet or regular worksheet
369        let is_chartsheet = rel.relationship_type == REL_CHARTSHEET;
370
371        // Parse the worksheet using generated FromXml parser
372        let worksheet = if is_chartsheet {
373            // Chartsheets don't have the same structure - parse minimal empty worksheet XML
374            // This ensures feature-gated fields are handled correctly by the generated parser
375            let minimal_xml = br#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData/></worksheet>"#;
376            parse_worksheet(minimal_xml)
377                .map_err(|e| Error::Invalid(format!("Chartsheet parse error: {:?}", e)))?
378        } else {
379            parse_worksheet(&data).map_err(|e| Error::Invalid(format!("Parse error: {:?}", e)))?
380        };
381
382        // Load comments, charts, and pivot tables
383        let mut comments = Vec::new();
384        let mut charts = Vec::new();
385        #[cfg(feature = "sml-pivot")]
386        let mut pivot_tables: Vec<crate::types::CTPivotTableDefinition> = Vec::new();
387
388        if let Ok(sheet_rels) = self.package.read_part_relationships(&path) {
389            // Load comments
390            if !is_chartsheet && let Some(comments_rel) = sheet_rels.get_by_type(REL_COMMENTS) {
391                let comments_path = resolve_path(&path, &comments_rel.target);
392                if let Ok(comments_data) = self.package.read_part(&comments_path) {
393                    comments = parse_comments_xml(&comments_data)?;
394                }
395            }
396
397            // Load charts via drawing relationships
398            if let Some(drawing_rel) = sheet_rels.get_by_type(REL_DRAWING) {
399                let drawing_path = resolve_path(&path, &drawing_rel.target);
400                if let Ok(drawing_rels) = self.package.read_part_relationships(&drawing_path) {
401                    for rel in drawing_rels.iter() {
402                        let chart_path = resolve_path(&drawing_path, &rel.target);
403                        if rel.relationship_type == REL_CHART
404                            && let Ok(chart_data) = self.package.read_part(&chart_path)
405                            && let Ok(chart) = parse_chart_ext(&chart_data)
406                        {
407                            charts.push(chart);
408                        }
409                    }
410                }
411            }
412
413            // Load pivot tables from sheet relationships
414            #[cfg(feature = "sml-pivot")]
415            for rel in sheet_rels.get_all_by_type(REL_PIVOT_TABLE) {
416                let pt_path = resolve_path(&path, &rel.target);
417                if let Ok(pt_data) = self.package.read_part(&pt_path)
418                    && let Ok(pt) = bootstrap::<crate::types::CTPivotTableDefinition>(&pt_data)
419                {
420                    pivot_tables.push(pt);
421                }
422            }
423        }
424
425        Ok(ResolvedSheet::with_extras(
426            info.name.clone(),
427            worksheet,
428            self.shared_strings.clone(),
429            comments,
430            charts,
431            #[cfg(feature = "sml-pivot")]
432            pivot_tables,
433        ))
434    }
435}
436
437/// Parse comments using the generated FromXml parser.
438fn parse_comments_xml(xml: &[u8]) -> Result<Vec<ExtComment>> {
439    let comments: crate::types::Comments = bootstrap(xml)?;
440    let authors = comments.authors.author.clone();
441    #[cfg(not(feature = "sml-comments"))]
442    {
443        let _ = (authors, comments.comment_list);
444        return Ok(Vec::new());
445    }
446    #[cfg(feature = "sml-comments")]
447    Ok(comments
448        .comment_list
449        .comment
450        .iter()
451        .map(|c| {
452            let author = authors.get(c.author_id as usize).cloned();
453            let text = rich_string_to_plain(&c.text);
454            ExtComment {
455                reference: c.reference.clone(),
456                author,
457                text,
458            }
459        })
460        .collect())
461}
462
463/// Parse chart for ext::Chart
464fn parse_chart_ext(xml: &[u8]) -> Result<ExtChart> {
465    let old_chart = parse_chart(xml)?;
466    Ok(ExtChart {
467        title: old_chart.title,
468        chart_type: match old_chart.chart_type {
469            ChartType::Bar | ChartType::Bar3D => ExtChartType::Bar,
470            ChartType::Line | ChartType::Line3D => ExtChartType::Line,
471            ChartType::Pie | ChartType::Pie3D => ExtChartType::Pie,
472            ChartType::Area | ChartType::Area3D => ExtChartType::Area,
473            ChartType::Surface | ChartType::Surface3D => ExtChartType::Surface,
474            ChartType::Scatter => ExtChartType::Scatter,
475            ChartType::Doughnut => ExtChartType::Doughnut,
476            ChartType::Radar => ExtChartType::Radar,
477            ChartType::Bubble => ExtChartType::Bubble,
478            ChartType::Stock => ExtChartType::Stock,
479            ChartType::Unknown => ExtChartType::Unknown,
480        },
481    })
482}
483
484/// Type of conditional formatting rule.
485///
486/// Used when writing conditional formatting via [`ConditionalFormat`].
487///
488/// ECMA-376 Part 1, Section 18.18.12 (ST_CfType).
489#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490pub enum ConditionalRuleType {
491    /// Expression-based rule.
492    Expression,
493    /// Cell value comparison.
494    CellIs,
495    /// Color scale gradient.
496    ColorScale,
497    /// Data bar visualization.
498    DataBar,
499    /// Icon set.
500    IconSet,
501    /// Top N values.
502    Top10,
503    /// Unique values.
504    UniqueValues,
505    /// Duplicate values.
506    DuplicateValues,
507    /// Contains specified text.
508    ContainsText,
509    /// Does not contain specified text.
510    NotContainsText,
511    /// Begins with specified text.
512    BeginsWith,
513    /// Ends with specified text.
514    EndsWith,
515    /// Contains blanks.
516    ContainsBlanks,
517    /// Does not contain blanks.
518    NotContainsBlanks,
519    /// Contains errors.
520    ContainsErrors,
521    /// Does not contain errors.
522    NotContainsErrors,
523    /// Time period comparison.
524    TimePeriod,
525    /// Above or below average.
526    AboveAverage,
527}
528
529impl ConditionalRuleType {
530    /// Parse from the cfRule type attribute string.
531    pub fn parse(s: &str) -> Option<Self> {
532        match s {
533            "expression" => Some(Self::Expression),
534            "cellIs" => Some(Self::CellIs),
535            "colorScale" => Some(Self::ColorScale),
536            "dataBar" => Some(Self::DataBar),
537            "iconSet" => Some(Self::IconSet),
538            "top10" => Some(Self::Top10),
539            "uniqueValues" => Some(Self::UniqueValues),
540            "duplicateValues" => Some(Self::DuplicateValues),
541            "containsText" => Some(Self::ContainsText),
542            "notContainsText" => Some(Self::NotContainsText),
543            "beginsWith" => Some(Self::BeginsWith),
544            "endsWith" => Some(Self::EndsWith),
545            "containsBlanks" => Some(Self::ContainsBlanks),
546            "notContainsBlanks" => Some(Self::NotContainsBlanks),
547            "containsErrors" => Some(Self::ContainsErrors),
548            "notContainsErrors" => Some(Self::NotContainsErrors),
549            "timePeriod" => Some(Self::TimePeriod),
550            "aboveAverage" => Some(Self::AboveAverage),
551            _ => None,
552        }
553    }
554
555    /// Convert to XML attribute value.
556    pub fn to_xml_value(self) -> &'static str {
557        match self {
558            Self::Expression => "expression",
559            Self::CellIs => "cellIs",
560            Self::ColorScale => "colorScale",
561            Self::DataBar => "dataBar",
562            Self::IconSet => "iconSet",
563            Self::Top10 => "top10",
564            Self::UniqueValues => "uniqueValues",
565            Self::DuplicateValues => "duplicateValues",
566            Self::ContainsText => "containsText",
567            Self::NotContainsText => "notContainsText",
568            Self::BeginsWith => "beginsWith",
569            Self::EndsWith => "endsWith",
570            Self::ContainsBlanks => "containsBlanks",
571            Self::NotContainsBlanks => "notContainsBlanks",
572            Self::ContainsErrors => "containsErrors",
573            Self::NotContainsErrors => "notContainsErrors",
574            Self::TimePeriod => "timePeriod",
575            Self::AboveAverage => "aboveAverage",
576        }
577    }
578}
579
580/// A chart embedded in a worksheet (workbook-module representation).
581///
582/// Returned by [`Workbook::resolved_sheet`] as part of [`ResolvedSheet`];
583/// see also [`ext::Chart`](crate::ext::Chart) which is the simpler public type.
584#[derive(Debug, Clone)]
585pub struct Chart {
586    title: Option<String>,
587    chart_type: ChartType,
588    series: Vec<ChartSeries>,
589}
590
591impl Chart {
592    /// Get the chart title (if any).
593    pub fn title(&self) -> Option<&str> {
594        self.title.as_deref()
595    }
596
597    /// Get the chart type.
598    pub fn chart_type(&self) -> ChartType {
599        self.chart_type
600    }
601
602    /// Get all data series in the chart.
603    pub fn series(&self) -> &[ChartSeries] {
604        &self.series
605    }
606}
607
608/// The type of chart as reported by the workbook reader.
609///
610/// Covers all chart types defined in ECMA-376 Part 1, §21.2.
611#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
612pub enum ChartType {
613    Area,
614    Area3D,
615    Bar,
616    Bar3D,
617    Bubble,
618    Doughnut,
619    #[default]
620    Line,
621    Line3D,
622    Pie,
623    Pie3D,
624    Radar,
625    Scatter,
626    Stock,
627    Surface,
628    Surface3D,
629    Unknown,
630}
631
632/// A data series within a chart, as parsed by the workbook reader.
633#[derive(Debug, Clone)]
634pub struct ChartSeries {
635    index: u32,
636    name: Option<String>,
637    category_ref: Option<String>,
638    value_ref: Option<String>,
639    categories: Vec<String>,
640    values: Vec<f64>,
641}
642
643impl ChartSeries {
644    /// Get the series index.
645    pub fn index(&self) -> u32 {
646        self.index
647    }
648
649    /// Get the series name (if any).
650    pub fn name(&self) -> Option<&str> {
651        self.name.as_deref()
652    }
653
654    /// Get the category data reference.
655    pub fn category_ref(&self) -> Option<&str> {
656        self.category_ref.as_deref()
657    }
658
659    /// Get the value data reference.
660    pub fn value_ref(&self) -> Option<&str> {
661        self.value_ref.as_deref()
662    }
663
664    /// Get the category labels.
665    pub fn categories(&self) -> &[String] {
666        &self.categories
667    }
668
669    /// Get the numeric values.
670    pub fn values(&self) -> &[f64] {
671        &self.values
672    }
673}
674
675/// Type of data validation.
676///
677/// ECMA-376 Part 1, Section 18.18.21 (ST_DataValidationType).
678#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
679pub enum DataValidationType {
680    /// No validation.
681    #[default]
682    None,
683    /// Whole number.
684    Whole,
685    /// Decimal number.
686    Decimal,
687    /// List of values (dropdown).
688    List,
689    /// Date.
690    Date,
691    /// Time.
692    Time,
693    /// Text length.
694    TextLength,
695    /// Custom formula.
696    Custom,
697}
698
699impl DataValidationType {
700    /// Parse from the dataValidation type attribute.
701    #[allow(dead_code)]
702    fn parse(s: &str) -> Self {
703        match s {
704            "none" => Self::None,
705            "whole" => Self::Whole,
706            "decimal" => Self::Decimal,
707            "list" => Self::List,
708            "date" => Self::Date,
709            "time" => Self::Time,
710            "textLength" => Self::TextLength,
711            "custom" => Self::Custom,
712            _ => Self::None,
713        }
714    }
715
716    /// Convert to XML attribute value.
717    pub fn to_xml_value(self) -> &'static str {
718        match self {
719            Self::None => "none",
720            Self::Whole => "whole",
721            Self::Decimal => "decimal",
722            Self::List => "list",
723            Self::Date => "date",
724            Self::Time => "time",
725            Self::TextLength => "textLength",
726            Self::Custom => "custom",
727        }
728    }
729}
730
731/// Comparison operator for data validation.
732///
733/// ECMA-376 Part 1, Section 18.18.22 (ST_DataValidationOperator).
734#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
735pub enum DataValidationOperator {
736    /// Between formula1 and formula2.
737    #[default]
738    Between,
739    /// Not between formula1 and formula2.
740    NotBetween,
741    /// Equal to formula1.
742    Equal,
743    /// Not equal to formula1.
744    NotEqual,
745    /// Less than formula1.
746    LessThan,
747    /// Less than or equal to formula1.
748    LessThanOrEqual,
749    /// Greater than formula1.
750    GreaterThan,
751    /// Greater than or equal to formula1.
752    GreaterThanOrEqual,
753}
754
755impl DataValidationOperator {
756    /// Parse from the dataValidation operator attribute.
757    #[allow(dead_code)]
758    fn parse(s: &str) -> Self {
759        match s {
760            "between" => Self::Between,
761            "notBetween" => Self::NotBetween,
762            "equal" => Self::Equal,
763            "notEqual" => Self::NotEqual,
764            "lessThan" => Self::LessThan,
765            "lessThanOrEqual" => Self::LessThanOrEqual,
766            "greaterThan" => Self::GreaterThan,
767            "greaterThanOrEqual" => Self::GreaterThanOrEqual,
768            _ => Self::Between,
769        }
770    }
771
772    /// Convert to XML attribute value.
773    pub fn to_xml_value(self) -> &'static str {
774        match self {
775            Self::Between => "between",
776            Self::NotBetween => "notBetween",
777            Self::Equal => "equal",
778            Self::NotEqual => "notEqual",
779            Self::LessThan => "lessThan",
780            Self::LessThanOrEqual => "lessThanOrEqual",
781            Self::GreaterThan => "greaterThan",
782            Self::GreaterThanOrEqual => "greaterThanOrEqual",
783        }
784    }
785}
786
787/// Error alert style for data validation.
788///
789/// ECMA-376 Part 1, Section 18.18.23 (ST_DataValidationErrorStyle).
790#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
791pub enum DataValidationErrorStyle {
792    /// Stop: Prevents the user from entering invalid data.
793    #[default]
794    Stop,
795    /// Warning: Warns the user but allows invalid data.
796    Warning,
797    /// Information: Informs the user but allows invalid data.
798    Information,
799}
800
801impl DataValidationErrorStyle {
802    /// Parse from the dataValidation errorStyle attribute.
803    #[allow(dead_code)]
804    fn parse(s: &str) -> Self {
805        match s {
806            "stop" => Self::Stop,
807            "warning" => Self::Warning,
808            "information" => Self::Information,
809            _ => Self::Stop,
810        }
811    }
812
813    /// Convert to XML attribute value.
814    pub fn to_xml_value(self) -> &'static str {
815        match self {
816            Self::Stop => "stop",
817            Self::Warning => "warning",
818            Self::Information => "information",
819        }
820    }
821}
822
823/// The scope of a defined name: either workbook-global or limited to one sheet.
824#[derive(Debug, Clone, Copy, PartialEq, Eq)]
825pub enum DefinedNameScope {
826    /// Global workbook scope.
827    Workbook,
828    /// Local to a specific sheet (by index).
829    Sheet(u32),
830}
831
832// ============================================================================
833// Extension Traits for Generated Types
834// ============================================================================
835
836/// Extension methods for the generated [`types::Stylesheet`](crate::types::Stylesheet).
837pub trait StylesheetExt {
838    /// Get a number format code by ID.
839    ///
840    /// Looks up custom formats first, then falls back to built-in formats.
841    fn format_code(&self, id: u32) -> Option<String>;
842
843    /// Check if a format ID represents a date/time format.
844    fn is_date_format(&self, id: u32) -> bool;
845}
846
847impl StylesheetExt for crate::types::Stylesheet {
848    fn format_code(&self, id: u32) -> Option<String> {
849        #[cfg(feature = "sml-styling")]
850        if let Some(num_fmts) = &self.num_fmts
851            && let Some(fmt) = num_fmts.num_fmt.iter().find(|f| f.number_format_id == id)
852        {
853            return Some(fmt.format_code.clone());
854        }
855        builtin_format_code(id).map(|s| s.to_string())
856    }
857
858    fn is_date_format(&self, id: u32) -> bool {
859        if let Some(code) = self.format_code(id) {
860            is_date_format_code(&code)
861        } else {
862            // Check built-in date format IDs (14-22, 45-47)
863            matches!(id, 14..=22 | 45..=47)
864        }
865    }
866}
867
868/// Extension methods for the generated [`types::DefinedName`](crate::types::DefinedName).
869pub trait DefinedNameExt {
870    /// Check if this is a built-in Excel name (prefixed with "_xlnm.").
871    ///
872    /// Built-in names include:
873    /// - _xlnm.Print_Area
874    /// - _xlnm.Print_Titles
875    /// - _xlnm._FilterDatabase
876    /// - _xlnm.Criteria
877    /// - _xlnm.Extract
878    fn is_builtin(&self) -> bool;
879
880    /// Get the scope of this defined name.
881    fn scope(&self) -> DefinedNameScope;
882}
883
884impl DefinedNameExt for crate::types::DefinedName {
885    fn is_builtin(&self) -> bool {
886        self.name.starts_with("_xlnm.")
887    }
888
889    fn scope(&self) -> DefinedNameScope {
890        match self.local_sheet_id {
891            Some(id) => DefinedNameScope::Sheet(id),
892            None => DefinedNameScope::Workbook,
893        }
894    }
895}
896
897// ============================================================================
898// Utility Functions
899// ============================================================================
900
901/// Get the format code for a built-in format ID.
902///
903/// Excel has built-in formats with IDs 0-49. Custom formats start at 164.
904/// Reference: ECMA-376 Part 1, Section 18.8.30.
905pub fn builtin_format_code(id: u32) -> Option<&'static str> {
906    match id {
907        0 => Some("General"),
908        1 => Some("0"),
909        2 => Some("0.00"),
910        3 => Some("#,##0"),
911        4 => Some("#,##0.00"),
912        9 => Some("0%"),
913        10 => Some("0.00%"),
914        11 => Some("0.00E+00"),
915        12 => Some("# ?/?"),
916        13 => Some("# ??/??"),
917        14 => Some("mm-dd-yy"),
918        15 => Some("d-mmm-yy"),
919        16 => Some("d-mmm"),
920        17 => Some("mmm-yy"),
921        18 => Some("h:mm AM/PM"),
922        19 => Some("h:mm:ss AM/PM"),
923        20 => Some("h:mm"),
924        21 => Some("h:mm:ss"),
925        22 => Some("m/d/yy h:mm"),
926        37 => Some("#,##0 ;(#,##0)"),
927        38 => Some("#,##0 ;[Red](#,##0)"),
928        39 => Some("#,##0.00;(#,##0.00)"),
929        40 => Some("#,##0.00;[Red](#,##0.00)"),
930        45 => Some("mm:ss"),
931        46 => Some("[h]:mm:ss"),
932        47 => Some("mmss.0"),
933        48 => Some("##0.0E+0"),
934        49 => Some("@"),
935        _ => None,
936    }
937}
938
939/// Convert an Excel serial date number to (year, month, day).
940///
941/// Excel stores dates as the number of days since 1899-12-30 (in the 1900 system).
942/// Serial 1 = January 1, 1900.
943/// Note: Excel incorrectly treats 1900 as a leap year (Feb 29, 1900 = serial 60).
944pub fn excel_date_to_ymd(serial: f64) -> Option<(i32, u32, u32)> {
945    if serial < 1.0 {
946        return None;
947    }
948
949    let mut days = serial.floor() as i32;
950
951    // Handle Excel's leap year bug: serial 60 = Feb 29, 1900 which doesn't exist
952    // For dates after this, we need to subtract 1
953    if days > 60 {
954        days -= 1;
955    } else if days == 60 {
956        // Feb 29, 1900 doesn't really exist, but Excel thinks it does
957        return Some((1900, 2, 29));
958    }
959
960    // days is now the actual number of days since Dec 31, 1899
961    // day 1 = Jan 1, 1900
962    days -= 1; // Convert to 0-based
963
964    // Calculate year
965    let mut year = 1900;
966    loop {
967        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
968        if days < days_in_year {
969            break;
970        }
971        days -= days_in_year;
972        year += 1;
973    }
974
975    // Calculate month and day
976    let leap = is_leap_year(year);
977    let days_in_months: [i32; 12] = if leap {
978        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
979    } else {
980        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
981    };
982
983    let mut month = 1u32;
984    for &dim in &days_in_months {
985        if days < dim {
986            break;
987        }
988        days -= dim;
989        month += 1;
990    }
991
992    Some((year, month, (days + 1) as u32))
993}
994
995/// Check if a year is a leap year.
996fn is_leap_year(year: i32) -> bool {
997    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
998}
999
1000/// Convert an Excel serial date/time to (year, month, day, hour, minute, second).
1001pub fn excel_datetime_to_ymdhms(serial: f64) -> Option<(i32, u32, u32, u32, u32, u32)> {
1002    let (y, m, d) = excel_date_to_ymd(serial)?;
1003
1004    // Extract time from fractional part
1005    let time_fraction = serial.fract();
1006    let total_seconds = (time_fraction * 86400.0).round() as u32;
1007    let hours = total_seconds / 3600;
1008    let minutes = (total_seconds % 3600) / 60;
1009    let seconds = total_seconds % 60;
1010
1011    Some((y, m, d, hours, minutes, seconds))
1012}
1013
1014/// Format an Excel date serial number as a string (YYYY-MM-DD).
1015pub fn format_excel_date(serial: f64) -> Option<String> {
1016    let (y, m, d) = excel_date_to_ymd(serial)?;
1017    Some(format!("{:04}-{:02}-{:02}", y, m, d))
1018}
1019
1020/// Format an Excel datetime serial number as a string (YYYY-MM-DD HH:MM:SS).
1021pub fn format_excel_datetime(serial: f64) -> Option<String> {
1022    let (y, m, d, h, min, s) = excel_datetime_to_ymdhms(serial)?;
1023    if h == 0 && min == 0 && s == 0 {
1024        Some(format!("{:04}-{:02}-{:02}", y, m, d))
1025    } else {
1026        Some(format!(
1027            "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
1028            y, m, d, h, min, s
1029        ))
1030    }
1031}
1032
1033/// Check if a format code represents a date/time format.
1034fn is_date_format_code(code: &str) -> bool {
1035    // Skip color codes like [Red], [Color1], etc.
1036    let code = code.to_lowercase();
1037
1038    // Remove sections in square brackets (colors, conditions)
1039    let mut clean = String::new();
1040    let mut in_bracket = false;
1041    for c in code.chars() {
1042        match c {
1043            '[' => in_bracket = true,
1044            ']' => in_bracket = false,
1045            _ if !in_bracket => clean.push(c),
1046            _ => {}
1047        }
1048    }
1049
1050    // Check for date/time tokens (not preceded by backslash escape)
1051    let date_tokens = ["y", "m", "d", "h", "s"];
1052    for token in date_tokens {
1053        if clean.contains(token) {
1054            // Make sure it's not just in a string literal
1055            return true;
1056        }
1057    }
1058
1059    false
1060}
1061
1062// ============================================================================
1063// Private Parsing Helpers
1064// ============================================================================
1065
1066/// Bootstrap a generated type from raw XML bytes.
1067///
1068/// Scans the XML for the first element and calls `T::from_xml` on it.
1069pub(crate) fn bootstrap<T: FromXml>(xml: &[u8]) -> Result<T> {
1070    let mut reader = Reader::from_reader(Cursor::new(xml));
1071    let mut buf = Vec::new();
1072    loop {
1073        match reader.read_event_into(&mut buf) {
1074            Ok(Event::Start(e)) => {
1075                return T::from_xml(&mut reader, &e, false)
1076                    .map_err(|e| Error::Invalid(format!("{e:?}")));
1077            }
1078            Ok(Event::Empty(e)) => {
1079                return T::from_xml(&mut reader, &e, true)
1080                    .map_err(|e| Error::Invalid(format!("{e:?}")));
1081            }
1082            Ok(Event::Eof) => break,
1083            Err(e) => return Err(Error::Xml(e)),
1084            _ => {}
1085        }
1086        buf.clear();
1087    }
1088    Err(Error::Invalid("element not found".into()))
1089}
1090
1091/// Extract plain strings from a SharedStrings SST.
1092fn extract_shared_strings(sst: &crate::types::SharedStrings) -> Vec<String> {
1093    sst.si
1094        .iter()
1095        .map(|si| {
1096            if let Some(t) = &si.cell_type {
1097                t.clone()
1098            } else {
1099                si.reference.iter().map(|r| r.cell_type.as_str()).collect()
1100            }
1101        })
1102        .collect()
1103}
1104
1105/// Extract plain text from a RichString.
1106fn rich_string_to_plain(rs: &crate::types::RichString) -> String {
1107    if let Some(t) = &rs.cell_type {
1108        t.clone()
1109    } else {
1110        rs.reference.iter().map(|r| r.cell_type.as_str()).collect()
1111    }
1112}
1113
1114/// Parse a chart XML file.
1115#[allow(dead_code)]
1116fn parse_chart(xml: &[u8]) -> Result<Chart> {
1117    let mut reader = Reader::from_reader(Cursor::new(xml));
1118    let mut buf = Vec::new();
1119
1120    let mut title: Option<String> = None;
1121    let mut chart_type = ChartType::Unknown;
1122    let mut series: Vec<ChartSeries> = Vec::new();
1123
1124    let mut in_chart = false;
1125    let mut in_plot_area = false;
1126    let mut in_title = false;
1127    let mut in_title_tx = false;
1128    let mut in_title_rich = false;
1129    let mut in_title_p = false;
1130    let mut in_title_r = false;
1131    let mut in_title_t = false;
1132    let mut in_ser = false;
1133    let mut in_cat = false;
1134    let mut in_val = false;
1135    let mut in_str_ref = false;
1136    let mut in_num_ref = false;
1137    let mut in_str_cache = false;
1138    let mut in_num_cache = false;
1139    let mut in_pt = false;
1140    let mut in_v = false;
1141    let mut in_f = false;
1142    let mut in_tx = false;
1143    let mut in_ser_name_str_ref = false;
1144
1145    let mut title_text = String::new();
1146    let mut current_series_idx: u32 = 0;
1147    let mut current_series_name: Option<String> = None;
1148    let mut current_cat_ref: Option<String> = None;
1149    let mut current_val_ref: Option<String> = None;
1150    let mut current_cat_values: Vec<String> = Vec::new();
1151    let mut current_val_values: Vec<f64> = Vec::new();
1152    let mut current_ref = String::new();
1153    let mut current_v = String::new();
1154
1155    loop {
1156        match reader.read_event_into(&mut buf) {
1157            Ok(Event::Start(e)) => {
1158                let name = e.local_name();
1159                let name = name.as_ref();
1160                match name {
1161                    b"chart" => in_chart = true,
1162                    b"plotArea" if in_chart => in_plot_area = true,
1163                    b"lineChart" | b"line3DChart" if in_plot_area => {
1164                        chart_type = if name == b"line3DChart" {
1165                            ChartType::Line3D
1166                        } else {
1167                            ChartType::Line
1168                        };
1169                    }
1170                    b"barChart" | b"bar3DChart" if in_plot_area => {
1171                        chart_type = if name == b"bar3DChart" {
1172                            ChartType::Bar3D
1173                        } else {
1174                            ChartType::Bar
1175                        };
1176                    }
1177                    b"areaChart" | b"area3DChart" if in_plot_area => {
1178                        chart_type = if name == b"area3DChart" {
1179                            ChartType::Area3D
1180                        } else {
1181                            ChartType::Area
1182                        };
1183                    }
1184                    b"pieChart" | b"pie3DChart" if in_plot_area => {
1185                        chart_type = if name == b"pie3DChart" {
1186                            ChartType::Pie3D
1187                        } else {
1188                            ChartType::Pie
1189                        };
1190                    }
1191                    b"doughnutChart" if in_plot_area => chart_type = ChartType::Doughnut,
1192                    b"scatterChart" if in_plot_area => chart_type = ChartType::Scatter,
1193                    b"bubbleChart" if in_plot_area => chart_type = ChartType::Bubble,
1194                    b"radarChart" if in_plot_area => chart_type = ChartType::Radar,
1195                    b"stockChart" if in_plot_area => chart_type = ChartType::Stock,
1196                    b"surfaceChart" | b"surface3DChart" if in_plot_area => {
1197                        chart_type = if name == b"surface3DChart" {
1198                            ChartType::Surface3D
1199                        } else {
1200                            ChartType::Surface
1201                        };
1202                    }
1203                    b"title" if in_chart && !in_plot_area => {
1204                        in_title = true;
1205                        title_text.clear();
1206                    }
1207                    b"tx" if in_title => in_title_tx = true,
1208                    b"rich" if in_title_tx => in_title_rich = true,
1209                    b"p" if in_title_rich => in_title_p = true,
1210                    b"r" if in_title_p => in_title_r = true,
1211                    b"t" if in_title_r => in_title_t = true,
1212                    b"ser" if in_plot_area => {
1213                        in_ser = true;
1214                        current_series_idx = 0;
1215                        current_series_name = None;
1216                        current_cat_ref = None;
1217                        current_val_ref = None;
1218                        current_cat_values.clear();
1219                        current_val_values.clear();
1220                    }
1221                    b"idx" if in_ser => {
1222                        for attr in e.attributes().filter_map(|a| a.ok()) {
1223                            if attr.key.as_ref() == b"val" {
1224                                current_series_idx =
1225                                    String::from_utf8_lossy(&attr.value).parse().unwrap_or(0);
1226                            }
1227                        }
1228                    }
1229                    b"tx" if in_ser && !in_cat && !in_val => in_tx = true,
1230                    b"strRef" if in_tx => in_ser_name_str_ref = true,
1231                    b"v" if in_ser_name_str_ref => in_v = true,
1232                    b"cat" if in_ser => in_cat = true,
1233                    b"val" if in_ser => in_val = true,
1234                    b"strRef" if in_cat || in_val => {
1235                        in_str_ref = true;
1236                        current_ref.clear();
1237                    }
1238                    b"numRef" if in_cat || in_val => {
1239                        in_num_ref = true;
1240                        current_ref.clear();
1241                    }
1242                    b"strCache" if in_str_ref => in_str_cache = true,
1243                    b"numCache" if in_num_ref => in_num_cache = true,
1244                    b"pt" if in_str_cache || in_num_cache => {
1245                        in_pt = true;
1246                        current_v.clear();
1247                    }
1248                    b"v" if in_pt => in_v = true,
1249                    b"f" if (in_str_ref || in_num_ref) && !in_str_cache && !in_num_cache => {
1250                        in_f = true;
1251                        current_ref.clear();
1252                    }
1253                    _ => {}
1254                }
1255            }
1256            Ok(Event::Text(e)) => {
1257                let text = e.decode().unwrap_or_default();
1258                if in_title_t {
1259                    title_text.push_str(&text);
1260                } else if in_v {
1261                    current_v.push_str(&text);
1262                } else if in_f {
1263                    current_ref.push_str(&text);
1264                }
1265            }
1266            Ok(Event::End(e)) => {
1267                let name = e.local_name();
1268                let name = name.as_ref();
1269                match name {
1270                    b"chart" => in_chart = false,
1271                    b"plotArea" => in_plot_area = false,
1272                    b"title" if in_title => {
1273                        in_title = false;
1274                        if !title_text.is_empty() {
1275                            title = Some(std::mem::take(&mut title_text));
1276                        }
1277                    }
1278                    b"tx" if in_title => in_title_tx = false,
1279                    b"rich" if in_title_rich => in_title_rich = false,
1280                    b"p" if in_title_p => in_title_p = false,
1281                    b"r" if in_title_r => in_title_r = false,
1282                    b"t" if in_title_t => in_title_t = false,
1283                    b"ser" if in_ser => {
1284                        in_ser = false;
1285                        series.push(ChartSeries {
1286                            index: current_series_idx,
1287                            name: current_series_name.take(),
1288                            category_ref: current_cat_ref.take(),
1289                            value_ref: current_val_ref.take(),
1290                            categories: std::mem::take(&mut current_cat_values),
1291                            values: std::mem::take(&mut current_val_values),
1292                        });
1293                    }
1294                    b"tx" if in_tx => in_tx = false,
1295                    b"strRef" if in_ser_name_str_ref => in_ser_name_str_ref = false,
1296                    b"v" if in_v => {
1297                        in_v = false;
1298                        if in_pt {
1299                            if in_str_cache && in_cat {
1300                                current_cat_values.push(std::mem::take(&mut current_v));
1301                            } else if in_num_cache {
1302                                if let Ok(v) = current_v.parse::<f64>() {
1303                                    if in_cat {
1304                                        current_cat_values.push(current_v.clone());
1305                                    } else if in_val {
1306                                        current_val_values.push(v);
1307                                    }
1308                                }
1309                                current_v.clear();
1310                            }
1311                        } else if in_ser_name_str_ref {
1312                            current_series_name = Some(std::mem::take(&mut current_v));
1313                        }
1314                    }
1315                    b"cat" if in_cat => in_cat = false,
1316                    b"val" if in_val => in_val = false,
1317                    b"strRef" if in_str_ref => {
1318                        in_str_ref = false;
1319                        if in_cat && !current_ref.is_empty() {
1320                            current_cat_ref = Some(std::mem::take(&mut current_ref));
1321                        }
1322                    }
1323                    b"numRef" if in_num_ref => {
1324                        in_num_ref = false;
1325                        if in_cat && current_cat_ref.is_none() && !current_ref.is_empty() {
1326                            current_cat_ref = Some(std::mem::take(&mut current_ref));
1327                        } else if in_val && !current_ref.is_empty() {
1328                            current_val_ref = Some(std::mem::take(&mut current_ref));
1329                        }
1330                    }
1331                    b"strCache" if in_str_cache => in_str_cache = false,
1332                    b"numCache" if in_num_cache => in_num_cache = false,
1333                    b"pt" if in_pt => in_pt = false,
1334                    b"f" if in_f => {
1335                        in_f = false;
1336                        if (in_str_ref || in_num_ref) && !current_ref.is_empty() {
1337                            if in_cat && current_cat_ref.is_none() {
1338                                current_cat_ref = Some(current_ref.clone());
1339                            } else if in_val && current_val_ref.is_none() {
1340                                current_val_ref = Some(current_ref.clone());
1341                            }
1342                        }
1343                    }
1344                    _ => {}
1345                }
1346            }
1347            Ok(Event::Eof) => break,
1348            Err(e) => return Err(Error::Xml(e)),
1349            _ => {}
1350        }
1351        buf.clear();
1352    }
1353
1354    Ok(Chart {
1355        title,
1356        chart_type,
1357        series,
1358    })
1359}
1360
1361// ============================================================================
1362/// Resolve a relative path against a base path.
1363fn resolve_path(base: &str, target: &str) -> String {
1364    let has_leading_slash = base.starts_with('/');
1365
1366    if target.starts_with('/') {
1367        return target.to_string();
1368    }
1369
1370    // Get the directory of the base path
1371    let base_dir = if let Some(idx) = base.rfind('/') {
1372        &base[..idx]
1373    } else {
1374        ""
1375    };
1376
1377    // Build path segments, handling ".." for parent directory
1378    let mut parts: Vec<&str> = base_dir.split('/').filter(|s| !s.is_empty()).collect();
1379    for segment in target.split('/') {
1380        match segment {
1381            ".." => {
1382                parts.pop();
1383            }
1384            "." | "" => {}
1385            _ => {
1386                parts.push(segment);
1387            }
1388        }
1389    }
1390
1391    let result = parts.join("/");
1392    if has_leading_slash {
1393        format!("/{}", result)
1394    } else {
1395        result
1396    }
1397}
1398
1399#[cfg(test)]
1400mod tests {
1401    use super::*;
1402
1403    #[test]
1404    fn test_resolve_path() {
1405        assert_eq!(
1406            resolve_path("/xl/workbook.xml", "worksheets/sheet1.xml"),
1407            "/xl/worksheets/sheet1.xml"
1408        );
1409        assert_eq!(
1410            resolve_path("/xl/workbook.xml", "/xl/sharedStrings.xml"),
1411            "/xl/sharedStrings.xml"
1412        );
1413        // Parent directory handling
1414        assert_eq!(
1415            resolve_path("/xl/chartsheets/sheet1.xml", "../drawings/drawing1.xml"),
1416            "/xl/drawings/drawing1.xml"
1417        );
1418        assert_eq!(
1419            resolve_path("/xl/worksheets/sheet1.xml", "../comments1.xml"),
1420            "/xl/comments1.xml"
1421        );
1422    }
1423
1424    #[test]
1425    fn test_bootstrap_shared_strings() {
1426        let xml = r#"<?xml version="1.0"?>
1427        <sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
1428            <si><t>Hello</t></si>
1429            <si><t>World</t></si>
1430            <si><t></t></si>
1431        </sst>"#;
1432
1433        let sst: crate::types::SharedStrings = bootstrap(xml.as_bytes()).unwrap();
1434        let strings = extract_shared_strings(&sst);
1435        assert_eq!(strings, vec!["Hello", "World", ""]);
1436    }
1437
1438    #[test]
1439    #[cfg(feature = "sml-styling")]
1440    fn test_stylesheet_ext() {
1441        let xml = r#"<?xml version="1.0"?>
1442        <styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
1443            <numFmts count="1">
1444                <numFmt numFmtId="164" formatCode="0.00%"/>
1445            </numFmts>
1446        </styleSheet>"#;
1447
1448        let styles: crate::types::Stylesheet = bootstrap(xml.as_bytes()).unwrap();
1449
1450        // Custom format lookup
1451        assert_eq!(styles.format_code(164), Some("0.00%".to_string()));
1452
1453        // Built-in format fallback
1454        assert_eq!(styles.format_code(14), Some("mm-dd-yy".to_string()));
1455        assert_eq!(styles.format_code(0), Some("General".to_string()));
1456        assert_eq!(styles.format_code(100), None);
1457
1458        // Date format detection
1459        assert!(styles.is_date_format(14));
1460        assert!(!styles.is_date_format(0));
1461        assert!(!styles.is_date_format(164)); // "0.00%" is not a date format
1462    }
1463
1464    #[test]
1465    #[cfg(feature = "sml-comments")]
1466    fn test_parse_comments_xml() {
1467        let xml = r#"<?xml version="1.0"?>
1468        <comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
1469            <authors>
1470                <author>John Doe</author>
1471                <author>Jane Smith</author>
1472            </authors>
1473            <commentList>
1474                <comment ref="A1" authorId="0">
1475                    <text><t>This is a comment on A1</t></text>
1476                </comment>
1477                <comment ref="B2" authorId="1">
1478                    <text>
1479                        <r><t>Multi-line</t></r>
1480                        <r><t> comment</t></r>
1481                    </text>
1482                </comment>
1483            </commentList>
1484        </comments>"#;
1485
1486        let comments = parse_comments_xml(xml.as_bytes()).unwrap();
1487        assert_eq!(comments.len(), 2);
1488
1489        assert_eq!(comments[0].reference, "A1");
1490        assert_eq!(comments[0].author, Some("John Doe".to_string()));
1491        assert_eq!(comments[0].text, "This is a comment on A1");
1492
1493        assert_eq!(comments[1].reference, "B2");
1494        assert_eq!(comments[1].author, Some("Jane Smith".to_string()));
1495        assert_eq!(comments[1].text, "Multi-line comment");
1496    }
1497
1498    #[test]
1499    fn test_bootstrap_defined_names() {
1500        let xml = r#"<?xml version="1.0"?>
1501        <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
1502                  xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
1503            <sheets>
1504                <sheet name="Sheet1" sheetId="1" r:id="rId1"/>
1505            </sheets>
1506            <definedNames>
1507                <definedName name="MyRange">Sheet1!$A$1:$B$10</definedName>
1508                <definedName name="LocalName" localSheetId="0">Sheet1!$C$1:$C$5</definedName>
1509                <definedName name="_xlnm.Print_Area" localSheetId="0" comment="Print area">Sheet1!$A$1:$F$20</definedName>
1510            </definedNames>
1511        </workbook>"#;
1512
1513        let wb: crate::types::Workbook = bootstrap(xml.as_bytes()).unwrap();
1514        let names = wb
1515            .defined_names
1516            .map(|dn| {
1517                let inner = *dn;
1518                inner.defined_name
1519            })
1520            .unwrap_or_default();
1521        assert_eq!(names.len(), 3);
1522
1523        // Global name
1524        assert_eq!(names[0].name, "MyRange");
1525        assert_eq!(names[0].text.as_deref(), Some("Sheet1!$A$1:$B$10"));
1526        assert!(names[0].local_sheet_id.is_none());
1527        assert!(!names[0].is_builtin());
1528        assert_eq!(names[0].scope(), DefinedNameScope::Workbook);
1529
1530        // Local name
1531        assert_eq!(names[1].name, "LocalName");
1532        assert_eq!(names[1].local_sheet_id, Some(0));
1533        assert_eq!(names[1].scope(), DefinedNameScope::Sheet(0));
1534
1535        // Built-in name with comment
1536        assert_eq!(names[2].name, "_xlnm.Print_Area");
1537        assert!(names[2].is_builtin());
1538        assert_eq!(names[2].comment.as_deref(), Some("Print area"));
1539    }
1540
1541    #[test]
1542    fn test_excel_date_conversion() {
1543        // Test some known dates
1544        // January 1, 2000 = serial 36526
1545        assert_eq!(excel_date_to_ymd(36526.0), Some((2000, 1, 1)));
1546
1547        // December 31, 1999 = serial 36525
1548        assert_eq!(excel_date_to_ymd(36525.0), Some((1999, 12, 31)));
1549
1550        // January 1, 1900 = serial 1
1551        assert_eq!(excel_date_to_ymd(1.0), Some((1900, 1, 1)));
1552
1553        // March 1, 1900 = serial 61 (after the leap year bug)
1554        assert_eq!(excel_date_to_ymd(61.0), Some((1900, 3, 1)));
1555
1556        // Test datetime
1557        // Noon on Jan 1, 2000 = 36526.5
1558        assert_eq!(
1559            excel_datetime_to_ymdhms(36526.5),
1560            Some((2000, 1, 1, 12, 0, 0))
1561        );
1562
1563        // Format functions
1564        assert_eq!(format_excel_date(36526.0), Some("2000-01-01".to_string()));
1565        assert_eq!(
1566            format_excel_datetime(36526.5),
1567            Some("2000-01-01 12:00:00".to_string())
1568        );
1569    }
1570
1571    #[test]
1572    fn test_builtin_format_codes() {
1573        assert_eq!(builtin_format_code(0), Some("General"));
1574        assert_eq!(builtin_format_code(1), Some("0"));
1575        assert_eq!(builtin_format_code(14), Some("mm-dd-yy"));
1576        assert_eq!(builtin_format_code(22), Some("m/d/yy h:mm"));
1577        assert_eq!(builtin_format_code(49), Some("@"));
1578        assert_eq!(builtin_format_code(100), None);
1579    }
1580
1581    #[test]
1582    fn test_is_date_format() {
1583        assert!(is_date_format_code("mm-dd-yy"));
1584        assert!(is_date_format_code("yyyy-mm-dd"));
1585        assert!(is_date_format_code("d-mmm-yy"));
1586        assert!(is_date_format_code("h:mm:ss"));
1587        assert!(is_date_format_code("[Red]yyyy-mm-dd")); // With color code
1588        assert!(!is_date_format_code("0.00"));
1589        assert!(!is_date_format_code("#,##0"));
1590        assert!(!is_date_format_code("General"));
1591        assert!(!is_date_format_code("@")); // Text format
1592    }
1593}