Skip to main content

ooxml_sml/
writer.rs

1//! Excel workbook writing support.
2//!
3//! This module provides `WorkbookBuilder` for creating new Excel files.
4//!
5//! # Example
6//!
7//! ```no_run
8//! use ooxml_sml::WorkbookBuilder;
9//!
10//! let mut wb = WorkbookBuilder::new();
11//! let sheet = wb.add_sheet("Sheet1");
12//! sheet.set_cell("A1", "Hello");
13//! sheet.set_cell("B1", 42.0);
14//! sheet.set_cell("A2", true);
15//! wb.save("output.xlsx")?;
16//! # Ok::<(), ooxml_sml::Error>(())
17//! ```
18
19use crate::error::Result;
20use crate::generated_serializers::ToXml;
21use crate::types;
22use ooxml_opc::PackageWriter;
23use std::collections::HashMap;
24use std::fs::File;
25use std::io::{BufWriter, Seek, Write};
26use std::path::Path;
27
28// Content types
29const CT_WORKBOOK: &str =
30    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml";
31const CT_WORKSHEET: &str =
32    "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml";
33const CT_SHARED_STRINGS: &str =
34    "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml";
35const CT_STYLES: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml";
36const CT_COMMENTS: &str =
37    "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml";
38const CT_RELATIONSHIPS: &str = "application/vnd.openxmlformats-package.relationships+xml";
39const CT_XML: &str = "application/xml";
40#[cfg(feature = "sml-charts")]
41const CT_DRAWING: &str = "application/vnd.openxmlformats-officedocument.drawing+xml";
42#[cfg(feature = "sml-charts")]
43const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml";
44#[cfg(feature = "sml-pivot")]
45const CT_PIVOT_TABLE: &str =
46    "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml";
47#[cfg(feature = "sml-pivot")]
48const CT_PIVOT_CACHE_DEF: &str =
49    "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml";
50#[cfg(feature = "sml-pivot")]
51const CT_PIVOT_CACHE_REC: &str =
52    "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml";
53
54// Relationship types
55const REL_OFFICE_DOCUMENT: &str =
56    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument";
57const REL_WORKSHEET: &str =
58    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet";
59const REL_SHARED_STRINGS: &str =
60    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings";
61const REL_STYLES: &str =
62    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles";
63const REL_COMMENTS: &str =
64    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
65const REL_HYPERLINK: &str =
66    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
67#[cfg(feature = "sml-charts")]
68const REL_DRAWING: &str =
69    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing";
70#[cfg(feature = "sml-charts")]
71const REL_CHART: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart";
72#[cfg(feature = "sml-pivot")]
73const REL_PIVOT_TABLE: &str =
74    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable";
75#[cfg(feature = "sml-pivot")]
76const REL_PIVOT_CACHE_DEF: &str =
77    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition";
78#[cfg(feature = "sml-pivot")]
79const REL_PIVOT_CACHE_REC: &str =
80    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheRecords";
81
82// Namespaces
83const NS_SPREADSHEET: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
84const NS_RELATIONSHIPS: &str =
85    "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
86
87/// A value that can be written to a cell.
88#[derive(Debug, Clone)]
89pub enum WriteCellValue {
90    /// String value.
91    String(String),
92    /// Numeric value.
93    Number(f64),
94    /// Boolean value.
95    Boolean(bool),
96    /// Formula (the formula text, not the result).
97    Formula(String),
98    /// Empty cell.
99    Empty,
100}
101
102impl From<&str> for WriteCellValue {
103    fn from(s: &str) -> Self {
104        WriteCellValue::String(s.to_string())
105    }
106}
107
108impl From<String> for WriteCellValue {
109    fn from(s: String) -> Self {
110        WriteCellValue::String(s)
111    }
112}
113
114impl From<f64> for WriteCellValue {
115    fn from(n: f64) -> Self {
116        WriteCellValue::Number(n)
117    }
118}
119
120impl From<i32> for WriteCellValue {
121    fn from(n: i32) -> Self {
122        WriteCellValue::Number(n as f64)
123    }
124}
125
126impl From<i64> for WriteCellValue {
127    fn from(n: i64) -> Self {
128        WriteCellValue::Number(n as f64)
129    }
130}
131
132impl From<bool> for WriteCellValue {
133    fn from(b: bool) -> Self {
134        WriteCellValue::Boolean(b)
135    }
136}
137
138/// A cell style for formatting.
139///
140/// Use `CellStyleBuilder` to create styles, then apply them with `set_cell_style`.
141#[derive(Debug, Clone, Default, PartialEq)]
142pub struct CellStyle {
143    /// Font formatting.
144    pub font: Option<FontStyle>,
145    /// Fill (background) formatting.
146    pub fill: Option<FillStyle>,
147    /// Border formatting.
148    pub border: Option<BorderStyle>,
149    /// Number format code (e.g., "0.00", "#,##0", "yyyy-mm-dd").
150    pub number_format: Option<String>,
151    /// Horizontal alignment.
152    pub horizontal_alignment: Option<HorizontalAlignment>,
153    /// Vertical alignment.
154    pub vertical_alignment: Option<VerticalAlignment>,
155    /// Text wrap.
156    pub wrap_text: bool,
157}
158
159impl CellStyle {
160    /// Create a new empty cell style.
161    pub fn new() -> Self {
162        Self::default()
163    }
164
165    /// Set the font style.
166    pub fn with_font(mut self, font: FontStyle) -> Self {
167        self.font = Some(font);
168        self
169    }
170
171    /// Set the fill style.
172    pub fn with_fill(mut self, fill: FillStyle) -> Self {
173        self.fill = Some(fill);
174        self
175    }
176
177    /// Set the border style.
178    pub fn with_border(mut self, border: BorderStyle) -> Self {
179        self.border = Some(border);
180        self
181    }
182
183    /// Set the number format code.
184    pub fn with_number_format(mut self, format: impl Into<String>) -> Self {
185        self.number_format = Some(format.into());
186        self
187    }
188
189    /// Set horizontal alignment.
190    pub fn with_horizontal_alignment(mut self, align: HorizontalAlignment) -> Self {
191        self.horizontal_alignment = Some(align);
192        self
193    }
194
195    /// Set vertical alignment.
196    pub fn with_vertical_alignment(mut self, align: VerticalAlignment) -> Self {
197        self.vertical_alignment = Some(align);
198        self
199    }
200
201    /// Enable text wrapping.
202    pub fn with_wrap_text(mut self, wrap: bool) -> Self {
203        self.wrap_text = wrap;
204        self
205    }
206}
207
208/// Font style for cell formatting.
209#[derive(Debug, Clone, Default, PartialEq)]
210pub struct FontStyle {
211    /// Font name (e.g., "Arial", "Calibri").
212    pub name: Option<String>,
213    /// Font size in points.
214    pub size: Option<f64>,
215    /// Bold text.
216    pub bold: bool,
217    /// Italic text.
218    pub italic: bool,
219    /// Underline style.
220    pub underline: Option<UnderlineStyle>,
221    /// Strikethrough.
222    pub strikethrough: bool,
223    /// Font color as RGB hex (e.g., "FF0000" for red).
224    pub color: Option<String>,
225}
226
227impl FontStyle {
228    /// Create a new empty font style.
229    pub fn new() -> Self {
230        Self::default()
231    }
232
233    /// Set the font name.
234    pub fn with_name(mut self, name: impl Into<String>) -> Self {
235        self.name = Some(name.into());
236        self
237    }
238
239    /// Set the font size.
240    pub fn with_size(mut self, size: f64) -> Self {
241        self.size = Some(size);
242        self
243    }
244
245    /// Set bold.
246    pub fn bold(mut self) -> Self {
247        self.bold = true;
248        self
249    }
250
251    /// Set italic.
252    pub fn italic(mut self) -> Self {
253        self.italic = true;
254        self
255    }
256
257    /// Set underline.
258    pub fn underline(mut self, style: UnderlineStyle) -> Self {
259        self.underline = Some(style);
260        self
261    }
262
263    /// Set strikethrough.
264    pub fn strikethrough(mut self) -> Self {
265        self.strikethrough = true;
266        self
267    }
268
269    /// Set the font color (RGB hex, e.g., "FF0000" for red).
270    pub fn with_color(mut self, color: impl Into<String>) -> Self {
271        self.color = Some(color.into());
272        self
273    }
274}
275
276/// Underline style for fonts.
277///
278/// Corresponds to `ST_UnderlineValues` in ECMA-376 Part 1, §18.18.90.
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
280pub enum UnderlineStyle {
281    /// Single underline (default).
282    #[default]
283    Single,
284    /// Double underline.
285    Double,
286    /// Single accounting underline (extends across the cell width).
287    SingleAccounting,
288    /// Double accounting underline.
289    DoubleAccounting,
290}
291
292impl UnderlineStyle {
293    fn to_xml_value(self) -> &'static str {
294        match self {
295            UnderlineStyle::Single => "single",
296            UnderlineStyle::Double => "double",
297            UnderlineStyle::SingleAccounting => "singleAccounting",
298            UnderlineStyle::DoubleAccounting => "doubleAccounting",
299        }
300    }
301}
302
303/// Fill style for cell background.
304#[derive(Debug, Clone, Default, PartialEq)]
305pub struct FillStyle {
306    /// Fill pattern type.
307    pub pattern: FillPattern,
308    /// Foreground color (pattern color) as RGB hex.
309    pub fg_color: Option<String>,
310    /// Background color as RGB hex.
311    pub bg_color: Option<String>,
312}
313
314impl FillStyle {
315    /// Create a new empty fill style.
316    pub fn new() -> Self {
317        Self::default()
318    }
319
320    /// Create a solid fill with the given color.
321    pub fn solid(color: impl Into<String>) -> Self {
322        Self {
323            pattern: FillPattern::Solid,
324            fg_color: Some(color.into()),
325            bg_color: None,
326        }
327    }
328
329    /// Set the pattern type.
330    pub fn with_pattern(mut self, pattern: FillPattern) -> Self {
331        self.pattern = pattern;
332        self
333    }
334
335    /// Set the foreground color.
336    pub fn with_fg_color(mut self, color: impl Into<String>) -> Self {
337        self.fg_color = Some(color.into());
338        self
339    }
340
341    /// Set the background color.
342    pub fn with_bg_color(mut self, color: impl Into<String>) -> Self {
343        self.bg_color = Some(color.into());
344        self
345    }
346}
347
348/// Fill pattern types.
349///
350/// Corresponds to `ST_PatternType` in ECMA-376 Part 1, §18.18.55.
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
352pub enum FillPattern {
353    /// No fill (transparent).
354    #[default]
355    None,
356    /// Solid fill using the foreground color.
357    Solid,
358    /// Medium gray pattern.
359    MediumGray,
360    /// Dark gray pattern.
361    DarkGray,
362    /// Light gray pattern.
363    LightGray,
364    /// Dark horizontal stripes.
365    DarkHorizontal,
366    /// Dark vertical stripes.
367    DarkVertical,
368    /// Dark diagonal stripes (down-right).
369    DarkDown,
370    /// Dark diagonal stripes (up-right).
371    DarkUp,
372    /// Dark grid pattern.
373    DarkGrid,
374    /// Dark trellis pattern.
375    DarkTrellis,
376    /// Light horizontal stripes.
377    LightHorizontal,
378    /// Light vertical stripes.
379    LightVertical,
380    /// Light diagonal stripes (down-right).
381    LightDown,
382    /// Light diagonal stripes (up-right).
383    LightUp,
384    /// Light grid pattern.
385    LightGrid,
386    /// Light trellis pattern.
387    LightTrellis,
388    /// 12.5% gray dots.
389    Gray125,
390    /// 6.25% gray dots.
391    Gray0625,
392}
393
394impl FillPattern {
395    fn to_xml_value(self) -> &'static str {
396        match self {
397            FillPattern::None => "none",
398            FillPattern::Solid => "solid",
399            FillPattern::MediumGray => "mediumGray",
400            FillPattern::DarkGray => "darkGray",
401            FillPattern::LightGray => "lightGray",
402            FillPattern::DarkHorizontal => "darkHorizontal",
403            FillPattern::DarkVertical => "darkVertical",
404            FillPattern::DarkDown => "darkDown",
405            FillPattern::DarkUp => "darkUp",
406            FillPattern::DarkGrid => "darkGrid",
407            FillPattern::DarkTrellis => "darkTrellis",
408            FillPattern::LightHorizontal => "lightHorizontal",
409            FillPattern::LightVertical => "lightVertical",
410            FillPattern::LightDown => "lightDown",
411            FillPattern::LightUp => "lightUp",
412            FillPattern::LightGrid => "lightGrid",
413            FillPattern::LightTrellis => "lightTrellis",
414            FillPattern::Gray125 => "gray125",
415            FillPattern::Gray0625 => "gray0625",
416        }
417    }
418}
419
420/// Border style for cells.
421#[derive(Debug, Clone, Default, PartialEq)]
422pub struct BorderStyle {
423    /// Left border.
424    pub left: Option<BorderSideStyle>,
425    /// Right border.
426    pub right: Option<BorderSideStyle>,
427    /// Top border.
428    pub top: Option<BorderSideStyle>,
429    /// Bottom border.
430    pub bottom: Option<BorderSideStyle>,
431    /// Diagonal border.
432    pub diagonal: Option<BorderSideStyle>,
433    /// Diagonal up.
434    pub diagonal_up: bool,
435    /// Diagonal down.
436    pub diagonal_down: bool,
437}
438
439impl BorderStyle {
440    /// Create a new empty border style.
441    pub fn new() -> Self {
442        Self::default()
443    }
444
445    /// Create a border with all sides using the same style.
446    pub fn all(style: BorderLineStyle, color: Option<String>) -> Self {
447        let side = BorderSideStyle { style, color };
448        Self {
449            left: Some(side.clone()),
450            right: Some(side.clone()),
451            top: Some(side.clone()),
452            bottom: Some(side),
453            diagonal: None,
454            diagonal_up: false,
455            diagonal_down: false,
456        }
457    }
458
459    /// Set the left border.
460    pub fn with_left(mut self, style: BorderLineStyle, color: Option<String>) -> Self {
461        self.left = Some(BorderSideStyle { style, color });
462        self
463    }
464
465    /// Set the right border.
466    pub fn with_right(mut self, style: BorderLineStyle, color: Option<String>) -> Self {
467        self.right = Some(BorderSideStyle { style, color });
468        self
469    }
470
471    /// Set the top border.
472    pub fn with_top(mut self, style: BorderLineStyle, color: Option<String>) -> Self {
473        self.top = Some(BorderSideStyle { style, color });
474        self
475    }
476
477    /// Set the bottom border.
478    pub fn with_bottom(mut self, style: BorderLineStyle, color: Option<String>) -> Self {
479        self.bottom = Some(BorderSideStyle { style, color });
480        self
481    }
482}
483
484/// Style for a single border side.
485#[derive(Debug, Clone, Default, PartialEq)]
486pub struct BorderSideStyle {
487    /// Line style.
488    pub style: BorderLineStyle,
489    /// Color as RGB hex.
490    pub color: Option<String>,
491}
492
493/// Border line styles.
494///
495/// Corresponds to `ST_BorderStyle` in ECMA-376 Part 1, §18.18.3.
496#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
497pub enum BorderLineStyle {
498    /// No border.
499    #[default]
500    None,
501    /// Thin single line.
502    Thin,
503    /// Medium single line.
504    Medium,
505    /// Dashed line.
506    Dashed,
507    /// Dotted line.
508    Dotted,
509    /// Thick single line.
510    Thick,
511    /// Double line.
512    Double,
513    /// Hairline (thinnest possible).
514    Hair,
515    /// Medium dashed line.
516    MediumDashed,
517    /// Alternating dash-dot line.
518    DashDot,
519    /// Medium alternating dash-dot line.
520    MediumDashDot,
521    /// Alternating dash-dot-dot line.
522    DashDotDot,
523    /// Medium alternating dash-dot-dot line.
524    MediumDashDotDot,
525    /// Slanted dash-dot line.
526    SlantDashDot,
527}
528
529impl BorderLineStyle {
530    fn to_xml_value(self) -> &'static str {
531        match self {
532            BorderLineStyle::None => "none",
533            BorderLineStyle::Thin => "thin",
534            BorderLineStyle::Medium => "medium",
535            BorderLineStyle::Dashed => "dashed",
536            BorderLineStyle::Dotted => "dotted",
537            BorderLineStyle::Thick => "thick",
538            BorderLineStyle::Double => "double",
539            BorderLineStyle::Hair => "hair",
540            BorderLineStyle::MediumDashed => "mediumDashed",
541            BorderLineStyle::DashDot => "dashDot",
542            BorderLineStyle::MediumDashDot => "mediumDashDot",
543            BorderLineStyle::DashDotDot => "dashDotDot",
544            BorderLineStyle::MediumDashDotDot => "mediumDashDotDot",
545            BorderLineStyle::SlantDashDot => "slantDashDot",
546        }
547    }
548}
549
550/// Horizontal text alignment within a cell.
551///
552/// Corresponds to `ST_HorizontalAlignment` in ECMA-376 Part 1, §18.18.40.
553#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
554pub enum HorizontalAlignment {
555    /// Excel's default: numeric values align right, text aligns left.
556    #[default]
557    General,
558    /// Align left.
559    Left,
560    /// Center horizontally.
561    Center,
562    /// Align right.
563    Right,
564    /// Repeat the cell content to fill the column width.
565    Fill,
566    /// Justify text across the full cell width.
567    Justify,
568    /// Center across multiple selected columns.
569    CenterContinuous,
570    /// Distributed alignment (similar to justify but for East-Asian text).
571    Distributed,
572}
573
574impl HorizontalAlignment {
575    fn to_xml_value(self) -> &'static str {
576        match self {
577            HorizontalAlignment::General => "general",
578            HorizontalAlignment::Left => "left",
579            HorizontalAlignment::Center => "center",
580            HorizontalAlignment::Right => "right",
581            HorizontalAlignment::Fill => "fill",
582            HorizontalAlignment::Justify => "justify",
583            HorizontalAlignment::CenterContinuous => "centerContinuous",
584            HorizontalAlignment::Distributed => "distributed",
585        }
586    }
587}
588
589/// Vertical text alignment within a cell.
590///
591/// Corresponds to `ST_VerticalAlignment` in ECMA-376 Part 1, §18.18.88.
592#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
593pub enum VerticalAlignment {
594    /// Align text to the top of the cell.
595    Top,
596    /// Center text vertically.
597    Center,
598    /// Align text to the bottom of the cell (Excel default).
599    #[default]
600    Bottom,
601    /// Justify text vertically (distributes lines evenly).
602    Justify,
603    /// Distributed alignment for East-Asian text.
604    Distributed,
605}
606
607impl VerticalAlignment {
608    fn to_xml_value(self) -> &'static str {
609        match self {
610            VerticalAlignment::Top => "top",
611            VerticalAlignment::Center => "center",
612            VerticalAlignment::Bottom => "bottom",
613            VerticalAlignment::Justify => "justify",
614            VerticalAlignment::Distributed => "distributed",
615        }
616    }
617}
618
619/// A cell being built in a sheet.
620#[derive(Debug, Clone)]
621struct BuilderCell {
622    value: WriteCellValue,
623    style: Option<CellStyle>,
624}
625
626/// A conditional formatting rule for writing.
627#[derive(Debug, Clone)]
628pub struct ConditionalFormat {
629    /// Cell range the rule applies to (e.g., "A1:C10").
630    pub range: String,
631    /// The rules in this conditional format.
632    pub rules: Vec<ConditionalFormatRule>,
633}
634
635/// A single conditional formatting rule.
636#[derive(Debug, Clone)]
637pub struct ConditionalFormatRule {
638    /// Rule type.
639    pub rule_type: crate::ConditionalRuleType,
640    /// Priority (lower = higher priority).
641    pub priority: u32,
642    /// Differential formatting ID.
643    pub dxf_id: Option<u32>,
644    /// Operator for cellIs rules.
645    pub operator: Option<String>,
646    /// Formula(s) for the rule.
647    pub formulas: Vec<String>,
648    /// Text for containsText/beginsWith/endsWith rules.
649    pub text: Option<String>,
650}
651
652impl ConditionalFormat {
653    /// Create a new conditional format for a range.
654    pub fn new(range: impl Into<String>) -> Self {
655        Self {
656            range: range.into(),
657            rules: Vec::new(),
658        }
659    }
660
661    /// Add a cell value comparison rule.
662    pub fn add_cell_is_rule(
663        mut self,
664        operator: &str,
665        formula: impl Into<String>,
666        priority: u32,
667        dxf_id: Option<u32>,
668    ) -> Self {
669        self.rules.push(ConditionalFormatRule {
670            rule_type: crate::ConditionalRuleType::CellIs,
671            priority,
672            dxf_id,
673            operator: Some(operator.to_string()),
674            formulas: vec![formula.into()],
675            text: None,
676        });
677        self
678    }
679
680    /// Add a formula expression rule.
681    pub fn add_expression_rule(
682        mut self,
683        formula: impl Into<String>,
684        priority: u32,
685        dxf_id: Option<u32>,
686    ) -> Self {
687        self.rules.push(ConditionalFormatRule {
688            rule_type: crate::ConditionalRuleType::Expression,
689            priority,
690            dxf_id,
691            operator: None,
692            formulas: vec![formula.into()],
693            text: None,
694        });
695        self
696    }
697
698    /// Add a duplicate values rule.
699    pub fn add_duplicate_values_rule(mut self, priority: u32, dxf_id: Option<u32>) -> Self {
700        self.rules.push(ConditionalFormatRule {
701            rule_type: crate::ConditionalRuleType::DuplicateValues,
702            priority,
703            dxf_id,
704            operator: None,
705            formulas: Vec::new(),
706            text: None,
707        });
708        self
709    }
710
711    /// Add a contains text rule.
712    pub fn add_contains_text_rule(
713        mut self,
714        text: impl Into<String>,
715        priority: u32,
716        dxf_id: Option<u32>,
717    ) -> Self {
718        let text = text.into();
719        self.rules.push(ConditionalFormatRule {
720            rule_type: crate::ConditionalRuleType::ContainsText,
721            priority,
722            dxf_id,
723            operator: Some("containsText".to_string()),
724            formulas: Vec::new(),
725            text: Some(text),
726        });
727        self
728    }
729}
730
731/// A data validation rule for writing.
732#[derive(Debug, Clone)]
733pub struct DataValidationBuilder {
734    /// Cell range the validation applies to (e.g., "A1:C10").
735    pub range: String,
736    /// Validation type.
737    pub validation_type: crate::DataValidationType,
738    /// Comparison operator.
739    pub operator: crate::DataValidationOperator,
740    /// First formula/value.
741    pub formula1: Option<String>,
742    /// Second formula/value (for between/notBetween operators).
743    pub formula2: Option<String>,
744    /// Allow blank cells.
745    pub allow_blank: bool,
746    /// Show input message when cell is selected.
747    pub show_input_message: bool,
748    /// Show error message on invalid input.
749    pub show_error_message: bool,
750    /// Error alert style.
751    pub error_style: crate::DataValidationErrorStyle,
752    /// Error title.
753    pub error_title: Option<String>,
754    /// Error message.
755    pub error_message: Option<String>,
756    /// Input prompt title.
757    pub prompt_title: Option<String>,
758    /// Input prompt message.
759    pub prompt_message: Option<String>,
760}
761
762impl DataValidationBuilder {
763    /// Create a new data validation for a range.
764    pub fn new(range: impl Into<String>) -> Self {
765        Self {
766            range: range.into(),
767            validation_type: crate::DataValidationType::None,
768            operator: crate::DataValidationOperator::Between,
769            formula1: None,
770            formula2: None,
771            allow_blank: true,
772            show_input_message: true,
773            show_error_message: true,
774            error_style: crate::DataValidationErrorStyle::Stop,
775            error_title: None,
776            error_message: None,
777            prompt_title: None,
778            prompt_message: None,
779        }
780    }
781
782    /// Create a list validation (dropdown) from a range or comma-separated values.
783    pub fn list(range: impl Into<String>, source: impl Into<String>) -> Self {
784        Self {
785            range: range.into(),
786            validation_type: crate::DataValidationType::List,
787            operator: crate::DataValidationOperator::Between,
788            formula1: Some(source.into()),
789            formula2: None,
790            allow_blank: true,
791            show_input_message: true,
792            show_error_message: true,
793            error_style: crate::DataValidationErrorStyle::Stop,
794            error_title: None,
795            error_message: None,
796            prompt_title: None,
797            prompt_message: None,
798        }
799    }
800
801    /// Create a whole number validation.
802    pub fn whole_number(
803        range: impl Into<String>,
804        operator: crate::DataValidationOperator,
805        value1: impl Into<String>,
806    ) -> Self {
807        Self {
808            range: range.into(),
809            validation_type: crate::DataValidationType::Whole,
810            operator,
811            formula1: Some(value1.into()),
812            formula2: None,
813            allow_blank: true,
814            show_input_message: true,
815            show_error_message: true,
816            error_style: crate::DataValidationErrorStyle::Stop,
817            error_title: None,
818            error_message: None,
819            prompt_title: None,
820            prompt_message: None,
821        }
822    }
823
824    /// Create a decimal number validation.
825    pub fn decimal(
826        range: impl Into<String>,
827        operator: crate::DataValidationOperator,
828        value1: impl Into<String>,
829    ) -> Self {
830        Self {
831            range: range.into(),
832            validation_type: crate::DataValidationType::Decimal,
833            operator,
834            formula1: Some(value1.into()),
835            formula2: None,
836            allow_blank: true,
837            show_input_message: true,
838            show_error_message: true,
839            error_style: crate::DataValidationErrorStyle::Stop,
840            error_title: None,
841            error_message: None,
842            prompt_title: None,
843            prompt_message: None,
844        }
845    }
846
847    /// Set the second value/formula for between/notBetween operators.
848    pub fn with_formula2(mut self, formula2: impl Into<String>) -> Self {
849        self.formula2 = Some(formula2.into());
850        self
851    }
852
853    /// Set whether blank cells are allowed.
854    pub fn with_allow_blank(mut self, allow: bool) -> Self {
855        self.allow_blank = allow;
856        self
857    }
858
859    /// Set the error style.
860    pub fn with_error_style(mut self, style: crate::DataValidationErrorStyle) -> Self {
861        self.error_style = style;
862        self
863    }
864
865    /// Set the error message.
866    pub fn with_error(mut self, title: impl Into<String>, message: impl Into<String>) -> Self {
867        self.error_title = Some(title.into());
868        self.error_message = Some(message.into());
869        self
870    }
871
872    /// Set the input prompt message.
873    pub fn with_prompt(mut self, title: impl Into<String>, message: impl Into<String>) -> Self {
874        self.prompt_title = Some(title.into());
875        self.prompt_message = Some(message.into());
876        self.show_input_message = true;
877        self
878    }
879}
880
881/// A defined name (named range) for writing.
882///
883/// Defined names can reference ranges, formulas, or constants.
884/// They can be global (workbook scope) or local (sheet scope).
885///
886/// # Example
887///
888/// ```ignore
889/// let mut wb = WorkbookBuilder::new();
890/// // Global defined name
891/// wb.add_defined_name("MyRange", "Sheet1!$A$1:$B$10");
892/// // Sheet-scoped defined name
893/// wb.add_defined_name_with_scope("LocalName", "Sheet1!$C$1", 0);
894/// ```
895#[derive(Debug, Clone)]
896pub struct DefinedNameBuilder {
897    /// The name (e.g., "MyRange", "_xlnm.Print_Area").
898    pub name: String,
899    /// The formula or reference (e.g., "Sheet1!$A$1:$B$10").
900    pub reference: String,
901    /// Optional sheet index if this name is scoped to a specific sheet.
902    pub local_sheet_id: Option<u32>,
903    /// Optional comment/description.
904    pub comment: Option<String>,
905    /// Whether this is a hidden name.
906    pub hidden: bool,
907}
908
909impl DefinedNameBuilder {
910    /// Create a new defined name with global scope.
911    pub fn new(name: impl Into<String>, reference: impl Into<String>) -> Self {
912        Self {
913            name: name.into(),
914            reference: reference.into(),
915            local_sheet_id: None,
916            comment: None,
917            hidden: false,
918        }
919    }
920
921    /// Create a new defined name with sheet scope.
922    pub fn with_sheet_scope(
923        name: impl Into<String>,
924        reference: impl Into<String>,
925        sheet_index: u32,
926    ) -> Self {
927        Self {
928            name: name.into(),
929            reference: reference.into(),
930            local_sheet_id: Some(sheet_index),
931            comment: None,
932            hidden: false,
933        }
934    }
935
936    /// Add a comment to the defined name.
937    pub fn with_comment(mut self, comment: impl Into<String>) -> Self {
938        self.comment = Some(comment.into());
939        self
940    }
941
942    /// Mark the defined name as hidden.
943    pub fn hidden(mut self) -> Self {
944        self.hidden = true;
945        self
946    }
947
948    /// Create a print area defined name for a sheet.
949    ///
950    /// # Example
951    ///
952    /// ```ignore
953    /// let print_area = DefinedNameBuilder::print_area(0, "Sheet1!$A$1:$G$20");
954    /// wb.add_defined_name_builder(print_area);
955    /// ```
956    pub fn print_area(sheet_index: u32, reference: impl Into<String>) -> Self {
957        Self {
958            name: "_xlnm.Print_Area".to_string(),
959            reference: reference.into(),
960            local_sheet_id: Some(sheet_index),
961            comment: None,
962            hidden: false,
963        }
964    }
965
966    /// Create a print titles defined name for a sheet (repeating rows/columns).
967    ///
968    /// # Example
969    ///
970    /// ```ignore
971    /// // Repeat rows 1-2 on each printed page
972    /// let print_titles = DefinedNameBuilder::print_titles(0, "Sheet1!$1:$2");
973    /// wb.add_defined_name_builder(print_titles);
974    /// ```
975    pub fn print_titles(sheet_index: u32, reference: impl Into<String>) -> Self {
976        Self {
977            name: "_xlnm.Print_Titles".to_string(),
978            reference: reference.into(),
979            local_sheet_id: Some(sheet_index),
980            comment: None,
981            hidden: false,
982        }
983    }
984}
985
986/// A single rich-text run inside a comment.
987///
988/// Created by [`CommentBuilder::add_run`].  Call the setter methods to apply
989/// formatting, then call [`CommentBuilder::add_run`] again for the next run.
990#[derive(Debug, Clone, Default)]
991pub struct CommentRun {
992    /// Text content of this run.
993    pub text: String,
994    /// Bold.
995    pub bold: bool,
996    /// Italic.
997    pub italic: bool,
998    /// RGB hex color (e.g., `"FF0000"` for red).
999    pub color: Option<String>,
1000    /// Font size in points.
1001    pub font_size: Option<f64>,
1002}
1003
1004impl CommentRun {
1005    /// Set bold formatting.
1006    pub fn set_bold(&mut self, bold: bool) -> &mut Self {
1007        self.bold = bold;
1008        self
1009    }
1010
1011    /// Set italic formatting.
1012    pub fn set_italic(&mut self, italic: bool) -> &mut Self {
1013        self.italic = italic;
1014        self
1015    }
1016
1017    /// Set the run color as an RGB hex string (e.g., `"FF0000"` for red).
1018    pub fn set_color(&mut self, rgb: &str) -> &mut Self {
1019        self.color = Some(rgb.to_string());
1020        self
1021    }
1022
1023    /// Set the font size in points.
1024    pub fn set_font_size(&mut self, pt: f64) -> &mut Self {
1025        self.font_size = Some(pt);
1026        self
1027    }
1028}
1029
1030/// A cell comment (note) for writing.
1031///
1032/// Comments can contain plain text or rich text with multiple runs.
1033///
1034/// # Example
1035///
1036/// ```ignore
1037/// let mut wb = WorkbookBuilder::new();
1038/// let sheet = wb.add_sheet("Sheet1");
1039/// sheet.add_comment("A1", "This is a comment");
1040/// sheet.add_comment_with_author("B1", "Another comment", "John Doe");
1041///
1042/// // Rich-text comment via builder
1043/// let mut cb = CommentBuilder::new_rich("C1");
1044/// cb.add_run("Important: ").set_bold(true);
1045/// cb.add_run("see the spec.");
1046/// sheet.add_comment_builder(cb);
1047/// ```
1048#[derive(Debug, Clone)]
1049pub struct CommentBuilder {
1050    /// Cell reference (e.g., "A1").
1051    pub reference: String,
1052    /// Plain-text content (used when no runs are set).
1053    pub text: String,
1054    /// Author of the comment (optional).
1055    pub author: Option<String>,
1056    /// Rich-text runs (when non-empty, `text` is ignored).
1057    pub runs: Vec<CommentRun>,
1058}
1059
1060impl CommentBuilder {
1061    /// Create a new comment with plain text.
1062    pub fn new(reference: impl Into<String>, text: impl Into<String>) -> Self {
1063        Self {
1064            reference: reference.into(),
1065            text: text.into(),
1066            author: None,
1067            runs: Vec::new(),
1068        }
1069    }
1070
1071    /// Create a new rich-text comment (no initial plain text).
1072    ///
1073    /// Use [`add_run`](Self::add_run) to append formatted runs.
1074    pub fn new_rich(reference: impl Into<String>) -> Self {
1075        Self {
1076            reference: reference.into(),
1077            text: String::new(),
1078            author: None,
1079            runs: Vec::new(),
1080        }
1081    }
1082
1083    /// Create a new comment with an author.
1084    pub fn with_author(
1085        reference: impl Into<String>,
1086        text: impl Into<String>,
1087        author: impl Into<String>,
1088    ) -> Self {
1089        Self {
1090            reference: reference.into(),
1091            text: text.into(),
1092            author: Some(author.into()),
1093            runs: Vec::new(),
1094        }
1095    }
1096
1097    /// Set the author of the comment.
1098    pub fn author(mut self, author: impl Into<String>) -> Self {
1099        self.author = Some(author.into());
1100        self
1101    }
1102
1103    /// Append a rich-text run and return a mutable reference to it.
1104    ///
1105    /// The returned `&mut CommentRun` can be used to set formatting
1106    /// (bold, italic, color, font size).
1107    ///
1108    /// # Example
1109    ///
1110    /// ```ignore
1111    /// cb.add_run("Warning: ").set_bold(true).set_color("FF0000");
1112    /// cb.add_run("normal text");
1113    /// ```
1114    pub fn add_run(&mut self, text: &str) -> &mut CommentRun {
1115        self.runs.push(CommentRun {
1116            text: text.to_string(),
1117            ..Default::default()
1118        });
1119        self.runs.last_mut().unwrap()
1120    }
1121}
1122
1123// =============================================================================
1124// Sheet protection
1125// =============================================================================
1126
1127/// Options for `SheetBuilder::set_sheet_protection` (ECMA-376 §18.3.1.85).
1128///
1129/// By default all fields are `false` (no restrictions).  Set a field to `true`
1130/// to **prevent** that operation (the OOXML attribute names are inverted: a
1131/// value of `true` on `formatCells` means "format cells is *not* allowed").
1132///
1133/// # Example
1134///
1135/// ```ignore
1136/// sheet.set_sheet_protection(SheetProtectionOptions {
1137///     sheet: true,
1138///     select_locked_cells: false,
1139///     ..Default::default()
1140/// });
1141/// ```
1142#[cfg(feature = "sml-protection")]
1143#[derive(Debug, Clone, Default)]
1144pub struct SheetProtectionOptions {
1145    /// Optional plain-text password (hashed with the OOXML XOR algorithm).
1146    ///
1147    /// When `None`, no password is set (the sheet can be unprotected without
1148    /// a password).
1149    pub password: Option<String>,
1150    /// Lock the sheet (enable protection).  Must be `true` for any other
1151    /// restriction to take effect.
1152    pub sheet: bool,
1153    /// Prevent selecting locked cells.
1154    pub select_locked_cells: bool,
1155    /// Prevent selecting unlocked cells.
1156    pub select_unlocked_cells: bool,
1157    /// Prevent formatting cells.
1158    pub format_cells: bool,
1159    /// Prevent formatting columns.
1160    pub format_columns: bool,
1161    /// Prevent formatting rows.
1162    pub format_rows: bool,
1163    /// Prevent inserting columns.
1164    pub insert_columns: bool,
1165    /// Prevent inserting rows.
1166    pub insert_rows: bool,
1167    /// Prevent deleting columns.
1168    pub delete_columns: bool,
1169    /// Prevent deleting rows.
1170    pub delete_rows: bool,
1171    /// Prevent sorting.
1172    pub sort: bool,
1173    /// Prevent using auto-filter.
1174    pub auto_filter: bool,
1175    /// Prevent using pivot tables.
1176    pub pivot_tables: bool,
1177}
1178
1179/// Compute the OOXML XOR password hash for a plain-text password.
1180///
1181/// Implements the algorithm described in ECMA-376 Part 1, §18.2.28.
1182/// Returns the 16-bit hash as a 2-byte `Vec<u8>` (big-endian), which is the
1183/// `STUnsignedShortHex` representation used by `sheetProtection/@password`.
1184#[cfg(feature = "sml-protection")]
1185fn ooxml_xor_hash(password: &str) -> Vec<u8> {
1186    if password.is_empty() {
1187        return vec![0x00, 0x00];
1188    }
1189
1190    // The algorithm from ECMA-376 Part 1, §18.2.28:
1191    // 1. Initialise hash to 0.
1192    // 2. For each character in reverse order:
1193    //    a. XOR hash with a rotating key derived from the character.
1194    //    b. Rotate the hash 1 bit left.
1195    // 3. XOR with the length.
1196    // 4. XOR with 0xCE4B (the "password verifier seed").
1197
1198    let chars: Vec<u8> = password.chars().map(|c| c as u8).collect();
1199    let mut hash: u16 = 0;
1200
1201    for &ch in chars.iter().rev() {
1202        // rotate hash left by 1 bit (15-bit rotation for 15-bit value)
1203        hash = ((hash << 1) | (hash >> 14)) & 0x7FFF;
1204        hash ^= ch as u16;
1205    }
1206
1207    // Final rotate and XOR with length + seed
1208    hash = ((hash << 1) | (hash >> 14)) & 0x7FFF;
1209    hash ^= chars.len() as u16;
1210    hash ^= 0xCE4B;
1211
1212    vec![(hash >> 8) as u8, (hash & 0xFF) as u8]
1213}
1214
1215/// Destination of a hyperlink.
1216#[derive(Debug, Clone)]
1217enum HyperlinkDest {
1218    /// External URL — needs a relationship entry in sheet rels.
1219    External(String),
1220    /// Internal reference (e.g. `"Sheet2!A1"`) — no relationship needed.
1221    Internal(String),
1222}
1223
1224/// A hyperlink to be written in a sheet.
1225#[derive(Debug, Clone)]
1226struct HyperlinkEntry {
1227    /// Cell reference the hyperlink is attached to (e.g. `"A1"`).
1228    reference: String,
1229    dest: HyperlinkDest,
1230    tooltip: Option<String>,
1231    display: Option<String>,
1232}
1233
1234/// Page orientation for `set_page_setup`.
1235#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1236pub enum PageOrientation {
1237    /// Portrait orientation (taller than wide).
1238    #[default]
1239    Portrait,
1240    /// Landscape orientation (wider than tall).
1241    Landscape,
1242}
1243
1244/// Options for `SheetBuilder::set_page_setup`.
1245///
1246/// All fields are optional; unset fields are left at their Excel defaults.
1247#[derive(Debug, Clone, Default)]
1248pub struct PageSetupOptions {
1249    /// Page orientation.
1250    pub orientation: Option<PageOrientation>,
1251    /// Paper size (e.g. 1 = Letter, 9 = A4). See ECMA-376 §18.18.43.
1252    pub paper_size: Option<u32>,
1253    /// Scaling percentage (10–400). Use instead of `fit_to_width`/`fit_to_height`.
1254    pub scale: Option<u32>,
1255    /// Fit to this many pages wide (0 = auto). Used with `fit_to_height`.
1256    pub fit_to_width: Option<u32>,
1257    /// Fit to this many pages tall (0 = auto). Used with `fit_to_width`.
1258    pub fit_to_height: Option<u32>,
1259}
1260
1261impl PageSetupOptions {
1262    /// Create a new empty page-setup options object.
1263    pub fn new() -> Self {
1264        Self::default()
1265    }
1266
1267    /// Set the page orientation.
1268    pub fn with_orientation(mut self, orientation: PageOrientation) -> Self {
1269        self.orientation = Some(orientation);
1270        self
1271    }
1272
1273    /// Set the paper size.
1274    pub fn with_paper_size(mut self, size: u32) -> Self {
1275        self.paper_size = Some(size);
1276        self
1277    }
1278
1279    /// Set the scaling percentage.
1280    pub fn with_scale(mut self, scale: u32) -> Self {
1281        self.scale = Some(scale);
1282        self
1283    }
1284
1285    /// Set the fit-to-pages dimensions.
1286    pub fn with_fit_to(mut self, width: u32, height: u32) -> Self {
1287        self.fit_to_width = Some(width);
1288        self.fit_to_height = Some(height);
1289        self
1290    }
1291}
1292
1293// ============================================================================
1294// Chart embedding (sml-charts feature)
1295// ============================================================================
1296
1297/// A chart embedded in a worksheet.
1298///
1299/// Created by [`SheetBuilder::embed_chart`].
1300#[cfg(feature = "sml-charts")]
1301#[derive(Debug)]
1302struct ChartEntry {
1303    /// Raw chart XML bytes.
1304    chart_xml: Vec<u8>,
1305    /// Column index (0-based) of the top-left anchor cell.
1306    x: u32,
1307    /// Row index (0-based) of the top-left anchor cell.
1308    y: u32,
1309    /// Width in cells.
1310    width: u32,
1311    /// Height in cells.
1312    height: u32,
1313}
1314
1315// ============================================================================
1316// Pivot table support (sml-pivot feature)
1317// ============================================================================
1318
1319/// Options for [`SheetBuilder::add_pivot_table`].
1320///
1321/// Produces a minimal but spec-compliant pivot table definition.
1322///
1323/// # Example
1324///
1325/// ```ignore
1326/// sheet.add_pivot_table(PivotTableOptions {
1327///     name: "SalesPivot".to_string(),
1328///     source_ref: "Sheet1!$A$1:$D$10".to_string(),
1329///     dest_ref: "F1".to_string(),
1330///     row_fields: vec!["Region".to_string()],
1331///     col_fields: vec!["Quarter".to_string()],
1332///     data_fields: vec!["Sales".to_string()],
1333/// });
1334/// ```
1335#[cfg(feature = "sml-pivot")]
1336#[derive(Debug, Clone)]
1337pub struct PivotTableOptions {
1338    /// Name of the pivot table (shown in Excel's pivot table list).
1339    pub name: String,
1340    /// Source data range, e.g. `"Sheet1!$A$1:$D$10"`.
1341    pub source_ref: String,
1342    /// Top-left cell of the pivot table output, e.g. `"A1"`.
1343    pub dest_ref: String,
1344    /// Field names to place on the row axis.
1345    pub row_fields: Vec<String>,
1346    /// Field names to place on the column axis.
1347    pub col_fields: Vec<String>,
1348    /// Field names to aggregate in the values area (sum by default).
1349    pub data_fields: Vec<String>,
1350}
1351
1352/// Internal record of a pivot table added to a sheet.
1353#[cfg(feature = "sml-pivot")]
1354#[derive(Debug)]
1355struct PivotEntry {
1356    opts: PivotTableOptions,
1357}
1358
1359/// Error type for `add_ignored_error`.
1360///
1361/// Each variant corresponds to one of the boolean flags on the `<ignoredError>`
1362/// element (ECMA-376 §18.3.1.35).
1363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1364pub enum IgnoredErrorType {
1365    /// Number stored as text (the most common Excel warning).
1366    NumberStoredAsText,
1367    /// Formula error (e.g. #VALUE!, #REF!).
1368    Formula,
1369    /// Two-digit text year.
1370    TwoDigitTextYear,
1371    /// Formula evaluation error.
1372    EvalError,
1373    /// Formula range mismatch.
1374    FormulaRange,
1375    /// Unlocked formula in a protected sheet.
1376    UnlockedFormula,
1377    /// Empty cell reference.
1378    EmptyCellReference,
1379    /// List data validation mismatch.
1380    ListDataValidation,
1381    /// Calculated column formula inconsistency.
1382    CalculatedColumn,
1383}
1384
1385/// Builder for a single worksheet within a [`WorkbookBuilder`].
1386///
1387/// Obtained via [`WorkbookBuilder::add_sheet`] or [`WorkbookBuilder::sheet_mut`].
1388/// Set cell values with [`set_cell`](Self::set_cell), apply formatting with
1389/// [`set_cell_styled`](Self::set_cell_styled), and configure layout features
1390/// (freeze panes, column widths, auto-filter, etc.) before calling
1391/// [`WorkbookBuilder::write`] or [`WorkbookBuilder::save`].
1392#[derive(Debug)]
1393pub struct SheetBuilder {
1394    name: String,
1395    /// Cells stored as a map for O(1) mutation; resolved to rows at write time.
1396    cells: HashMap<(u32, u32), BuilderCell>,
1397    /// Row heights applied to Row elements at write time.
1398    row_heights: HashMap<u32, f64>,
1399    /// Per-row outline levels (ECMA-376 §18.3.1.73 `@outlineLevel`).
1400    row_outline_levels: HashMap<u32, u8>,
1401    /// Per-row collapsed flag (ECMA-376 §18.3.1.73 `@collapsed`).
1402    row_collapsed: HashMap<u32, bool>,
1403    /// Per-column outline levels (ECMA-376 §18.3.1.13 `@outlineLevel`).
1404    col_outline_levels: HashMap<u32, u8>,
1405    /// Per-column collapsed flag (ECMA-376 §18.3.1.13 `@collapsed`).
1406    col_collapsed: HashMap<u32, bool>,
1407    /// Comments go into a separate XML file, not into Worksheet.
1408    comments: Vec<CommentBuilder>,
1409    /// Hyperlinks, resolved to worksheet XML + sheet rels at write time.
1410    hyperlinks: Vec<HyperlinkEntry>,
1411    /// Embedded charts, written to xl/charts/ + xl/drawings/ at write time.
1412    #[cfg(feature = "sml-charts")]
1413    charts: Vec<ChartEntry>,
1414    /// Pivot tables, written to xl/pivotTables/ and xl/pivotCache/ at write time.
1415    #[cfg(feature = "sml-pivot")]
1416    pivot_tables: Vec<PivotEntry>,
1417    /// Show/hide gridlines for the default sheet view.
1418    show_gridlines: Option<bool>,
1419    /// Show/hide row and column headers for the default sheet view.
1420    show_row_col_headers: Option<bool>,
1421    /// All other worksheet state lives here directly; mutated by setter methods.
1422    worksheet: types::Worksheet,
1423}
1424
1425/// Create a default empty Worksheet, ready to be filled in by SheetBuilder methods.
1426fn init_worksheet() -> types::Worksheet {
1427    types::Worksheet {
1428        #[cfg(feature = "sml-styling")]
1429        sheet_properties: None,
1430        dimension: None,
1431        sheet_views: None,
1432        #[cfg(feature = "sml-styling")]
1433        sheet_format: None,
1434        #[cfg(feature = "sml-styling")]
1435        cols: Vec::new(),
1436        sheet_data: Box::new(types::SheetData {
1437            row: Vec::new(),
1438            #[cfg(feature = "extra-children")]
1439            extra_children: Vec::new(),
1440        }),
1441        #[cfg(feature = "sml-formulas")]
1442        sheet_calc_pr: None,
1443        #[cfg(feature = "sml-protection")]
1444        sheet_protection: None,
1445        #[cfg(feature = "sml-protection")]
1446        protected_ranges: None,
1447        #[cfg(feature = "sml-formulas-advanced")]
1448        scenarios: None,
1449        #[cfg(feature = "sml-filtering")]
1450        auto_filter: None,
1451        #[cfg(feature = "sml-filtering")]
1452        sort_state: None,
1453        #[cfg(feature = "sml-formulas-advanced")]
1454        data_consolidate: None,
1455        #[cfg(feature = "sml-structure")]
1456        custom_sheet_views: None,
1457        merged_cells: None,
1458        #[cfg(feature = "sml-i18n")]
1459        phonetic_pr: None,
1460        #[cfg(feature = "sml-styling")]
1461        conditional_formatting: Vec::new(),
1462        #[cfg(feature = "sml-validation")]
1463        data_validations: None,
1464        #[cfg(feature = "sml-hyperlinks")]
1465        hyperlinks: None,
1466        #[cfg(feature = "sml-layout")]
1467        print_options: None,
1468        #[cfg(feature = "sml-layout")]
1469        page_margins: None,
1470        #[cfg(feature = "sml-layout")]
1471        page_setup: None,
1472        #[cfg(feature = "sml-layout")]
1473        header_footer: None,
1474        #[cfg(feature = "sml-layout")]
1475        row_breaks: None,
1476        #[cfg(feature = "sml-layout")]
1477        col_breaks: None,
1478        #[cfg(feature = "sml-metadata")]
1479        custom_properties: None,
1480        #[cfg(feature = "sml-formulas-advanced")]
1481        cell_watches: None,
1482        #[cfg(feature = "sml-validation")]
1483        ignored_errors: None,
1484        #[cfg(feature = "sml-metadata")]
1485        smart_tags: None,
1486        #[cfg(feature = "sml-drawings")]
1487        drawing: None,
1488        #[cfg(feature = "sml-comments")]
1489        legacy_drawing: None,
1490        #[cfg(feature = "sml-layout")]
1491        legacy_drawing_h_f: None,
1492        #[cfg(feature = "sml-drawings")]
1493        drawing_h_f: None,
1494        #[cfg(feature = "sml-drawings")]
1495        picture: None,
1496        #[cfg(feature = "sml-external")]
1497        ole_objects: None,
1498        #[cfg(feature = "sml-external")]
1499        controls: None,
1500        #[cfg(feature = "sml-external")]
1501        web_publish_items: None,
1502        #[cfg(feature = "sml-tables")]
1503        table_parts: None,
1504        #[cfg(feature = "sml-extensions")]
1505        extension_list: None,
1506        #[cfg(feature = "extra-children")]
1507        extra_children: Vec::new(),
1508    }
1509}
1510
1511impl SheetBuilder {
1512    fn new(name: impl Into<String>) -> Self {
1513        Self {
1514            name: name.into(),
1515            cells: HashMap::new(),
1516            row_heights: HashMap::new(),
1517            row_outline_levels: HashMap::new(),
1518            row_collapsed: HashMap::new(),
1519            col_outline_levels: HashMap::new(),
1520            col_collapsed: HashMap::new(),
1521            comments: Vec::new(),
1522            hyperlinks: Vec::new(),
1523            #[cfg(feature = "sml-charts")]
1524            charts: Vec::new(),
1525            #[cfg(feature = "sml-pivot")]
1526            pivot_tables: Vec::new(),
1527            show_gridlines: None,
1528            show_row_col_headers: None,
1529            worksheet: init_worksheet(),
1530        }
1531    }
1532
1533    /// Set a cell value by reference (e.g., "A1", "B2").
1534    pub fn set_cell(&mut self, reference: &str, value: impl Into<WriteCellValue>) {
1535        if let Some((row, col)) = parse_cell_reference(reference) {
1536            self.cells.insert(
1537                (row, col),
1538                BuilderCell {
1539                    value: value.into(),
1540                    style: None,
1541                },
1542            );
1543        }
1544    }
1545
1546    /// Set a cell value with a style by reference.
1547    pub fn set_cell_styled(
1548        &mut self,
1549        reference: &str,
1550        value: impl Into<WriteCellValue>,
1551        style: CellStyle,
1552    ) {
1553        if let Some((row, col)) = parse_cell_reference(reference) {
1554            self.cells.insert(
1555                (row, col),
1556                BuilderCell {
1557                    value: value.into(),
1558                    style: Some(style),
1559                },
1560            );
1561        }
1562    }
1563
1564    /// Set a cell value by row and column (1-based).
1565    pub fn set_cell_at(&mut self, row: u32, col: u32, value: impl Into<WriteCellValue>) {
1566        self.cells.insert(
1567            (row, col),
1568            BuilderCell {
1569                value: value.into(),
1570                style: None,
1571            },
1572        );
1573    }
1574
1575    /// Set a cell value with a style by row and column.
1576    pub fn set_cell_at_styled(
1577        &mut self,
1578        row: u32,
1579        col: u32,
1580        value: impl Into<WriteCellValue>,
1581        style: CellStyle,
1582    ) {
1583        self.cells.insert(
1584            (row, col),
1585            BuilderCell {
1586                value: value.into(),
1587                style: Some(style),
1588            },
1589        );
1590    }
1591
1592    /// Apply a style to an existing cell.
1593    pub fn set_cell_style(&mut self, reference: &str, style: CellStyle) {
1594        if let Some((row, col)) = parse_cell_reference(reference)
1595            && let Some(cell) = self.cells.get_mut(&(row, col))
1596        {
1597            cell.style = Some(style);
1598        }
1599    }
1600
1601    /// Set a formula in a cell.
1602    pub fn set_formula(&mut self, reference: &str, formula: impl Into<String>) {
1603        if let Some((row, col)) = parse_cell_reference(reference) {
1604            self.cells.insert(
1605                (row, col),
1606                BuilderCell {
1607                    value: WriteCellValue::Formula(formula.into()),
1608                    style: None,
1609                },
1610            );
1611        }
1612    }
1613
1614    /// Set a formula with a style in a cell.
1615    pub fn set_formula_styled(
1616        &mut self,
1617        reference: &str,
1618        formula: impl Into<String>,
1619        style: CellStyle,
1620    ) {
1621        if let Some((row, col)) = parse_cell_reference(reference) {
1622            self.cells.insert(
1623                (row, col),
1624                BuilderCell {
1625                    value: WriteCellValue::Formula(formula.into()),
1626                    style: Some(style),
1627                },
1628            );
1629        }
1630    }
1631
1632    /// Merge cells in a range (e.g., "A1:B2").
1633    ///
1634    /// Note: The value of the merged cell should be set in the top-left cell.
1635    pub fn merge_cells(&mut self, range: &str) {
1636        let mc = self.worksheet.merged_cells.get_or_insert_with(Box::default);
1637        mc.merge_cell.push(types::MergedCell {
1638            reference: range.to_string(),
1639            #[cfg(feature = "extra-attrs")]
1640            extra_attrs: Default::default(),
1641        });
1642        mc.count = Some(mc.merge_cell.len() as u32);
1643    }
1644
1645    /// Set the width of a column (in character units, Excel default is ~8.43).
1646    ///
1647    /// Column is specified by letter (e.g., "A", "B", "AA").
1648    pub fn set_column_width(&mut self, col: &str, width: f64) {
1649        if let Some(col_num) = column_letter_to_number(col) {
1650            self.push_column(col_num, col_num, width);
1651        }
1652    }
1653
1654    /// Set the width of a range of columns.
1655    ///
1656    /// Columns are specified by letter (e.g., "A:C" for columns A through C).
1657    pub fn set_column_width_range(&mut self, start_col: &str, end_col: &str, width: f64) {
1658        if let (Some(min), Some(max)) = (
1659            column_letter_to_number(start_col),
1660            column_letter_to_number(end_col),
1661        ) {
1662            self.push_column(min, max, width);
1663        }
1664    }
1665
1666    /// Push a column definition directly into the worksheet.
1667    fn push_column(&mut self, min: u32, max: u32, width: f64) {
1668        #[cfg(feature = "sml-styling")]
1669        {
1670            let col = types::Column {
1671                #[cfg(feature = "sml-styling")]
1672                start_column: min,
1673                #[cfg(feature = "sml-styling")]
1674                end_column: max,
1675                #[cfg(feature = "sml-styling")]
1676                width: Some(width),
1677                #[cfg(feature = "sml-styling")]
1678                style: None,
1679                #[cfg(feature = "sml-structure")]
1680                hidden: None,
1681                #[cfg(feature = "sml-styling")]
1682                best_fit: None,
1683                #[cfg(feature = "sml-styling")]
1684                custom_width: Some(true),
1685                #[cfg(feature = "sml-i18n")]
1686                phonetic: None,
1687                #[cfg(feature = "sml-structure")]
1688                outline_level: None,
1689                #[cfg(feature = "sml-structure")]
1690                collapsed: None,
1691                #[cfg(feature = "extra-attrs")]
1692                extra_attrs: Default::default(),
1693            };
1694            if let Some(cols_list) = self.worksheet.cols.first_mut() {
1695                cols_list.col.push(col);
1696            } else {
1697                self.worksheet.cols.push(types::Columns {
1698                    col: vec![col],
1699                    #[cfg(feature = "extra-children")]
1700                    extra_children: Vec::new(),
1701                });
1702            }
1703        }
1704        #[cfg(not(feature = "sml-styling"))]
1705        {
1706            let _ = (min, max, width);
1707        }
1708    }
1709
1710    /// Set the height of a row (in points, Excel default is ~15).
1711    pub fn set_row_height(&mut self, row: u32, height: f64) {
1712        self.row_heights.insert(row, height);
1713    }
1714
1715    /// Freeze the top `rows` rows and left `cols` columns.
1716    ///
1717    /// Pass `0` for either dimension to freeze only the other axis.
1718    /// For example, `set_freeze_pane(1, 0)` freezes the header row.
1719    ///
1720    /// This is equivalent to View → Freeze Panes in Excel.
1721    ///
1722    /// # Example
1723    ///
1724    /// ```ignore
1725    /// // Freeze the first row (common for headers)
1726    /// sheet.set_freeze_pane(1, 0);
1727    ///
1728    /// // Freeze both first row and first column
1729    /// sheet.set_freeze_pane(1, 1);
1730    /// ```
1731    pub fn set_freeze_pane(&mut self, rows: u32, cols: u32) {
1732        self.apply_freeze_pane(rows, cols);
1733    }
1734
1735    /// Freeze the top `n` rows (e.g., header rows).
1736    ///
1737    /// Shorthand for `set_freeze_pane(n, 0)`.
1738    pub fn freeze_rows(&mut self, n: u32) {
1739        let (_, c) = self.current_freeze_pane();
1740        self.apply_freeze_pane(n, c);
1741    }
1742
1743    /// Freeze the left `n` columns.
1744    ///
1745    /// Shorthand for `set_freeze_pane(0, n)`.
1746    pub fn freeze_cols(&mut self, n: u32) {
1747        let (r, _) = self.current_freeze_pane();
1748        self.apply_freeze_pane(r, n);
1749    }
1750
1751    /// Read back the current freeze pane settings from the worksheet.
1752    fn current_freeze_pane(&self) -> (u32, u32) {
1753        #[cfg(feature = "sml-structure")]
1754        {
1755            let pane = self
1756                .worksheet
1757                .sheet_views
1758                .as_deref()
1759                .and_then(|v| v.sheet_view.first())
1760                .and_then(|sv| sv.pane.as_deref());
1761            if let Some(p) = pane {
1762                return (
1763                    p.y_split.map(|y| y as u32).unwrap_or(0),
1764                    p.x_split.map(|x| x as u32).unwrap_or(0),
1765                );
1766            }
1767        }
1768        (0, 0)
1769    }
1770
1771    /// Write freeze pane settings directly into the worksheet's sheet_views.
1772    fn apply_freeze_pane(&mut self, frozen_rows: u32, frozen_cols: u32) {
1773        #[cfg(feature = "sml-structure")]
1774        {
1775            if frozen_rows == 0 && frozen_cols == 0 {
1776                self.worksheet.sheet_views = None;
1777                return;
1778            }
1779            // Determine which pane is active after freezing.
1780            let active_pane = match (frozen_rows > 0, frozen_cols > 0) {
1781                (true, true) => types::PaneType::BottomRight,
1782                (true, false) => types::PaneType::BottomLeft,
1783                (false, true) => types::PaneType::TopRight,
1784                (false, false) => types::PaneType::TopLeft,
1785            };
1786            // topLeftCell is the first unfrozen cell (e.g., "B2" for 1 frozen row + 1 frozen col).
1787            let top_left_col = if frozen_cols > 0 {
1788                column_to_letter(frozen_cols + 1)
1789            } else {
1790                "A".to_string()
1791            };
1792            let top_left_row = frozen_rows + 1;
1793            let top_left_cell = format!("{}{}", top_left_col, top_left_row);
1794
1795            let pane = types::Pane {
1796                x_split: (frozen_cols > 0).then_some(frozen_cols as f64),
1797                y_split: (frozen_rows > 0).then_some(frozen_rows as f64),
1798                top_left_cell: Some(top_left_cell),
1799                active_pane: Some(active_pane),
1800                state: Some(types::PaneState::Frozen),
1801                #[cfg(feature = "extra-attrs")]
1802                extra_attrs: Default::default(),
1803            };
1804            let sheet_view = types::SheetView {
1805                #[cfg(feature = "sml-protection")]
1806                window_protection: None,
1807                #[cfg(feature = "sml-formulas")]
1808                show_formulas: None,
1809                #[cfg(feature = "sml-styling")]
1810                show_grid_lines: None,
1811                #[cfg(feature = "sml-styling")]
1812                show_row_col_headers: None,
1813                #[cfg(feature = "sml-styling")]
1814                show_zeros: None,
1815                #[cfg(feature = "sml-i18n")]
1816                right_to_left: None,
1817                tab_selected: None,
1818                #[cfg(feature = "sml-layout")]
1819                show_ruler: None,
1820                #[cfg(feature = "sml-structure")]
1821                show_outline_symbols: None,
1822                #[cfg(feature = "sml-styling")]
1823                default_grid_color: None,
1824                #[cfg(feature = "sml-layout")]
1825                show_white_space: None,
1826                view: None,
1827                top_left_cell: None,
1828                #[cfg(feature = "sml-styling")]
1829                color_id: None,
1830                zoom_scale: None,
1831                zoom_scale_normal: None,
1832                #[cfg(feature = "sml-layout")]
1833                zoom_scale_sheet_layout_view: None,
1834                #[cfg(feature = "sml-layout")]
1835                zoom_scale_page_layout_view: None,
1836                workbook_view_id: 0,
1837                #[cfg(feature = "sml-structure")]
1838                pane: Some(Box::new(pane)),
1839                selection: vec![types::Selection {
1840                    pane: Some(active_pane),
1841                    active_cell: None,
1842                    active_cell_id: None,
1843                    square_reference: None,
1844                    #[cfg(feature = "extra-attrs")]
1845                    extra_attrs: Default::default(),
1846                }],
1847                #[cfg(feature = "sml-pivot")]
1848                pivot_selection: Vec::new(),
1849                #[cfg(feature = "sml-extensions")]
1850                extension_list: None,
1851                #[cfg(feature = "extra-attrs")]
1852                extra_attrs: Default::default(),
1853                #[cfg(feature = "extra-children")]
1854                extra_children: Vec::new(),
1855            };
1856            self.worksheet.sheet_views = Some(Box::new(types::SheetViews {
1857                sheet_view: vec![sheet_view],
1858                extension_list: None,
1859                #[cfg(feature = "extra-children")]
1860                extra_children: Vec::new(),
1861            }));
1862        }
1863        #[cfg(not(feature = "sml-structure"))]
1864        {
1865            let _ = (frozen_rows, frozen_cols);
1866        }
1867    }
1868
1869    /// Add conditional formatting to the sheet.
1870    pub fn add_conditional_format(&mut self, cf: ConditionalFormat) {
1871        #[cfg(feature = "sml-styling")]
1872        self.worksheet
1873            .conditional_formatting
1874            .push(build_one_conditional_format(&cf));
1875        #[cfg(not(feature = "sml-styling"))]
1876        let _ = cf;
1877    }
1878
1879    /// Add data validation to the sheet.
1880    pub fn add_data_validation(&mut self, dv: DataValidationBuilder) {
1881        #[cfg(feature = "sml-validation")]
1882        {
1883            let validation = build_one_data_validation(&dv);
1884            let dvs = self.worksheet.data_validations.get_or_insert_with(|| {
1885                Box::new(types::DataValidations {
1886                    disable_prompts: None,
1887                    x_window: None,
1888                    y_window: None,
1889                    count: None,
1890                    data_validation: Vec::new(),
1891                    #[cfg(feature = "extra-attrs")]
1892                    extra_attrs: Default::default(),
1893                    #[cfg(feature = "extra-children")]
1894                    extra_children: Vec::new(),
1895                })
1896            });
1897            dvs.data_validation.push(validation);
1898            dvs.count = Some(dvs.data_validation.len() as u32);
1899        }
1900        #[cfg(not(feature = "sml-validation"))]
1901        let _ = dv;
1902    }
1903
1904    /// Add a comment (note) to a cell.
1905    ///
1906    /// # Example
1907    ///
1908    /// ```ignore
1909    /// sheet.add_comment("A1", "This is a comment");
1910    /// ```
1911    pub fn add_comment(&mut self, reference: impl Into<String>, text: impl Into<String>) {
1912        self.comments.push(CommentBuilder::new(reference, text));
1913    }
1914
1915    /// Add a comment (note) with an author to a cell.
1916    ///
1917    /// # Example
1918    ///
1919    /// ```ignore
1920    /// sheet.add_comment_with_author("A1", "Review needed", "John Doe");
1921    /// ```
1922    pub fn add_comment_with_author(
1923        &mut self,
1924        reference: impl Into<String>,
1925        text: impl Into<String>,
1926        author: impl Into<String>,
1927    ) {
1928        self.comments
1929            .push(CommentBuilder::with_author(reference, text, author));
1930    }
1931
1932    /// Add a comment using a builder for full control.
1933    pub fn add_comment_builder(&mut self, comment: CommentBuilder) {
1934        self.comments.push(comment);
1935    }
1936
1937    /// Enable auto-filter dropdowns on a header range (e.g. `"A1:D1"`).
1938    ///
1939    /// Excel will add dropdown buttons to every column in the range.  Callers
1940    /// typically set this on the same row as their column headers.
1941    ///
1942    /// # Example
1943    ///
1944    /// ```ignore
1945    /// sheet.set_cell("A1", "Name");
1946    /// sheet.set_cell("B1", "Score");
1947    /// sheet.set_auto_filter("A1:B1");
1948    /// ```
1949    pub fn set_auto_filter(&mut self, range: &str) {
1950        #[cfg(feature = "sml-filtering")]
1951        {
1952            self.worksheet.auto_filter = Some(Box::new(types::AutoFilter {
1953                reference: Some(range.to_string()),
1954                filter_column: Vec::new(),
1955                sort_state: None,
1956                #[cfg(feature = "sml-extensions")]
1957                extension_list: None,
1958                #[cfg(feature = "extra-attrs")]
1959                extra_attrs: Default::default(),
1960                #[cfg(feature = "extra-children")]
1961                extra_children: Vec::new(),
1962            }));
1963        }
1964        #[cfg(not(feature = "sml-filtering"))]
1965        let _ = range;
1966    }
1967
1968    /// Add an external hyperlink on a cell (e.g. a URL).
1969    ///
1970    /// The URL is written as a relationship in the sheet's `.rels` file, and
1971    /// the `<hyperlink>` element references it by `r:id`.
1972    ///
1973    /// # Example
1974    ///
1975    /// ```ignore
1976    /// sheet.set_cell("A1", "Visit us");
1977    /// sheet.add_hyperlink("A1", "https://example.com");
1978    /// ```
1979    pub fn add_hyperlink(&mut self, reference: &str, url: &str) {
1980        self.hyperlinks.push(HyperlinkEntry {
1981            reference: reference.to_string(),
1982            dest: HyperlinkDest::External(url.to_string()),
1983            tooltip: None,
1984            display: None,
1985        });
1986    }
1987
1988    /// Set a tooltip on the last-added hyperlink.
1989    ///
1990    /// Call immediately after [`add_hyperlink`](Self::add_hyperlink) or
1991    /// [`add_internal_hyperlink`](Self::add_internal_hyperlink).
1992    pub fn set_last_hyperlink_tooltip(&mut self, tooltip: &str) {
1993        if let Some(h) = self.hyperlinks.last_mut() {
1994            h.tooltip = Some(tooltip.to_string());
1995        }
1996    }
1997
1998    /// Add an internal hyperlink that navigates to another location in the
1999    /// workbook (e.g. `"Sheet2!A1"`).
2000    ///
2001    /// Internal hyperlinks do not need a relationship — the location is stored
2002    /// inline in the `<hyperlink>` element.
2003    ///
2004    /// # Example
2005    ///
2006    /// ```ignore
2007    /// sheet.add_internal_hyperlink("B1", "Sheet2!A1");
2008    /// ```
2009    pub fn add_internal_hyperlink(&mut self, reference: &str, location: &str) {
2010        self.hyperlinks.push(HyperlinkEntry {
2011            reference: reference.to_string(),
2012            dest: HyperlinkDest::Internal(location.to_string()),
2013            tooltip: None,
2014            display: None,
2015        });
2016    }
2017
2018    // -------------------------------------------------------------------------
2019    // Page layout
2020    // -------------------------------------------------------------------------
2021
2022    /// Set the page setup options for printing (ECMA-376 §18.3.1.63).
2023    ///
2024    /// # Example
2025    ///
2026    /// ```ignore
2027    /// sheet.set_page_setup(PageSetupOptions::new()
2028    ///     .with_orientation(PageOrientation::Landscape)
2029    ///     .with_paper_size(9));  // A4
2030    /// ```
2031    pub fn set_page_setup(&mut self, opts: PageSetupOptions) {
2032        #[cfg(feature = "sml-layout")]
2033        {
2034            let orientation = opts.orientation.map(|o| match o {
2035                PageOrientation::Portrait => types::STOrientation::Portrait,
2036                PageOrientation::Landscape => types::STOrientation::Landscape,
2037            });
2038            self.worksheet.page_setup = Some(Box::new(types::PageSetup {
2039                #[cfg(feature = "sml-layout")]
2040                paper_size: opts.paper_size,
2041                #[cfg(feature = "sml-layout")]
2042                paper_height: None,
2043                #[cfg(feature = "sml-layout")]
2044                paper_width: None,
2045                #[cfg(feature = "sml-layout")]
2046                scale: opts.scale,
2047                #[cfg(feature = "sml-layout")]
2048                first_page_number: None,
2049                #[cfg(feature = "sml-layout")]
2050                fit_to_width: opts.fit_to_width,
2051                #[cfg(feature = "sml-layout")]
2052                fit_to_height: opts.fit_to_height,
2053                #[cfg(feature = "sml-layout")]
2054                page_order: None,
2055                #[cfg(feature = "sml-layout")]
2056                orientation,
2057                #[cfg(feature = "sml-layout")]
2058                use_printer_defaults: None,
2059                #[cfg(feature = "sml-layout")]
2060                black_and_white: None,
2061                #[cfg(feature = "sml-layout")]
2062                draft: None,
2063                #[cfg(feature = "sml-layout")]
2064                cell_comments: None,
2065                #[cfg(feature = "sml-layout")]
2066                use_first_page_number: None,
2067                #[cfg(feature = "sml-layout")]
2068                errors: None,
2069                #[cfg(feature = "sml-layout")]
2070                horizontal_dpi: None,
2071                #[cfg(feature = "sml-layout")]
2072                vertical_dpi: None,
2073                #[cfg(feature = "sml-layout")]
2074                copies: None,
2075                id: None,
2076                #[cfg(feature = "extra-attrs")]
2077                extra_attrs: Default::default(),
2078            }));
2079        }
2080        #[cfg(not(feature = "sml-layout"))]
2081        let _ = opts;
2082    }
2083
2084    /// Set the page margins for printing (ECMA-376 §18.3.1.62).
2085    ///
2086    /// All measurements are in inches.
2087    ///
2088    /// # Example
2089    ///
2090    /// ```ignore
2091    /// sheet.set_page_margins(0.7, 0.7, 0.75, 0.75, 0.3, 0.3);
2092    /// ```
2093    pub fn set_page_margins(
2094        &mut self,
2095        left: f64,
2096        right: f64,
2097        top: f64,
2098        bottom: f64,
2099        header: f64,
2100        footer: f64,
2101    ) {
2102        #[cfg(feature = "sml-layout")]
2103        {
2104            self.worksheet.page_margins = Some(Box::new(types::PageMargins {
2105                #[cfg(feature = "sml-layout")]
2106                left,
2107                #[cfg(feature = "sml-layout")]
2108                right,
2109                #[cfg(feature = "sml-layout")]
2110                top,
2111                #[cfg(feature = "sml-layout")]
2112                bottom,
2113                #[cfg(feature = "sml-layout")]
2114                header,
2115                #[cfg(feature = "sml-layout")]
2116                footer,
2117                #[cfg(feature = "extra-attrs")]
2118                extra_attrs: Default::default(),
2119            }));
2120        }
2121        #[cfg(not(feature = "sml-layout"))]
2122        let _ = (left, right, top, bottom, header, footer);
2123    }
2124
2125    // -------------------------------------------------------------------------
2126    // Row and column grouping (outline)
2127    // -------------------------------------------------------------------------
2128
2129    /// Set the outline level for a row (ECMA-376 §18.3.1.73 `@outlineLevel`).
2130    ///
2131    /// Level 0 means no grouping; levels 1–7 define nested groups.
2132    pub fn set_row_outline_level(&mut self, row: u32, level: u8) {
2133        self.row_outline_levels.insert(row, level);
2134    }
2135
2136    /// Set whether a row should be displayed as collapsed (ECMA-376 §18.3.1.73 `@collapsed`).
2137    pub fn set_row_collapsed(&mut self, row: u32, collapsed: bool) {
2138        self.row_collapsed.insert(row, collapsed);
2139    }
2140
2141    /// Set the outline level for a column (ECMA-376 §18.3.1.13 `@outlineLevel`).
2142    ///
2143    /// Column is specified by letter (e.g., "A", "B", "AA").
2144    pub fn set_column_outline_level(&mut self, col: &str, level: u8) {
2145        if let Some(col_num) = column_letter_to_number(col) {
2146            self.col_outline_levels.insert(col_num, level);
2147        }
2148    }
2149
2150    /// Set whether a column should be displayed as collapsed (ECMA-376 §18.3.1.13 `@collapsed`).
2151    ///
2152    /// Column is specified by letter (e.g., "A", "B", "AA").
2153    pub fn set_column_collapsed(&mut self, col: &str, collapsed: bool) {
2154        if let Some(col_num) = column_letter_to_number(col) {
2155            self.col_collapsed.insert(col_num, collapsed);
2156        }
2157    }
2158
2159    // -------------------------------------------------------------------------
2160    // Ignored errors
2161    // -------------------------------------------------------------------------
2162
2163    /// Suppress an Excel validation warning for a cell range (ECMA-376 §18.3.1.35).
2164    ///
2165    /// This tells Excel to ignore the specified error type for the given range,
2166    /// preventing the green-triangle warning indicators.
2167    ///
2168    /// # Example
2169    ///
2170    /// ```ignore
2171    /// // Suppress the "number stored as text" warning on A1:A10
2172    /// sheet.add_ignored_error("A1:A10", IgnoredErrorType::NumberStoredAsText);
2173    /// ```
2174    pub fn add_ignored_error(&mut self, sqref: &str, error_type: IgnoredErrorType) {
2175        #[cfg(feature = "sml-validation")]
2176        {
2177            let mut entry = types::IgnoredError {
2178                square_reference: sqref.to_string(),
2179                eval_error: None,
2180                two_digit_text_year: None,
2181                number_stored_as_text: None,
2182                formula: None,
2183                formula_range: None,
2184                unlocked_formula: None,
2185                empty_cell_reference: None,
2186                list_data_validation: None,
2187                calculated_column: None,
2188                #[cfg(feature = "extra-attrs")]
2189                extra_attrs: Default::default(),
2190            };
2191            match error_type {
2192                IgnoredErrorType::NumberStoredAsText => {
2193                    entry.number_stored_as_text = Some(true);
2194                }
2195                IgnoredErrorType::Formula => {
2196                    entry.formula = Some(true);
2197                }
2198                IgnoredErrorType::TwoDigitTextYear => {
2199                    entry.two_digit_text_year = Some(true);
2200                }
2201                IgnoredErrorType::EvalError => {
2202                    entry.eval_error = Some(true);
2203                }
2204                IgnoredErrorType::FormulaRange => {
2205                    entry.formula_range = Some(true);
2206                }
2207                IgnoredErrorType::UnlockedFormula => {
2208                    entry.unlocked_formula = Some(true);
2209                }
2210                IgnoredErrorType::EmptyCellReference => {
2211                    entry.empty_cell_reference = Some(true);
2212                }
2213                IgnoredErrorType::ListDataValidation => {
2214                    entry.list_data_validation = Some(true);
2215                }
2216                IgnoredErrorType::CalculatedColumn => {
2217                    entry.calculated_column = Some(true);
2218                }
2219            }
2220            let ie = self.worksheet.ignored_errors.get_or_insert_with(|| {
2221                Box::new(types::IgnoredErrors {
2222                    ignored_error: Vec::new(),
2223                    extension_list: None,
2224                    #[cfg(feature = "extra-children")]
2225                    extra_children: Vec::new(),
2226                })
2227            });
2228            ie.ignored_error.push(entry);
2229        }
2230        #[cfg(not(feature = "sml-validation"))]
2231        let _ = (sqref, error_type);
2232    }
2233
2234    // -------------------------------------------------------------------------
2235    // Sheet protection
2236    // -------------------------------------------------------------------------
2237
2238    /// Protect the sheet with optional restrictions (ECMA-376 §18.3.1.85).
2239    ///
2240    /// Requires the `sml-protection` feature.
2241    ///
2242    /// # Example
2243    ///
2244    /// ```ignore
2245    /// sheet.set_sheet_protection(SheetProtectionOptions {
2246    ///     sheet: true,
2247    ///     password: Some("secret".to_string()),
2248    ///     format_cells: true,
2249    ///     ..Default::default()
2250    /// });
2251    /// ```
2252    #[cfg(feature = "sml-protection")]
2253    pub fn set_sheet_protection(&mut self, opts: SheetProtectionOptions) {
2254        {
2255            let password = opts
2256                .password
2257                .as_deref()
2258                .filter(|p| !p.is_empty())
2259                .map(ooxml_xor_hash);
2260
2261            self.worksheet.sheet_protection = Some(Box::new(types::SheetProtection {
2262                password,
2263                algorithm_name: None,
2264                hash_value: None,
2265                salt_value: None,
2266                spin_count: None,
2267                sheet: if opts.sheet { Some(true) } else { None },
2268                objects: None,
2269                scenarios: None,
2270                format_cells: if opts.format_cells { Some(true) } else { None },
2271                format_columns: if opts.format_columns {
2272                    Some(true)
2273                } else {
2274                    None
2275                },
2276                format_rows: if opts.format_rows { Some(true) } else { None },
2277                insert_columns: if opts.insert_columns {
2278                    Some(true)
2279                } else {
2280                    None
2281                },
2282                insert_rows: if opts.insert_rows { Some(true) } else { None },
2283                insert_hyperlinks: None,
2284                delete_columns: if opts.delete_columns {
2285                    Some(true)
2286                } else {
2287                    None
2288                },
2289                delete_rows: if opts.delete_rows { Some(true) } else { None },
2290                select_locked_cells: if opts.select_locked_cells {
2291                    Some(true)
2292                } else {
2293                    None
2294                },
2295                sort: if opts.sort { Some(true) } else { None },
2296                auto_filter: if opts.auto_filter { Some(true) } else { None },
2297                pivot_tables: if opts.pivot_tables { Some(true) } else { None },
2298                select_unlocked_cells: if opts.select_unlocked_cells {
2299                    Some(true)
2300                } else {
2301                    None
2302                },
2303                #[cfg(feature = "extra-attrs")]
2304                extra_attrs: Default::default(),
2305            }));
2306        }
2307    }
2308
2309    // -------------------------------------------------------------------------
2310    // Tab color
2311    // -------------------------------------------------------------------------
2312
2313    /// Set the sheet tab color (ECMA-376 §18.3.1.77).
2314    ///
2315    /// The color is an RGB hex string (e.g. `"FF0000"` for red, `"4472C4"` for
2316    /// the Excel blue theme color).
2317    ///
2318    /// # Example
2319    ///
2320    /// ```ignore
2321    /// sheet.set_tab_color("FF0000"); // red tab
2322    /// ```
2323    pub fn set_tab_color(&mut self, rgb: &str) {
2324        #[cfg(feature = "sml-styling")]
2325        {
2326            let color = Box::new(types::Color {
2327                auto: None,
2328                indexed: None,
2329                rgb: Some(hex_color_to_bytes(rgb)),
2330                theme: None,
2331                tint: None,
2332                #[cfg(feature = "extra-attrs")]
2333                extra_attrs: Default::default(),
2334            });
2335            let props = self
2336                .worksheet
2337                .sheet_properties
2338                .get_or_insert_with(|| Box::new(types::SheetProperties::default()));
2339            props.tab_color = Some(color);
2340        }
2341        #[cfg(not(feature = "sml-styling"))]
2342        let _ = rgb;
2343    }
2344
2345    // -------------------------------------------------------------------------
2346    // Sheet view options
2347    // -------------------------------------------------------------------------
2348
2349    /// Show or hide gridlines in the default sheet view (ECMA-376 §18.3.1.76
2350    /// `@showGridLines`).
2351    ///
2352    /// Gridlines are visible by default in Excel; pass `false` to hide them.
2353    pub fn set_show_gridlines(&mut self, show: bool) {
2354        self.show_gridlines = Some(show);
2355    }
2356
2357    /// Show or hide row and column headers in the default sheet view
2358    /// (ECMA-376 §18.3.1.76 `@showRowColHeaders`).
2359    ///
2360    /// Row/col headers are visible by default in Excel; pass `false` to hide
2361    /// them.
2362    pub fn set_show_row_col_headers(&mut self, show: bool) {
2363        self.show_row_col_headers = Some(show);
2364    }
2365
2366    // -------------------------------------------------------------------------
2367    // Chart embedding (sml-charts)
2368    // -------------------------------------------------------------------------
2369
2370    /// Embed a chart in the worksheet.
2371    ///
2372    /// The chart XML must be a complete `<c:chartSpace>` document (ECMA-376
2373    /// §21.2).  The position and size are specified in cell units; `(x, y)` is
2374    /// the 0-based column/row of the top-left anchor and `(width, height)` is
2375    /// the extent in cells.
2376    ///
2377    /// At write time the chart is written to `xl/charts/chart{n}.xml`, a
2378    /// drawing part is created at `xl/drawings/drawing{n}.xml`, and the
2379    /// worksheet references the drawing via a relationship.
2380    ///
2381    /// Requires the `sml-charts` feature.
2382    pub fn embed_chart(
2383        &mut self,
2384        chart_xml: &[u8],
2385        x: u32,
2386        y: u32,
2387        width: u32,
2388        height: u32,
2389    ) -> &mut Self {
2390        #[cfg(feature = "sml-charts")]
2391        self.charts.push(ChartEntry {
2392            chart_xml: chart_xml.to_vec(),
2393            x,
2394            y,
2395            width,
2396            height,
2397        });
2398        #[cfg(not(feature = "sml-charts"))]
2399        let _ = (chart_xml, x, y, width, height);
2400        self
2401    }
2402
2403    // -------------------------------------------------------------------------
2404    // Pivot tables (sml-pivot)
2405    // -------------------------------------------------------------------------
2406
2407    /// Add a pivot table to the worksheet.
2408    ///
2409    /// This writes:
2410    /// - `xl/pivotCache/pivotCacheDefinition{n}.xml`
2411    /// - `xl/pivotCache/pivotCacheRecords{n}.xml`
2412    /// - `xl/pivotTables/pivotTable{n}.xml`
2413    ///
2414    /// and the necessary relationship files.  The cache and pivot table are
2415    /// cross-linked via the workbook `<pivotCaches>` element.
2416    ///
2417    /// Requires the `sml-pivot` feature.
2418    #[cfg(feature = "sml-pivot")]
2419    pub fn add_pivot_table(&mut self, opts: PivotTableOptions) -> &mut Self {
2420        self.pivot_tables.push(PivotEntry { opts });
2421        self
2422    }
2423
2424    /// Get the sheet name as supplied to [`WorkbookBuilder::add_sheet`].
2425    pub fn name(&self) -> &str {
2426        &self.name
2427    }
2428}
2429
2430/// Builder for creating Excel workbooks.
2431///
2432/// # Why `WorkbookBuilder` doesn't hold `types::Workbook` directly
2433///
2434/// Unlike WML (`DocumentBuilder`) and PML (`PresentationBuilder`), this builder
2435/// cannot eagerly hold a `types::Workbook` because cell style indices are
2436/// cross-sheet: every `set_cell_style()` call on any sheet potentially adds a new
2437/// entry to the shared `Stylesheet` (fonts, fills, borders, number formats), and
2438/// the final deduplicated indices aren't known until all cells across all sheets
2439/// have been added.  Baking those indices into `SheetData` rows upfront would
2440/// require retroactively rewriting previously built rows when new styles appear.
2441///
2442/// Instead, `WorkbookBuilder` accumulates raw style values during building and
2443/// resolves them to index-based `Stylesheet` + `SheetData` rows at `write()` time.
2444/// `SheetBuilder` holds `types::Worksheet` directly for everything that doesn't
2445/// depend on style resolution (merge cells, freeze panes, column widths, etc.).
2446/// A named cell style entry for `WorkbookBuilder::add_cell_style`.
2447#[derive(Debug, Clone)]
2448struct NamedCellStyle {
2449    name: String,
2450    format_id: u32,
2451}
2452
2453/// Builder for creating Excel workbooks (XLSX files).
2454///
2455/// Accumulates sheets, styles, shared strings, and defined names during
2456/// construction.  Call [`save`](Self::save) or [`write`](Self::write) to
2457/// serialize everything into an XLSX package.
2458///
2459/// Style indices (fonts, fills, borders, number formats) are resolved across
2460/// all sheets at write time, so any number of distinct `CellStyle` values may
2461/// be mixed freely before calling `write`.
2462///
2463/// # Example
2464///
2465/// ```no_run
2466/// use ooxml_sml::WorkbookBuilder;
2467///
2468/// let mut wb = WorkbookBuilder::new();
2469/// let sheet = wb.add_sheet("Sheet1");
2470/// sheet.set_cell("A1", "Hello");
2471/// sheet.set_cell("B1", 42.0_f64);
2472/// wb.save("output.xlsx").unwrap();
2473/// ```
2474#[derive(Debug)]
2475pub struct WorkbookBuilder {
2476    sheets: Vec<SheetBuilder>,
2477    shared_strings: Vec<String>,
2478    string_index: HashMap<String, usize>,
2479    defined_names: Vec<DefinedNameBuilder>,
2480    // Style collections (populated during write)
2481    fonts: Vec<FontStyle>,
2482    font_index: HashMap<FontStyleKey, usize>,
2483    fills: Vec<FillStyle>,
2484    fill_index: HashMap<FillStyleKey, usize>,
2485    borders: Vec<BorderStyle>,
2486    border_index: HashMap<BorderStyleKey, usize>,
2487    number_formats: Vec<String>,
2488    number_format_index: HashMap<String, u32>,
2489    cell_formats: Vec<CellFormatRecord>,
2490    cell_format_index: HashMap<CellFormatKey, usize>,
2491    /// Extra named cell styles beyond "Normal" (sml-styling).
2492    extra_cell_styles: Vec<NamedCellStyle>,
2493    /// Optional workbook protection (sml-protection).
2494    #[cfg(feature = "sml-protection")]
2495    workbook_protection: Option<types::WorkbookProtection>,
2496}
2497
2498// Helper types for style deduplication
2499#[derive(Debug, Clone, Hash, PartialEq, Eq)]
2500struct FontStyleKey {
2501    name: Option<String>,
2502    size_bits: Option<u64>, // f64 as bits for hashing
2503    bold: bool,
2504    italic: bool,
2505    underline: Option<String>,
2506    strikethrough: bool,
2507    color: Option<String>,
2508}
2509
2510impl From<&FontStyle> for FontStyleKey {
2511    fn from(f: &FontStyle) -> Self {
2512        Self {
2513            name: f.name.clone(),
2514            size_bits: f.size.map(|s| s.to_bits()),
2515            bold: f.bold,
2516            italic: f.italic,
2517            underline: f.underline.map(|u| u.to_xml_value().to_string()),
2518            strikethrough: f.strikethrough,
2519            color: f.color.clone(),
2520        }
2521    }
2522}
2523
2524#[derive(Debug, Clone, Hash, PartialEq, Eq)]
2525struct FillStyleKey {
2526    pattern: String,
2527    fg_color: Option<String>,
2528    bg_color: Option<String>,
2529}
2530
2531impl From<&FillStyle> for FillStyleKey {
2532    fn from(f: &FillStyle) -> Self {
2533        Self {
2534            pattern: f.pattern.to_xml_value().to_string(),
2535            fg_color: f.fg_color.clone(),
2536            bg_color: f.bg_color.clone(),
2537        }
2538    }
2539}
2540
2541#[derive(Debug, Clone, Hash, PartialEq, Eq)]
2542struct BorderStyleKey {
2543    left: Option<(String, Option<String>)>,
2544    right: Option<(String, Option<String>)>,
2545    top: Option<(String, Option<String>)>,
2546    bottom: Option<(String, Option<String>)>,
2547}
2548
2549impl From<&BorderStyle> for BorderStyleKey {
2550    fn from(b: &BorderStyle) -> Self {
2551        Self {
2552            left: b
2553                .left
2554                .as_ref()
2555                .map(|s| (s.style.to_xml_value().to_string(), s.color.clone())),
2556            right: b
2557                .right
2558                .as_ref()
2559                .map(|s| (s.style.to_xml_value().to_string(), s.color.clone())),
2560            top: b
2561                .top
2562                .as_ref()
2563                .map(|s| (s.style.to_xml_value().to_string(), s.color.clone())),
2564            bottom: b
2565                .bottom
2566                .as_ref()
2567                .map(|s| (s.style.to_xml_value().to_string(), s.color.clone())),
2568        }
2569    }
2570}
2571
2572#[derive(Debug, Clone, Hash, PartialEq, Eq)]
2573struct CellFormatKey {
2574    font_id: usize,
2575    fill_id: usize,
2576    border_id: usize,
2577    num_fmt_id: u32,
2578    horizontal: Option<String>,
2579    vertical: Option<String>,
2580    wrap_text: bool,
2581}
2582
2583#[derive(Debug, Clone)]
2584#[allow(dead_code)] // Fields read only with sml-styling feature
2585struct CellFormatRecord {
2586    font_id: usize,
2587    fill_id: usize,
2588    border_id: usize,
2589    num_fmt_id: u32,
2590    horizontal: Option<HorizontalAlignment>,
2591    vertical: Option<VerticalAlignment>,
2592    wrap_text: bool,
2593}
2594
2595impl Default for WorkbookBuilder {
2596    fn default() -> Self {
2597        Self::new()
2598    }
2599}
2600
2601impl WorkbookBuilder {
2602    /// Create a new workbook builder.
2603    pub fn new() -> Self {
2604        Self {
2605            sheets: Vec::new(),
2606            shared_strings: Vec::new(),
2607            string_index: HashMap::new(),
2608            defined_names: Vec::new(),
2609            fonts: Vec::new(),
2610            font_index: HashMap::new(),
2611            fills: Vec::new(),
2612            fill_index: HashMap::new(),
2613            borders: Vec::new(),
2614            border_index: HashMap::new(),
2615            number_formats: Vec::new(),
2616            number_format_index: HashMap::new(),
2617            cell_formats: Vec::new(),
2618            cell_format_index: HashMap::new(),
2619            extra_cell_styles: Vec::new(),
2620            #[cfg(feature = "sml-protection")]
2621            workbook_protection: None,
2622        }
2623    }
2624
2625    /// Add a new sheet to the workbook.
2626    pub fn add_sheet(&mut self, name: impl Into<String>) -> &mut SheetBuilder {
2627        self.sheets.push(SheetBuilder::new(name));
2628        self.sheets.last_mut().unwrap()
2629    }
2630
2631    /// Get a mutable reference to a sheet by index.
2632    pub fn sheet_mut(&mut self, index: usize) -> Option<&mut SheetBuilder> {
2633        self.sheets.get_mut(index)
2634    }
2635
2636    /// Get the number of sheets.
2637    pub fn sheet_count(&self) -> usize {
2638        self.sheets.len()
2639    }
2640
2641    /// Add a defined name (named range) with global (workbook) scope.
2642    ///
2643    /// # Example
2644    ///
2645    /// ```ignore
2646    /// let mut wb = WorkbookBuilder::new();
2647    /// wb.add_sheet("Sheet1");
2648    /// wb.add_defined_name("MyRange", "Sheet1!$A$1:$B$10");
2649    /// ```
2650    pub fn add_defined_name(&mut self, name: impl Into<String>, reference: impl Into<String>) {
2651        self.defined_names
2652            .push(DefinedNameBuilder::new(name, reference));
2653    }
2654
2655    /// Add a defined name (named range) with sheet scope.
2656    ///
2657    /// Sheet-scoped names are only visible within the specified sheet.
2658    ///
2659    /// # Example
2660    ///
2661    /// ```ignore
2662    /// let mut wb = WorkbookBuilder::new();
2663    /// wb.add_sheet("Sheet1");
2664    /// // This name is only visible in Sheet1 (index 0)
2665    /// wb.add_defined_name_with_scope("LocalRange", "Sheet1!$A$1:$B$10", 0);
2666    /// ```
2667    pub fn add_defined_name_with_scope(
2668        &mut self,
2669        name: impl Into<String>,
2670        reference: impl Into<String>,
2671        sheet_index: u32,
2672    ) {
2673        self.defined_names
2674            .push(DefinedNameBuilder::with_sheet_scope(
2675                name,
2676                reference,
2677                sheet_index,
2678            ));
2679    }
2680
2681    /// Add a defined name using a builder for full control.
2682    ///
2683    /// # Example
2684    ///
2685    /// ```ignore
2686    /// let mut wb = WorkbookBuilder::new();
2687    /// wb.add_sheet("Sheet1");
2688    ///
2689    /// let name = DefinedNameBuilder::new("MyRange", "Sheet1!$A$1:$B$10")
2690    ///     .with_comment("Sales data range")
2691    ///     .hidden();
2692    /// wb.add_defined_name_builder(name);
2693    /// ```
2694    pub fn add_defined_name_builder(&mut self, builder: DefinedNameBuilder) {
2695        self.defined_names.push(builder);
2696    }
2697
2698    /// Add a print area for a sheet.
2699    ///
2700    /// # Example
2701    ///
2702    /// ```ignore
2703    /// let mut wb = WorkbookBuilder::new();
2704    /// wb.add_sheet("Sheet1");
2705    /// wb.set_print_area(0, "Sheet1!$A$1:$G$20");
2706    /// ```
2707    pub fn set_print_area(&mut self, sheet_index: u32, reference: impl Into<String>) {
2708        self.defined_names
2709            .push(DefinedNameBuilder::print_area(sheet_index, reference));
2710    }
2711
2712    /// Add print titles (repeating rows/columns) for a sheet.
2713    ///
2714    /// # Example
2715    ///
2716    /// ```ignore
2717    /// let mut wb = WorkbookBuilder::new();
2718    /// wb.add_sheet("Sheet1");
2719    /// // Repeat rows 1-2 on each printed page
2720    /// wb.set_print_titles(0, "Sheet1!$1:$2");
2721    /// ```
2722    pub fn set_print_titles(&mut self, sheet_index: u32, reference: impl Into<String>) {
2723        self.defined_names
2724            .push(DefinedNameBuilder::print_titles(sheet_index, reference));
2725    }
2726
2727    // -------------------------------------------------------------------------
2728    // Workbook protection
2729    // -------------------------------------------------------------------------
2730
2731    /// Protect the workbook structure and/or windows (ECMA-376 §18.2.29).
2732    ///
2733    /// Requires the `sml-protection` feature.
2734    ///
2735    /// - `lock_structure`: prevent adding, deleting, or moving sheets.
2736    /// - `lock_windows`: prevent resizing/moving the workbook window.
2737    /// - `password`: optional plain-text password (hashed with the OOXML XOR
2738    ///   algorithm).
2739    ///
2740    /// # Example
2741    ///
2742    /// ```ignore
2743    /// wb.set_workbook_protection(true, false, Some("secret"));
2744    /// ```
2745    pub fn set_workbook_protection(
2746        &mut self,
2747        lock_structure: bool,
2748        lock_windows: bool,
2749        password: Option<&str>,
2750    ) {
2751        #[cfg(feature = "sml-protection")]
2752        {
2753            let workbook_password = password.filter(|p| !p.is_empty()).map(ooxml_xor_hash);
2754
2755            self.workbook_protection = Some(types::WorkbookProtection {
2756                workbook_password,
2757                workbook_password_character_set: None,
2758                revisions_password: None,
2759                revisions_password_character_set: None,
2760                lock_structure: if lock_structure { Some(true) } else { None },
2761                lock_windows: if lock_windows { Some(true) } else { None },
2762                lock_revision: None,
2763                revisions_algorithm_name: None,
2764                revisions_hash_value: None,
2765                revisions_salt_value: None,
2766                revisions_spin_count: None,
2767                workbook_algorithm_name: None,
2768                workbook_hash_value: None,
2769                workbook_salt_value: None,
2770                workbook_spin_count: None,
2771                #[cfg(feature = "extra-attrs")]
2772                extra_attrs: Default::default(),
2773            });
2774        }
2775        #[cfg(not(feature = "sml-protection"))]
2776        let _ = (lock_structure, lock_windows, password);
2777    }
2778
2779    // -------------------------------------------------------------------------
2780    // Named cell styles
2781    // -------------------------------------------------------------------------
2782
2783    /// Add a named cell style to the workbook stylesheet (ECMA-376 §18.8.7).
2784    ///
2785    /// Requires the `sml-styling` feature.
2786    ///
2787    /// The `format_id` must be the index of a `<xf>` entry in `cellStyleXfs`.
2788    /// Use `0` for the default "Normal" format.  Returns the 0-based index of
2789    /// the new cell style in the `cellStyles` collection.
2790    ///
2791    /// # Example
2792    ///
2793    /// ```ignore
2794    /// // Add a "Good" style backed by format_id 0 (Normal format)
2795    /// wb.add_cell_style("Good", 0);
2796    /// ```
2797    pub fn add_cell_style(&mut self, name: &str, format_id: u32) -> u32 {
2798        let idx = self.extra_cell_styles.len() as u32;
2799        self.extra_cell_styles.push(NamedCellStyle {
2800            name: name.to_string(),
2801            format_id,
2802        });
2803        // +1 because the "Normal" style always occupies slot 0
2804        idx + 1
2805    }
2806
2807    /// Save the workbook to a file.
2808    pub fn save<P: AsRef<Path>>(self, path: P) -> Result<()> {
2809        let file = File::create(path)?;
2810        let writer = BufWriter::new(file);
2811        self.write(writer)
2812    }
2813
2814    /// Write the workbook to a writer.
2815    pub fn write<W: Write + Seek>(mut self, writer: W) -> Result<()> {
2816        // Collect all strings and styles first
2817        self.collect_shared_strings();
2818        self.collect_styles();
2819
2820        let has_styles = !self.cell_formats.is_empty() || !self.extra_cell_styles.is_empty();
2821
2822        let mut pkg = PackageWriter::new(writer);
2823
2824        // Add default content types
2825        pkg.add_default_content_type("rels", CT_RELATIONSHIPS);
2826        pkg.add_default_content_type("xml", CT_XML);
2827
2828        // Build root relationships
2829        let root_rels = format!(
2830            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2831<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
2832  <Relationship Id="rId1" Type="{}" Target="xl/workbook.xml"/>
2833</Relationships>"#,
2834            REL_OFFICE_DOCUMENT
2835        );
2836
2837        // Build workbook relationships
2838        let mut wb_rels = String::new();
2839        wb_rels.push_str(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#);
2840        wb_rels.push('\n');
2841        wb_rels.push_str(
2842            r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#,
2843        );
2844        wb_rels.push('\n');
2845
2846        let mut next_rel_id = 1;
2847        for (i, _sheet) in self.sheets.iter().enumerate() {
2848            wb_rels.push_str(&format!(
2849                r#"  <Relationship Id="rId{}" Type="{}" Target="worksheets/sheet{}.xml"/>"#,
2850                next_rel_id,
2851                REL_WORKSHEET,
2852                i + 1
2853            ));
2854            wb_rels.push('\n');
2855            next_rel_id += 1;
2856        }
2857
2858        // Add styles relationship if we have styles
2859        if has_styles {
2860            wb_rels.push_str(&format!(
2861                r#"  <Relationship Id="rId{}" Type="{}" Target="styles.xml"/>"#,
2862                next_rel_id, REL_STYLES
2863            ));
2864            wb_rels.push('\n');
2865            next_rel_id += 1;
2866        }
2867
2868        // Add shared strings relationship if we have strings
2869        if !self.shared_strings.is_empty() {
2870            wb_rels.push_str(&format!(
2871                r#"  <Relationship Id="rId{}" Type="{}" Target="sharedStrings.xml"/>"#,
2872                next_rel_id, REL_SHARED_STRINGS
2873            ));
2874            wb_rels.push('\n');
2875        }
2876
2877        wb_rels.push_str("</Relationships>");
2878
2879        // Build workbook.xml using generated types
2880        let workbook = self.build_workbook();
2881        let workbook_xml = serialize_workbook(&workbook)?;
2882
2883        // Write parts to package
2884        pkg.add_part("_rels/.rels", CT_RELATIONSHIPS, root_rels.as_bytes())?;
2885        pkg.add_part(
2886            "xl/_rels/workbook.xml.rels",
2887            CT_RELATIONSHIPS,
2888            wb_rels.as_bytes(),
2889        )?;
2890        pkg.add_part("xl/workbook.xml", CT_WORKBOOK, &workbook_xml)?;
2891
2892        // Write styles if any
2893        if has_styles {
2894            let styles_xml = self.serialize_styles()?;
2895            pkg.add_part("xl/styles.xml", CT_STYLES, &styles_xml)?;
2896        }
2897
2898        // Pre-compute global drawing and pivot numbering across all sheets.
2899        // Each sheet that has charts gets one drawing part; each pivot table
2900        // entry gets its own pivotCacheDefinition + pivotCacheRecords + pivotTable.
2901        #[cfg(feature = "sml-charts")]
2902        let mut next_drawing_num = 1usize;
2903        #[cfg(feature = "sml-charts")]
2904        let mut next_chart_num = 1usize;
2905        #[cfg(feature = "sml-pivot")]
2906        let mut next_pivot_num = 1usize;
2907
2908        // Write each sheet and its related parts (comments, hyperlinks, charts, pivot tables).
2909        for (i, sheet) in self.sheets.iter().enumerate() {
2910            let sheet_num = i + 1;
2911            let has_comments = !sheet.comments.is_empty();
2912
2913            // Collect external hyperlink URLs (need relationship entries).
2914            let ext_hyperlinks: Vec<&str> = sheet
2915                .hyperlinks
2916                .iter()
2917                .filter_map(|h| {
2918                    if let HyperlinkDest::External(url) = &h.dest {
2919                        Some(url.as_str())
2920                    } else {
2921                        None
2922                    }
2923                })
2924                .collect();
2925
2926            #[cfg(feature = "sml-charts")]
2927            let has_charts = !sheet.charts.is_empty();
2928            #[cfg(not(feature = "sml-charts"))]
2929            let has_charts = false;
2930
2931            #[cfg(feature = "sml-pivot")]
2932            let has_pivots = !sheet.pivot_tables.is_empty();
2933            #[cfg(not(feature = "sml-pivot"))]
2934            let has_pivots = false;
2935
2936            let needs_rels = has_comments || !ext_hyperlinks.is_empty() || has_charts || has_pivots;
2937
2938            // Relative IDs inside the sheet .rels file.
2939            let mut sheet_rel_id = 1usize;
2940            let comments_rel_id = if has_comments { sheet_rel_id } else { 0 };
2941            if has_comments {
2942                sheet_rel_id += 1;
2943            }
2944
2945            // Track drawing rel id (if any charts are present, one drawing part per sheet).
2946            #[cfg(feature = "sml-charts")]
2947            let drawing_rel_id = if has_charts {
2948                let id = sheet_rel_id;
2949                sheet_rel_id += 1;
2950                id
2951            } else {
2952                0
2953            };
2954
2955            // Assign pivot rel IDs for this sheet.
2956            #[cfg(feature = "sml-pivot")]
2957            let pivot_rel_id_start = sheet_rel_id;
2958
2959            // Compute hyperlink rel id start (after comments, drawing, pivot rels).
2960            #[cfg(feature = "sml-hyperlinks")]
2961            let hyperlink_rel_id_start = {
2962                let mut start = 1usize;
2963                if has_comments {
2964                    start += 1;
2965                }
2966                if has_charts {
2967                    start += 1;
2968                }
2969                #[cfg(feature = "sml-pivot")]
2970                {
2971                    start += sheet.pivot_tables.len();
2972                }
2973                start
2974            };
2975            #[cfg(not(feature = "sml-hyperlinks"))]
2976            let hyperlink_rel_id_start = 1usize;
2977
2978            if needs_rels {
2979                let mut sheet_rels = String::new();
2980                sheet_rels.push_str(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#);
2981                sheet_rels.push('\n');
2982                sheet_rels.push_str(
2983                    r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#,
2984                );
2985                sheet_rels.push('\n');
2986
2987                if has_comments {
2988                    sheet_rels.push_str(&format!(
2989                        r#"  <Relationship Id="rId{}" Type="{}" Target="../comments{}.xml"/>"#,
2990                        comments_rel_id, REL_COMMENTS, sheet_num
2991                    ));
2992                    sheet_rels.push('\n');
2993                }
2994
2995                #[cfg(feature = "sml-charts")]
2996                if has_charts {
2997                    sheet_rels.push_str(&format!(
2998                        r#"  <Relationship Id="rId{}" Type="{}" Target="../drawings/drawing{}.xml"/>"#,
2999                        drawing_rel_id, REL_DRAWING, next_drawing_num
3000                    ));
3001                    sheet_rels.push('\n');
3002                }
3003
3004                // Pivot table rels: one per pivot entry for this sheet.
3005                #[cfg(feature = "sml-pivot")]
3006                {
3007                    let mut prel = pivot_rel_id_start;
3008                    for (pi, _pt) in sheet.pivot_tables.iter().enumerate() {
3009                        let global_pivot = next_pivot_num + pi;
3010                        sheet_rels.push_str(&format!(
3011                            r#"  <Relationship Id="rId{}" Type="{}" Target="../pivotTables/pivotTable{}.xml"/>"#,
3012                            prel, REL_PIVOT_TABLE, global_pivot
3013                        ));
3014                        sheet_rels.push('\n');
3015                        prel += 1;
3016                    }
3017                }
3018
3019                for (hi, url) in ext_hyperlinks.iter().enumerate() {
3020                    sheet_rels.push_str(&format!(
3021                        r#"  <Relationship Id="rId{}" Type="{}" Target="{}" TargetMode="External"/>"#,
3022                        hyperlink_rel_id_start + hi,
3023                        REL_HYPERLINK,
3024                        escape_xml(url)
3025                    ));
3026                    sheet_rels.push('\n');
3027                }
3028
3029                sheet_rels.push_str("</Relationships>");
3030                let rels_part = format!("xl/worksheets/_rels/sheet{}.xml.rels", sheet_num);
3031                pkg.add_part(&rels_part, CT_RELATIONSHIPS, sheet_rels.as_bytes())?;
3032            }
3033
3034            // Write the worksheet XML, injecting the drawing reference if needed.
3035            let sheet_xml = self.serialize_sheet_with_drawing(
3036                sheet,
3037                hyperlink_rel_id_start,
3038                #[cfg(feature = "sml-charts")]
3039                if has_charts {
3040                    Some(drawing_rel_id)
3041                } else {
3042                    None
3043                },
3044                #[cfg(not(feature = "sml-charts"))]
3045                None,
3046            )?;
3047            let part_name = format!("xl/worksheets/sheet{}.xml", sheet_num);
3048            pkg.add_part(&part_name, CT_WORKSHEET, &sheet_xml)?;
3049
3050            if has_comments {
3051                let comments_xml = self.serialize_comments(sheet)?;
3052                let comments_part = format!("xl/comments{}.xml", sheet_num);
3053                pkg.add_part(&comments_part, CT_COMMENTS, &comments_xml)?;
3054            }
3055
3056            // Write chart parts and the drawing part for this sheet.
3057            #[cfg(feature = "sml-charts")]
3058            if has_charts {
3059                let drawing_num = next_drawing_num;
3060                next_drawing_num += 1;
3061
3062                // Drawing XML references each chart.
3063                let drawing_xml = build_drawing_xml(&sheet.charts, next_chart_num);
3064                let drawing_part = format!("xl/drawings/drawing{}.xml", drawing_num);
3065                pkg.add_part(&drawing_part, CT_DRAWING, drawing_xml.as_bytes())?;
3066
3067                // Drawing .rels: one entry per chart.
3068                let mut drawing_rels = String::new();
3069                drawing_rels.push_str(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#);
3070                drawing_rels.push('\n');
3071                drawing_rels.push_str(
3072                    r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#,
3073                );
3074                drawing_rels.push('\n');
3075                for (ci, _chart) in sheet.charts.iter().enumerate() {
3076                    let chart_num = next_chart_num + ci;
3077                    drawing_rels.push_str(&format!(
3078                        r#"  <Relationship Id="rId{}" Type="{}" Target="../charts/chart{}.xml"/>"#,
3079                        ci + 1,
3080                        REL_CHART,
3081                        chart_num,
3082                    ));
3083                    drawing_rels.push('\n');
3084                }
3085                drawing_rels.push_str("</Relationships>");
3086                let drawing_rels_part =
3087                    format!("xl/drawings/_rels/drawing{}.xml.rels", drawing_num);
3088                pkg.add_part(
3089                    &drawing_rels_part,
3090                    CT_RELATIONSHIPS,
3091                    drawing_rels.as_bytes(),
3092                )?;
3093
3094                // Write each chart XML file.
3095                for chart in &sheet.charts {
3096                    let chart_part = format!("xl/charts/chart{}.xml", next_chart_num);
3097                    pkg.add_part(&chart_part, CT_CHART, &chart.chart_xml)?;
3098                    next_chart_num += 1;
3099                }
3100            }
3101
3102            // Write pivot table parts for this sheet.
3103            #[cfg(feature = "sml-pivot")]
3104            {
3105                for (pi, pt) in sheet.pivot_tables.iter().enumerate() {
3106                    let pn = next_pivot_num + pi;
3107
3108                    // pivotCacheDefinition
3109                    let cache_def_xml = build_pivot_cache_definition_xml(&pt.opts, pn);
3110                    let cache_def_part = format!("xl/pivotCache/pivotCacheDefinition{}.xml", pn);
3111                    pkg.add_part(
3112                        &cache_def_part,
3113                        CT_PIVOT_CACHE_DEF,
3114                        cache_def_xml.as_bytes(),
3115                    )?;
3116
3117                    // pivotCacheDefinition .rels → pivotCacheRecords
3118                    let cache_def_rels = format!(
3119                        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3120<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
3121  <Relationship Id="rId1" Type="{}" Target="pivotCacheRecords{}.xml"/>
3122</Relationships>"#,
3123                        REL_PIVOT_CACHE_REC, pn
3124                    );
3125                    let cache_def_rels_part =
3126                        format!("xl/pivotCache/_rels/pivotCacheDefinition{}.xml.rels", pn);
3127                    pkg.add_part(
3128                        &cache_def_rels_part,
3129                        CT_RELATIONSHIPS,
3130                        cache_def_rels.as_bytes(),
3131                    )?;
3132
3133                    // pivotCacheRecords (empty)
3134                    let cache_rec_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3135<pivotCacheRecords xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="0"/>"#;
3136                    let cache_rec_part = format!("xl/pivotCache/pivotCacheRecords{}.xml", pn);
3137                    pkg.add_part(
3138                        &cache_rec_part,
3139                        CT_PIVOT_CACHE_REC,
3140                        cache_rec_xml.as_bytes(),
3141                    )?;
3142
3143                    // pivotTable
3144                    let pivot_xml = build_pivot_table_xml(&pt.opts, pn, &sheet.name);
3145                    let pivot_part = format!("xl/pivotTables/pivotTable{}.xml", pn);
3146                    pkg.add_part(&pivot_part, CT_PIVOT_TABLE, pivot_xml.as_bytes())?;
3147
3148                    // pivotTable .rels → pivotCacheDefinition
3149                    let pivot_rels = format!(
3150                        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3151<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
3152  <Relationship Id="rId1" Type="{}" Target="../pivotCache/pivotCacheDefinition{}.xml"/>
3153</Relationships>"#,
3154                        REL_PIVOT_CACHE_DEF, pn
3155                    );
3156                    let pivot_rels_part = format!("xl/pivotTables/_rels/pivotTable{}.xml.rels", pn);
3157                    pkg.add_part(&pivot_rels_part, CT_RELATIONSHIPS, pivot_rels.as_bytes())?;
3158                }
3159                // Advance global pivot counter past all pivots written for this sheet.
3160                next_pivot_num += sheet.pivot_tables.len();
3161            }
3162        }
3163
3164        // Write shared strings if any
3165        if !self.shared_strings.is_empty() {
3166            let ss_xml = self.serialize_shared_strings()?;
3167            pkg.add_part("xl/sharedStrings.xml", CT_SHARED_STRINGS, &ss_xml)?;
3168        }
3169
3170        pkg.finish()?;
3171        Ok(())
3172    }
3173
3174    /// Collect all strings from cells into shared string table.
3175    fn collect_shared_strings(&mut self) {
3176        for sheet in &self.sheets {
3177            for cell in sheet.cells.values() {
3178                if let WriteCellValue::String(s) = &cell.value
3179                    && !self.string_index.contains_key(s)
3180                {
3181                    let idx = self.shared_strings.len();
3182                    self.shared_strings.push(s.clone());
3183                    self.string_index.insert(s.clone(), idx);
3184                }
3185            }
3186        }
3187    }
3188
3189    /// Collect all styles from cells and build style indices.
3190    fn collect_styles(&mut self) {
3191        // Add default font (required by Excel)
3192        let default_font = FontStyle::new().with_name("Calibri").with_size(11.0);
3193        self.get_or_add_font(&default_font);
3194
3195        // Add required default fills (required by Excel: none and gray125)
3196        let none_fill = FillStyle::new();
3197        let gray_fill = FillStyle::new().with_pattern(FillPattern::Gray125);
3198        self.get_or_add_fill(&none_fill);
3199        self.get_or_add_fill(&gray_fill);
3200
3201        // Add default border (required by Excel)
3202        let default_border = BorderStyle::new();
3203        self.get_or_add_border(&default_border);
3204
3205        // First collect all styles into a Vec to avoid borrow issues
3206        let styles: Vec<CellStyle> = self
3207            .sheets
3208            .iter()
3209            .flat_map(|sheet| sheet.cells.values())
3210            .filter_map(|cell| cell.style.clone())
3211            .collect();
3212
3213        // Then add them to the style collections
3214        for style in &styles {
3215            self.get_or_add_cell_format(style);
3216        }
3217    }
3218
3219    /// Get or add a font, returning its index.
3220    fn get_or_add_font(&mut self, font: &FontStyle) -> usize {
3221        let key = FontStyleKey::from(font);
3222        if let Some(&idx) = self.font_index.get(&key) {
3223            return idx;
3224        }
3225        let idx = self.fonts.len();
3226        self.fonts.push(font.clone());
3227        self.font_index.insert(key, idx);
3228        idx
3229    }
3230
3231    /// Get or add a fill, returning its index.
3232    fn get_or_add_fill(&mut self, fill: &FillStyle) -> usize {
3233        let key = FillStyleKey::from(fill);
3234        if let Some(&idx) = self.fill_index.get(&key) {
3235            return idx;
3236        }
3237        let idx = self.fills.len();
3238        self.fills.push(fill.clone());
3239        self.fill_index.insert(key, idx);
3240        idx
3241    }
3242
3243    /// Get or add a border, returning its index.
3244    fn get_or_add_border(&mut self, border: &BorderStyle) -> usize {
3245        let key = BorderStyleKey::from(border);
3246        if let Some(&idx) = self.border_index.get(&key) {
3247            return idx;
3248        }
3249        let idx = self.borders.len();
3250        self.borders.push(border.clone());
3251        self.border_index.insert(key, idx);
3252        idx
3253    }
3254
3255    /// Get or add a number format, returning its ID.
3256    fn get_or_add_number_format(&mut self, format: &str) -> u32 {
3257        if let Some(&id) = self.number_format_index.get(format) {
3258            return id;
3259        }
3260        // Custom number formats start at 164
3261        let id = 164 + self.number_formats.len() as u32;
3262        self.number_formats.push(format.to_string());
3263        self.number_format_index.insert(format.to_string(), id);
3264        id
3265    }
3266
3267    /// Get or add a cell format, returning its index (xfId).
3268    fn get_or_add_cell_format(&mut self, style: &CellStyle) -> usize {
3269        let font_id = style.font.as_ref().map_or(0, |f| self.get_or_add_font(f));
3270        let fill_id = style.fill.as_ref().map_or(0, |f| self.get_or_add_fill(f));
3271        let border_id = style
3272            .border
3273            .as_ref()
3274            .map_or(0, |b| self.get_or_add_border(b));
3275        let num_fmt_id = style
3276            .number_format
3277            .as_ref()
3278            .map_or(0, |f| self.get_or_add_number_format(f));
3279
3280        let key = CellFormatKey {
3281            font_id,
3282            fill_id,
3283            border_id,
3284            num_fmt_id,
3285            horizontal: style
3286                .horizontal_alignment
3287                .map(|a| a.to_xml_value().to_string()),
3288            vertical: style
3289                .vertical_alignment
3290                .map(|a| a.to_xml_value().to_string()),
3291            wrap_text: style.wrap_text,
3292        };
3293
3294        if let Some(&idx) = self.cell_format_index.get(&key) {
3295            return idx;
3296        }
3297
3298        let record = CellFormatRecord {
3299            font_id,
3300            fill_id,
3301            border_id,
3302            num_fmt_id,
3303            horizontal: style.horizontal_alignment,
3304            vertical: style.vertical_alignment,
3305            wrap_text: style.wrap_text,
3306        };
3307
3308        let idx = self.cell_formats.len();
3309        self.cell_formats.push(record);
3310        self.cell_format_index.insert(key, idx);
3311        idx
3312    }
3313
3314    /// Get the style index for a cell (returns 0 if no style, or actual index + 1).
3315    fn get_cell_style_index(&self, style: &Option<CellStyle>) -> Option<usize> {
3316        style.as_ref().map(|s| {
3317            let font_id = s.font.as_ref().map_or(0, |f| {
3318                let key = FontStyleKey::from(f);
3319                *self.font_index.get(&key).unwrap_or(&0)
3320            });
3321            let fill_id = s.fill.as_ref().map_or(0, |f| {
3322                let key = FillStyleKey::from(f);
3323                *self.fill_index.get(&key).unwrap_or(&0)
3324            });
3325            let border_id = s.border.as_ref().map_or(0, |b| {
3326                let key = BorderStyleKey::from(b);
3327                *self.border_index.get(&key).unwrap_or(&0)
3328            });
3329            let num_fmt_id = s
3330                .number_format
3331                .as_ref()
3332                .map_or(0, |f| *self.number_format_index.get(f).unwrap_or(&0));
3333
3334            let key = CellFormatKey {
3335                font_id,
3336                fill_id,
3337                border_id,
3338                num_fmt_id,
3339                horizontal: s.horizontal_alignment.map(|a| a.to_xml_value().to_string()),
3340                vertical: s.vertical_alignment.map(|a| a.to_xml_value().to_string()),
3341                wrap_text: s.wrap_text,
3342            };
3343
3344            // Return index + 1 because index 0 is reserved for default style
3345            self.cell_format_index.get(&key).map_or(0, |&idx| idx + 1)
3346        })
3347    }
3348
3349    /// Serialize comments to XML.
3350    ///
3351    /// ECMA-376 Part 1, Section 18.7 (Comments).
3352    fn serialize_comments(&self, sheet: &SheetBuilder) -> Result<Vec<u8>> {
3353        // Collect unique authors and build author->index mapping
3354        let mut authors: Vec<String> = Vec::new();
3355        let mut author_index: HashMap<String, u32> = HashMap::new();
3356
3357        for comment in &sheet.comments {
3358            let author = comment.author.clone().unwrap_or_default();
3359            if !author_index.contains_key(&author) {
3360                author_index.insert(author.clone(), authors.len() as u32);
3361                authors.push(author);
3362            }
3363        }
3364
3365        // Build comment list
3366        let comment_list: Vec<types::Comment> = sheet
3367            .comments
3368            .iter()
3369            .map(|_c| {
3370                #[cfg(feature = "sml-comments")]
3371                let author_id = {
3372                    let author = _c.author.clone().unwrap_or_default();
3373                    *author_index.get(&author).unwrap_or(&0)
3374                };
3375
3376                // Build the rich-text body: prefer runs if any, else plain text.
3377                #[cfg(feature = "sml-comments")]
3378                let rich_text = if _c.runs.is_empty() {
3379                    // Plain-text comment: single unstyled run.
3380                    types::RichString {
3381                        cell_type: None,
3382                        reference: vec![types::RichTextElement {
3383                            r_pr: None,
3384                            cell_type: _c.text.clone(),
3385                            #[cfg(feature = "extra-children")]
3386                            extra_children: Vec::new(),
3387                        }],
3388                        r_ph: Vec::new(),
3389                        phonetic_pr: None,
3390                        #[cfg(feature = "extra-children")]
3391                        extra_children: Vec::new(),
3392                    }
3393                } else {
3394                    // Rich-text comment: one RichTextElement per run.
3395                    let runs: Vec<types::RichTextElement> = _c
3396                        .runs
3397                        .iter()
3398                        .map(|run| {
3399                            let has_props = run.bold
3400                                || run.italic
3401                                || run.color.is_some()
3402                                || run.font_size.is_some();
3403                            let r_pr = if has_props {
3404                                Some(Box::new(types::RichTextRunProperties {
3405                                    r_font: None,
3406                                    charset: None,
3407                                    family: None,
3408                                    b: if run.bold {
3409                                        Some(Box::new(types::BooleanProperty {
3410                                            value: None,
3411                                            #[cfg(feature = "extra-attrs")]
3412                                            extra_attrs: Default::default(),
3413                                        }))
3414                                    } else {
3415                                        None
3416                                    },
3417                                    i: if run.italic {
3418                                        Some(Box::new(types::BooleanProperty {
3419                                            value: None,
3420                                            #[cfg(feature = "extra-attrs")]
3421                                            extra_attrs: Default::default(),
3422                                        }))
3423                                    } else {
3424                                        None
3425                                    },
3426                                    strike: None,
3427                                    outline: None,
3428                                    shadow: None,
3429                                    condense: None,
3430                                    extend: None,
3431                                    #[cfg(feature = "sml-styling")]
3432                                    color: run.color.as_ref().map(|c| {
3433                                        Box::new(types::Color {
3434                                            auto: None,
3435                                            indexed: None,
3436                                            rgb: Some(hex_color_to_bytes(c)),
3437                                            theme: None,
3438                                            tint: None,
3439                                            #[cfg(feature = "extra-attrs")]
3440                                            extra_attrs: Default::default(),
3441                                        })
3442                                    }),
3443                                    #[cfg(not(feature = "sml-styling"))]
3444                                    color: None,
3445                                    sz: run.font_size.map(|s| {
3446                                        Box::new(types::FontSize {
3447                                            value: s,
3448                                            #[cfg(feature = "extra-attrs")]
3449                                            extra_attrs: Default::default(),
3450                                        })
3451                                    }),
3452                                    u: None,
3453                                    vert_align: None,
3454                                    scheme: None,
3455                                    #[cfg(feature = "extra-children")]
3456                                    extra_children: Vec::new(),
3457                                }))
3458                            } else {
3459                                None
3460                            };
3461                            types::RichTextElement {
3462                                r_pr,
3463                                cell_type: run.text.clone(),
3464                                #[cfg(feature = "extra-children")]
3465                                extra_children: Vec::new(),
3466                            }
3467                        })
3468                        .collect();
3469                    types::RichString {
3470                        cell_type: None,
3471                        reference: runs,
3472                        r_ph: Vec::new(),
3473                        phonetic_pr: None,
3474                        #[cfg(feature = "extra-children")]
3475                        extra_children: Vec::new(),
3476                    }
3477                };
3478
3479                types::Comment {
3480                    #[cfg(feature = "sml-comments")]
3481                    reference: _c.reference.clone(),
3482                    #[cfg(feature = "sml-comments")]
3483                    author_id,
3484                    #[cfg(feature = "sml-comments")]
3485                    guid: None,
3486                    #[cfg(feature = "sml-comments")]
3487                    shape_id: None,
3488                    #[cfg(feature = "sml-comments")]
3489                    text: Box::new(rich_text),
3490                    comment_pr: None,
3491                    #[cfg(feature = "extra-attrs")]
3492                    extra_attrs: Default::default(),
3493                    #[cfg(feature = "extra-children")]
3494                    extra_children: Vec::new(),
3495                }
3496            })
3497            .collect();
3498
3499        let comments = types::Comments {
3500            authors: Box::new(types::Authors {
3501                author: authors,
3502                #[cfg(feature = "extra-children")]
3503                extra_children: Vec::new(),
3504            }),
3505            comment_list: Box::new(types::CommentList {
3506                comment: comment_list,
3507                #[cfg(feature = "extra-children")]
3508                extra_children: Vec::new(),
3509            }),
3510            extension_list: None,
3511            #[cfg(feature = "extra-children")]
3512            extra_children: Vec::new(),
3513        };
3514
3515        serialize_with_namespaces(&comments, "comments")
3516    }
3517
3518    /// Serialize styles to XML using generated ToXml serializers.
3519    #[cfg(feature = "sml-styling")]
3520    fn serialize_styles(&self) -> Result<Vec<u8>> {
3521        let stylesheet = self.build_stylesheet();
3522        serialize_with_namespaces(&stylesheet, "styleSheet")
3523    }
3524
3525    /// Stub for serialize_styles when sml-styling is not enabled.
3526    #[cfg(not(feature = "sml-styling"))]
3527    fn serialize_styles(&self) -> Result<Vec<u8>> {
3528        // Return a minimal empty stylesheet
3529        let stylesheet = types::Stylesheet::default();
3530        serialize_with_namespaces(&stylesheet, "styleSheet")
3531    }
3532
3533    /// Build a Stylesheet type from builder data.
3534    #[cfg(feature = "sml-styling")]
3535    fn build_stylesheet(&self) -> types::Stylesheet {
3536        // Number formats (custom formats start at ID 164)
3537        let num_fmts: Option<Box<types::NumberFormats>> = if self.number_formats.is_empty() {
3538            None
3539        } else {
3540            Some(Box::new(types::NumberFormats {
3541                count: Some(self.number_formats.len() as u32),
3542                num_fmt: self
3543                    .number_formats
3544                    .iter()
3545                    .enumerate()
3546                    .map(|(i, fmt)| types::NumberFormat {
3547                        number_format_id: (164 + i) as u32,
3548                        format_code: fmt.clone(),
3549                        #[cfg(feature = "extra-attrs")]
3550                        extra_attrs: Default::default(),
3551                    })
3552                    .collect(),
3553                #[cfg(feature = "extra-attrs")]
3554                extra_attrs: Default::default(),
3555                #[cfg(feature = "extra-children")]
3556                extra_children: Vec::new(),
3557            }))
3558        };
3559
3560        // Fonts
3561        let fonts = Box::new(types::Fonts {
3562            count: Some(self.fonts.len() as u32),
3563            font: self.fonts.iter().map(build_font).collect(),
3564            #[cfg(feature = "extra-attrs")]
3565            extra_attrs: Default::default(),
3566            #[cfg(feature = "extra-children")]
3567            extra_children: Vec::new(),
3568        });
3569
3570        // Fills
3571        let fills = Box::new(types::Fills {
3572            count: Some(self.fills.len() as u32),
3573            fill: self.fills.iter().map(build_fill).collect(),
3574            #[cfg(feature = "extra-attrs")]
3575            extra_attrs: Default::default(),
3576            #[cfg(feature = "extra-children")]
3577            extra_children: Vec::new(),
3578        });
3579
3580        // Borders
3581        let borders = Box::new(types::Borders {
3582            count: Some(self.borders.len() as u32),
3583            border: self.borders.iter().map(build_border).collect(),
3584            #[cfg(feature = "extra-attrs")]
3585            extra_attrs: Default::default(),
3586            #[cfg(feature = "extra-children")]
3587            extra_children: Vec::new(),
3588        });
3589
3590        // Cell style XFs (required, at least one default)
3591        let cell_style_xfs = Box::new(types::CellStyleFormats {
3592            count: Some(1),
3593            xf: vec![types::Format {
3594                #[cfg(feature = "sml-styling")]
3595                number_format_id: Some(0),
3596                #[cfg(feature = "sml-styling")]
3597                font_id: Some(0),
3598                #[cfg(feature = "sml-styling")]
3599                fill_id: Some(0),
3600                #[cfg(feature = "sml-styling")]
3601                border_id: Some(0),
3602                #[cfg(feature = "sml-styling")]
3603                format_id: None,
3604                #[cfg(feature = "sml-styling")]
3605                quote_prefix: None,
3606                #[cfg(feature = "sml-pivot")]
3607                pivot_button: None,
3608                #[cfg(feature = "sml-styling")]
3609                apply_number_format: None,
3610                #[cfg(feature = "sml-styling")]
3611                apply_font: None,
3612                #[cfg(feature = "sml-styling")]
3613                apply_fill: None,
3614                #[cfg(feature = "sml-styling")]
3615                apply_border: None,
3616                #[cfg(feature = "sml-styling")]
3617                apply_alignment: None,
3618                #[cfg(feature = "sml-styling")]
3619                apply_protection: None,
3620                #[cfg(feature = "sml-styling")]
3621                alignment: None,
3622                #[cfg(feature = "sml-protection")]
3623                protection: None,
3624                #[cfg(feature = "sml-extensions")]
3625                extension_list: None,
3626                #[cfg(feature = "extra-attrs")]
3627                extra_attrs: Default::default(),
3628                #[cfg(feature = "extra-children")]
3629                extra_children: Vec::new(),
3630            }],
3631            #[cfg(feature = "extra-attrs")]
3632            extra_attrs: Default::default(),
3633            #[cfg(feature = "extra-children")]
3634            extra_children: Vec::new(),
3635        });
3636
3637        // Cell XFs - includes default format plus custom formats
3638        let mut xf_list: Vec<types::Format> = vec![types::Format {
3639            #[cfg(feature = "sml-styling")]
3640            number_format_id: Some(0),
3641            #[cfg(feature = "sml-styling")]
3642            font_id: Some(0),
3643            #[cfg(feature = "sml-styling")]
3644            fill_id: Some(0),
3645            #[cfg(feature = "sml-styling")]
3646            border_id: Some(0),
3647            #[cfg(feature = "sml-styling")]
3648            format_id: Some(0),
3649            #[cfg(feature = "sml-styling")]
3650            quote_prefix: None,
3651            #[cfg(feature = "sml-pivot")]
3652            pivot_button: None,
3653            #[cfg(feature = "sml-styling")]
3654            apply_number_format: None,
3655            #[cfg(feature = "sml-styling")]
3656            apply_font: None,
3657            #[cfg(feature = "sml-styling")]
3658            apply_fill: None,
3659            #[cfg(feature = "sml-styling")]
3660            apply_border: None,
3661            #[cfg(feature = "sml-styling")]
3662            apply_alignment: None,
3663            #[cfg(feature = "sml-styling")]
3664            apply_protection: None,
3665            #[cfg(feature = "sml-styling")]
3666            alignment: None,
3667            #[cfg(feature = "sml-protection")]
3668            protection: None,
3669            #[cfg(feature = "sml-extensions")]
3670            extension_list: None,
3671            #[cfg(feature = "extra-attrs")]
3672            extra_attrs: Default::default(),
3673            #[cfg(feature = "extra-children")]
3674            extra_children: Vec::new(),
3675        }];
3676
3677        for xf in &self.cell_formats {
3678            xf_list.push(build_cell_format(xf));
3679        }
3680
3681        let cell_xfs = Box::new(types::CellFormats {
3682            count: Some(xf_list.len() as u32),
3683            xf: xf_list,
3684            #[cfg(feature = "extra-attrs")]
3685            extra_attrs: Default::default(),
3686            #[cfg(feature = "extra-children")]
3687            extra_children: Vec::new(),
3688        });
3689
3690        // Cell styles (required — always includes "Normal" at index 0)
3691        let mut cell_style_list = vec![types::CellStyle {
3692            name: Some("Normal".to_string()),
3693            format_id: 0,
3694            builtin_id: Some(0),
3695            i_level: None,
3696            hidden: None,
3697            custom_builtin: None,
3698            extension_list: None,
3699            #[cfg(feature = "extra-attrs")]
3700            extra_attrs: Default::default(),
3701            #[cfg(feature = "extra-children")]
3702            extra_children: Vec::new(),
3703        }];
3704        for cs in &self.extra_cell_styles {
3705            cell_style_list.push(types::CellStyle {
3706                name: Some(cs.name.clone()),
3707                format_id: cs.format_id,
3708                builtin_id: None,
3709                i_level: None,
3710                hidden: None,
3711                custom_builtin: Some(true),
3712                extension_list: None,
3713                #[cfg(feature = "extra-attrs")]
3714                extra_attrs: Default::default(),
3715                #[cfg(feature = "extra-children")]
3716                extra_children: Vec::new(),
3717            });
3718        }
3719        let count = cell_style_list.len() as u32;
3720        let cell_styles = Box::new(types::CellStyles {
3721            count: Some(count),
3722            cell_style: cell_style_list,
3723            #[cfg(feature = "extra-attrs")]
3724            extra_attrs: Default::default(),
3725            #[cfg(feature = "extra-children")]
3726            extra_children: Vec::new(),
3727        });
3728
3729        types::Stylesheet {
3730            #[cfg(feature = "sml-styling")]
3731            num_fmts,
3732            #[cfg(feature = "sml-styling")]
3733            fonts: Some(fonts),
3734            #[cfg(feature = "sml-styling")]
3735            fills: Some(fills),
3736            #[cfg(feature = "sml-styling")]
3737            borders: Some(borders),
3738            #[cfg(feature = "sml-styling")]
3739            cell_style_xfs: Some(cell_style_xfs),
3740            #[cfg(feature = "sml-styling")]
3741            cell_xfs: Some(cell_xfs),
3742            #[cfg(feature = "sml-styling")]
3743            cell_styles: Some(cell_styles),
3744            #[cfg(feature = "sml-styling")]
3745            dxfs: None,
3746            #[cfg(feature = "sml-styling")]
3747            table_styles: None,
3748            #[cfg(feature = "sml-styling")]
3749            colors: None,
3750            #[cfg(feature = "sml-extensions")]
3751            extension_list: None,
3752            #[cfg(feature = "extra-children")]
3753            extra_children: Vec::new(),
3754        }
3755    }
3756
3757    /// Serialize a sheet to XML using generated types.
3758    ///
3759    /// `hyperlink_rel_id_start` is the first relationship ID available for
3760    /// external hyperlinks (1-based; accounts for comments/drawing/pivot
3761    /// occupying earlier rIds when present).
3762    ///
3763    /// `drawing_rel_id` is `Some(id)` when the sheet has embedded charts and a
3764    /// drawing part has been assigned to it.
3765    fn serialize_sheet_with_drawing(
3766        &self,
3767        sheet: &SheetBuilder,
3768        hyperlink_rel_id_start: usize,
3769        drawing_rel_id: Option<usize>,
3770    ) -> Result<Vec<u8>> {
3771        // Build row height lookup (already a HashMap now)
3772        #[cfg(feature = "sml-styling")]
3773        let row_heights = &sheet.row_heights;
3774
3775        // Group cells by row
3776        let mut rows_map: HashMap<u32, Vec<(u32, &BuilderCell)>> = HashMap::new();
3777        for ((row, col), cell) in &sheet.cells {
3778            rows_map.entry(*row).or_default().push((*col, cell));
3779        }
3780
3781        // Any rows that have outline/collapsed but no cells still need a Row element.
3782        #[cfg(feature = "sml-structure")]
3783        for &row_num in sheet
3784            .row_outline_levels
3785            .keys()
3786            .chain(sheet.row_collapsed.keys())
3787        {
3788            rows_map.entry(row_num).or_default();
3789        }
3790
3791        // Sort and build rows
3792        let mut row_nums: Vec<_> = rows_map.keys().copied().collect();
3793        row_nums.sort();
3794
3795        let rows: Vec<types::Row> = row_nums
3796            .iter()
3797            .map(|&row_num| {
3798                let cells_data = rows_map.get(&row_num).unwrap();
3799                let mut sorted_cells: Vec<_> = cells_data.clone();
3800                sorted_cells.sort_by_key(|(col, _)| *col);
3801
3802                let cells: Vec<types::Cell> = sorted_cells
3803                    .iter()
3804                    .map(|(col, cell)| {
3805                        let ref_str = column_to_letter(*col) + &row_num.to_string();
3806                        self.build_cell(&ref_str, cell)
3807                    })
3808                    .collect();
3809
3810                types::Row {
3811                    reference: Some(row_num),
3812                    cell_spans: None,
3813                    style_index: None,
3814                    #[cfg(feature = "sml-styling")]
3815                    custom_format: None,
3816                    #[cfg(feature = "sml-styling")]
3817                    height: row_heights.get(&row_num).copied(),
3818                    #[cfg(feature = "sml-structure")]
3819                    hidden: None,
3820                    #[cfg(feature = "sml-styling")]
3821                    custom_height: row_heights.get(&row_num).map(|_| true),
3822                    #[cfg(feature = "sml-structure")]
3823                    outline_level: sheet.row_outline_levels.get(&row_num).copied(),
3824                    #[cfg(feature = "sml-structure")]
3825                    collapsed: sheet.row_collapsed.get(&row_num).copied(),
3826                    #[cfg(feature = "sml-styling")]
3827                    thick_top: None,
3828                    #[cfg(feature = "sml-styling")]
3829                    thick_bot: None,
3830                    #[cfg(feature = "sml-i18n")]
3831                    placeholder: None,
3832                    cells,
3833                    #[cfg(feature = "sml-extensions")]
3834                    extension_list: None,
3835                    #[cfg(feature = "extra-attrs")]
3836                    extra_attrs: Default::default(),
3837                    #[cfg(feature = "extra-children")]
3838                    extra_children: Vec::new(),
3839                }
3840            })
3841            .collect();
3842
3843        // Clone pre-built worksheet and fill in the rows
3844        let mut worksheet = sheet.worksheet.clone();
3845        worksheet.sheet_data = Box::new(types::SheetData {
3846            row: rows,
3847            #[cfg(feature = "extra-children")]
3848            extra_children: Vec::new(),
3849        });
3850
3851        // Apply column outline levels and collapsed flags (if any).
3852        // We update existing Column entries in place; if a column has outline/collapsed
3853        // but no width entry (no Col element yet), we add one.
3854        #[cfg(all(feature = "sml-styling", feature = "sml-structure"))]
3855        if !sheet.col_outline_levels.is_empty() || !sheet.col_collapsed.is_empty() {
3856            // Collect all column numbers that need outline/collapsed attributes.
3857            let mut col_nums: std::collections::HashSet<u32> = std::collections::HashSet::new();
3858            col_nums.extend(sheet.col_outline_levels.keys().copied());
3859            col_nums.extend(sheet.col_collapsed.keys().copied());
3860
3861            for col_num in col_nums {
3862                let level = sheet.col_outline_levels.get(&col_num).copied();
3863                let collapsed = sheet.col_collapsed.get(&col_num).copied();
3864
3865                // Look for an existing Column entry that covers col_num.
3866                let mut found = false;
3867                for cols_group in &mut worksheet.cols {
3868                    for col_entry in &mut cols_group.col {
3869                        if col_entry.start_column <= col_num && col_num <= col_entry.end_column {
3870                            if level.is_some() {
3871                                col_entry.outline_level = level;
3872                            }
3873                            if collapsed.is_some() {
3874                                col_entry.collapsed = collapsed;
3875                            }
3876                            found = true;
3877                            break;
3878                        }
3879                    }
3880                    if found {
3881                        break;
3882                    }
3883                }
3884
3885                if !found {
3886                    // No existing column entry — create a minimal one just for
3887                    // the outline/collapsed attributes.
3888                    let col_entry = types::Column {
3889                        #[cfg(feature = "sml-styling")]
3890                        start_column: col_num,
3891                        #[cfg(feature = "sml-styling")]
3892                        end_column: col_num,
3893                        #[cfg(feature = "sml-styling")]
3894                        width: None,
3895                        #[cfg(feature = "sml-styling")]
3896                        style: None,
3897                        #[cfg(feature = "sml-structure")]
3898                        hidden: None,
3899                        #[cfg(feature = "sml-styling")]
3900                        best_fit: None,
3901                        #[cfg(feature = "sml-styling")]
3902                        custom_width: None,
3903                        #[cfg(feature = "sml-i18n")]
3904                        phonetic: None,
3905                        #[cfg(feature = "sml-structure")]
3906                        outline_level: level,
3907                        #[cfg(feature = "sml-structure")]
3908                        collapsed,
3909                        #[cfg(feature = "extra-attrs")]
3910                        extra_attrs: Default::default(),
3911                    };
3912                    if let Some(cols_group) = worksheet.cols.first_mut() {
3913                        cols_group.col.push(col_entry);
3914                    } else {
3915                        worksheet.cols.push(types::Columns {
3916                            col: vec![col_entry],
3917                            #[cfg(feature = "extra-children")]
3918                            extra_children: Vec::new(),
3919                        });
3920                    }
3921                }
3922            }
3923        }
3924
3925        // Apply show_gridlines / show_row_col_headers to the sheet view.
3926        #[cfg(feature = "sml-styling")]
3927        if sheet.show_gridlines.is_some() || sheet.show_row_col_headers.is_some() {
3928            if let Some(views) = worksheet.sheet_views.as_mut() {
3929                // Modify the first (default) sheet view if one already exists.
3930                if let Some(sv) = views.sheet_view.first_mut() {
3931                    if let Some(v) = sheet.show_gridlines {
3932                        sv.show_grid_lines = Some(v);
3933                    }
3934                    if let Some(v) = sheet.show_row_col_headers {
3935                        sv.show_row_col_headers = Some(v);
3936                    }
3937                }
3938            } else {
3939                // No sheet view yet — create a minimal one.
3940                let sv = types::SheetView {
3941                    #[cfg(feature = "sml-protection")]
3942                    window_protection: None,
3943                    #[cfg(feature = "sml-formulas")]
3944                    show_formulas: None,
3945                    #[cfg(feature = "sml-styling")]
3946                    show_grid_lines: sheet.show_gridlines,
3947                    #[cfg(feature = "sml-styling")]
3948                    show_row_col_headers: sheet.show_row_col_headers,
3949                    #[cfg(feature = "sml-styling")]
3950                    show_zeros: None,
3951                    #[cfg(feature = "sml-i18n")]
3952                    right_to_left: None,
3953                    tab_selected: None,
3954                    #[cfg(feature = "sml-layout")]
3955                    show_ruler: None,
3956                    #[cfg(feature = "sml-structure")]
3957                    show_outline_symbols: None,
3958                    #[cfg(feature = "sml-styling")]
3959                    default_grid_color: None,
3960                    #[cfg(feature = "sml-layout")]
3961                    show_white_space: None,
3962                    view: None,
3963                    top_left_cell: None,
3964                    #[cfg(feature = "sml-styling")]
3965                    color_id: None,
3966                    zoom_scale: None,
3967                    zoom_scale_normal: None,
3968                    #[cfg(feature = "sml-layout")]
3969                    zoom_scale_sheet_layout_view: None,
3970                    #[cfg(feature = "sml-layout")]
3971                    zoom_scale_page_layout_view: None,
3972                    workbook_view_id: 0,
3973                    #[cfg(feature = "sml-structure")]
3974                    pane: None,
3975                    selection: Vec::new(),
3976                    #[cfg(feature = "sml-pivot")]
3977                    pivot_selection: Vec::new(),
3978                    #[cfg(feature = "sml-extensions")]
3979                    extension_list: None,
3980                    #[cfg(feature = "extra-attrs")]
3981                    extra_attrs: Default::default(),
3982                    #[cfg(feature = "extra-children")]
3983                    extra_children: Vec::new(),
3984                };
3985                worksheet.sheet_views = Some(Box::new(types::SheetViews {
3986                    sheet_view: vec![sv],
3987                    extension_list: None,
3988                    #[cfg(feature = "extra-children")]
3989                    extra_children: Vec::new(),
3990                }));
3991            }
3992        }
3993
3994        // Inject hyperlinks (external ones carry their rId; internal ones use location).
3995        #[cfg(feature = "sml-hyperlinks")]
3996        if !sheet.hyperlinks.is_empty() {
3997            let mut rel_id = hyperlink_rel_id_start;
3998            let hyperlink_list: Vec<types::Hyperlink> = sheet
3999                .hyperlinks
4000                .iter()
4001                .map(|h| {
4002                    let (id, location) = match &h.dest {
4003                        HyperlinkDest::External(_) => {
4004                            let r = Some(format!("rId{}", rel_id));
4005                            rel_id += 1;
4006                            (r, None)
4007                        }
4008                        HyperlinkDest::Internal(loc) => (None, Some(loc.clone())),
4009                    };
4010                    types::Hyperlink {
4011                        reference: h.reference.clone(),
4012                        id,
4013                        #[cfg(feature = "sml-hyperlinks")]
4014                        location,
4015                        #[cfg(feature = "sml-hyperlinks")]
4016                        tooltip: h.tooltip.clone(),
4017                        #[cfg(feature = "sml-hyperlinks")]
4018                        display: h.display.clone(),
4019                        #[cfg(feature = "extra-attrs")]
4020                        extra_attrs: Default::default(),
4021                    }
4022                })
4023                .collect();
4024            worksheet.hyperlinks = Some(Box::new(types::Hyperlinks {
4025                hyperlink: hyperlink_list,
4026                #[cfg(feature = "extra-children")]
4027                extra_children: Vec::new(),
4028            }));
4029        }
4030        #[cfg(not(feature = "sml-hyperlinks"))]
4031        let _ = hyperlink_rel_id_start;
4032
4033        // Inject the drawing relationship reference when charts are present.
4034        // ECMA-376 §18.3.1.27: <drawing r:id="rId{n}"/> inside <worksheet>.
4035        #[cfg(feature = "sml-drawings")]
4036        if let Some(rel_id) = drawing_rel_id {
4037            worksheet.drawing = Some(Box::new(types::Drawing {
4038                id: format!("rId{}", rel_id),
4039                #[cfg(feature = "extra-attrs")]
4040                extra_attrs: Default::default(),
4041            }));
4042        }
4043        #[cfg(not(feature = "sml-drawings"))]
4044        let _ = drawing_rel_id;
4045
4046        serialize_with_namespaces(&worksheet, "worksheet")
4047    }
4048
4049    /// Build a Cell type from builder data.
4050    fn build_cell(&self, reference: &str, cell: &BuilderCell) -> types::Cell {
4051        let style_index = self
4052            .get_cell_style_index(&cell.style)
4053            .filter(|&s| s > 0)
4054            .map(|s| s as u32);
4055
4056        let (cell_type, value, formula) = match &cell.value {
4057            WriteCellValue::String(s) => {
4058                let idx = self.string_index.get(s).unwrap_or(&0);
4059                (
4060                    Some(types::CellType::SharedString),
4061                    Some(idx.to_string()),
4062                    None,
4063                )
4064            }
4065            WriteCellValue::Number(n) => (None, Some(n.to_string()), None),
4066            WriteCellValue::Boolean(b) => {
4067                let val = if *b { "1" } else { "0" };
4068                (Some(types::CellType::Boolean), Some(val.to_string()), None)
4069            }
4070            WriteCellValue::Formula(f) => (
4071                None,
4072                None,
4073                Some(Box::new(types::CellFormula {
4074                    text: Some(f.clone()),
4075                    cell_type: None,
4076                    #[cfg(feature = "sml-formulas-advanced")]
4077                    aca: None,
4078                    reference: None,
4079                    #[cfg(feature = "sml-formulas-advanced")]
4080                    dt2_d: None,
4081                    #[cfg(feature = "sml-formulas-advanced")]
4082                    dtr: None,
4083                    #[cfg(feature = "sml-formulas-advanced")]
4084                    del1: None,
4085                    #[cfg(feature = "sml-formulas-advanced")]
4086                    del2: None,
4087                    #[cfg(feature = "sml-formulas-advanced")]
4088                    r1: None,
4089                    #[cfg(feature = "sml-formulas-advanced")]
4090                    r2: None,
4091                    #[cfg(feature = "sml-formulas-advanced")]
4092                    ca: None,
4093                    si: None,
4094                    #[cfg(feature = "sml-formulas-advanced")]
4095                    bx: None,
4096                    #[cfg(feature = "extra-attrs")]
4097                    extra_attrs: Default::default(),
4098                    #[cfg(feature = "extra-children")]
4099                    extra_children: Vec::new(),
4100                })),
4101            ),
4102            WriteCellValue::Empty => (None, None, None),
4103        };
4104
4105        types::Cell {
4106            reference: Some(reference.to_string()),
4107            style_index,
4108            cell_type,
4109            #[cfg(feature = "sml-metadata")]
4110            cm: None,
4111            #[cfg(feature = "sml-metadata")]
4112            vm: None,
4113            #[cfg(feature = "sml-i18n")]
4114            placeholder: None,
4115            formula,
4116            value,
4117            is: None,
4118            #[cfg(feature = "sml-extensions")]
4119            extension_list: None,
4120            #[cfg(feature = "extra-attrs")]
4121            extra_attrs: Default::default(),
4122            #[cfg(feature = "extra-children")]
4123            extra_children: Vec::new(),
4124        }
4125    }
4126
4127    /// Build a Workbook type from builder data.
4128    fn build_workbook(&self) -> types::Workbook {
4129        // Build sheets
4130        let sheets: Vec<types::Sheet> = self
4131            .sheets
4132            .iter()
4133            .enumerate()
4134            .map(|(i, sheet)| types::Sheet {
4135                name: sheet.name.clone(),
4136                sheet_id: (i + 1) as u32,
4137                #[cfg(feature = "sml-structure")]
4138                state: None,
4139                id: format!("rId{}", i + 1),
4140                #[cfg(feature = "extra-attrs")]
4141                extra_attrs: Default::default(),
4142            })
4143            .collect();
4144
4145        // Build defined names if any
4146        let defined_names: Option<Box<types::DefinedNames>> = if self.defined_names.is_empty() {
4147            None
4148        } else {
4149            Some(Box::new(types::DefinedNames {
4150                defined_name: self
4151                    .defined_names
4152                    .iter()
4153                    .map(|dn| types::DefinedName {
4154                        text: Some(dn.reference.clone()),
4155                        name: dn.name.clone(),
4156                        comment: dn.comment.clone(),
4157                        #[cfg(feature = "sml-formulas-advanced")]
4158                        custom_menu: None,
4159                        description: None,
4160                        #[cfg(feature = "sml-formulas-advanced")]
4161                        help: None,
4162                        #[cfg(feature = "sml-formulas-advanced")]
4163                        status_bar: None,
4164                        local_sheet_id: dn.local_sheet_id,
4165                        #[cfg(feature = "sml-structure")]
4166                        hidden: if dn.hidden { Some(true) } else { None },
4167                        #[cfg(feature = "sml-formulas-advanced")]
4168                        function: None,
4169                        #[cfg(feature = "sml-formulas-advanced")]
4170                        vb_procedure: None,
4171                        #[cfg(feature = "sml-formulas-advanced")]
4172                        xlm: None,
4173                        #[cfg(feature = "sml-formulas-advanced")]
4174                        function_group_id: None,
4175                        #[cfg(feature = "sml-formulas-advanced")]
4176                        shortcut_key: None,
4177                        #[cfg(feature = "sml-formulas-advanced")]
4178                        publish_to_server: None,
4179                        #[cfg(feature = "sml-formulas-advanced")]
4180                        workbook_parameter: None,
4181                        #[cfg(feature = "extra-attrs")]
4182                        extra_attrs: Default::default(),
4183                        #[cfg(feature = "extra-children")]
4184                        extra_children: Vec::new(),
4185                    })
4186                    .collect(),
4187                #[cfg(feature = "extra-children")]
4188                extra_children: Vec::new(),
4189            }))
4190        };
4191
4192        types::Workbook {
4193            conformance: None,
4194            file_version: None,
4195            #[cfg(feature = "sml-protection")]
4196            file_sharing: None,
4197            workbook_pr: None,
4198            #[cfg(feature = "sml-protection")]
4199            workbook_protection: self.workbook_protection.clone().map(Box::new),
4200            book_views: None,
4201            sheets: Box::new(types::Sheets {
4202                sheet: sheets,
4203                #[cfg(feature = "extra-children")]
4204                extra_children: Vec::new(),
4205            }),
4206            #[cfg(feature = "sml-formulas-advanced")]
4207            function_groups: None,
4208            #[cfg(feature = "sml-external")]
4209            external_references: None,
4210            defined_names,
4211            #[cfg(feature = "sml-formulas")]
4212            calc_pr: None,
4213            #[cfg(feature = "sml-external")]
4214            ole_size: None,
4215            #[cfg(feature = "sml-structure")]
4216            custom_workbook_views: None,
4217            #[cfg(feature = "sml-pivot")]
4218            pivot_caches: None,
4219            #[cfg(feature = "sml-metadata")]
4220            smart_tag_pr: None,
4221            #[cfg(feature = "sml-metadata")]
4222            smart_tag_types: None,
4223            #[cfg(feature = "sml-external")]
4224            web_publishing: None,
4225            file_recovery_pr: Vec::new(),
4226            #[cfg(feature = "sml-external")]
4227            web_publish_objects: None,
4228            #[cfg(feature = "sml-extensions")]
4229            extension_list: None,
4230            #[cfg(feature = "extra-attrs")]
4231            extra_attrs: Default::default(),
4232            #[cfg(feature = "extra-children")]
4233            extra_children: Vec::new(),
4234        }
4235    }
4236
4237    /// Serialize shared strings table to XML using generated types.
4238    fn serialize_shared_strings(&self) -> Result<Vec<u8>> {
4239        let count = self.shared_strings.len() as u32;
4240        let sst = types::SharedStrings {
4241            count: Some(count),
4242            unique_count: Some(count),
4243            si: self
4244                .shared_strings
4245                .iter()
4246                .map(|s| types::RichString {
4247                    cell_type: Some(s.clone()),
4248                    reference: Vec::new(),
4249                    r_ph: Vec::new(),
4250                    phonetic_pr: None,
4251                    #[cfg(feature = "extra-children")]
4252                    extra_children: Vec::new(),
4253                })
4254                .collect(),
4255            extension_list: None,
4256            #[cfg(feature = "extra-attrs")]
4257            extra_attrs: Default::default(),
4258            #[cfg(feature = "extra-children")]
4259            extra_children: Vec::new(),
4260        };
4261        serialize_with_namespaces(&sst, "sst")
4262    }
4263}
4264
4265// ============================================================================
4266// Chart drawing XML builder (sml-charts)
4267// ============================================================================
4268
4269/// Build the `<xdr:wsDr>` drawing XML for a set of chart entries.
4270///
4271/// Each chart gets a `<xdr:twoCellAnchor>` referencing `rId{n}` where `n` is
4272/// the 1-based index into the drawing part's own relationship list.
4273///
4274/// ECMA-376 Part 1, §20.5 (SpreadsheetDrawingML).
4275#[cfg(feature = "sml-charts")]
4276fn build_drawing_xml(charts: &[ChartEntry], _first_chart_num: usize) -> String {
4277    const NS_XDR: &str = "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing";
4278    const NS_A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
4279    const NS_C: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart";
4280    const NS_R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
4281
4282    let mut xml = format!(
4283        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4284<xdr:wsDr xmlns:xdr="{NS_XDR}" xmlns:a="{NS_A}" xmlns:c="{NS_C}" xmlns:r="{NS_R}">"#
4285    );
4286
4287    for (idx, chart) in charts.iter().enumerate() {
4288        let rel_id = idx + 1; // 1-based within the drawing .rels
4289        let to_col = chart.x + chart.width;
4290        let to_row = chart.y + chart.height;
4291        xml.push_str(&format!(
4292            r#"
4293<xdr:twoCellAnchor>
4294  <xdr:from><xdr:col>{}</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>{}</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>
4295  <xdr:to><xdr:col>{}</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>{}</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>
4296  <xdr:graphicFrame macro="">
4297    <xdr:nvGraphicFramePr>
4298      <xdr:cNvPr id="{}" name="Chart {}"/>
4299      <xdr:cNvGraphicFramePr/>
4300    </xdr:nvGraphicFramePr>
4301    <xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>
4302    <a:graphic>
4303      <a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">
4304        <c:chart r:id="rId{}"/>
4305      </a:graphicData>
4306    </a:graphic>
4307  </xdr:graphicFrame>
4308  <xdr:clientData/>
4309</xdr:twoCellAnchor>"#,
4310            chart.x, chart.y, to_col, to_row,
4311            idx + 2, // nvPr id (must be >= 2 to avoid conflicts)
4312            idx + 1,
4313            rel_id,
4314        ));
4315    }
4316
4317    xml.push_str("\n</xdr:wsDr>");
4318    xml
4319}
4320
4321// ============================================================================
4322// Pivot table XML builders (sml-pivot)
4323// ============================================================================
4324
4325/// Build a minimal `<pivotCacheDefinition>` XML.
4326///
4327/// ECMA-376 Part 1, §18.10.1.
4328#[cfg(feature = "sml-pivot")]
4329fn build_pivot_cache_definition_xml(opts: &PivotTableOptions, _pn: usize) -> String {
4330    // Parse "SheetName!$A$1:$D$10" → sheet name + range.
4331    let (sheet_name, ref_range) = parse_source_ref(&opts.source_ref);
4332
4333    // All source fields: row_fields + col_fields + data_fields (deduplicated by order).
4334    let all_fields = collect_all_fields(opts);
4335    let field_count = all_fields.len();
4336
4337    let mut xml = format!(
4338        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4339<pivotCacheDefinition xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
4340  xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
4341  r:id="rId1" refreshedBy="ooxml-sml" refreshedDate="0" createdVersion="3"
4342  refreshedVersion="3" minRefreshableVersion="3" recordCount="0">
4343  <cacheSource type="worksheet">
4344    <worksheetSource ref="{ref_range}" sheet="{sheet_name}"/>
4345  </cacheSource>
4346  <cacheFields count="{field_count}">"#
4347    );
4348
4349    for name in &all_fields {
4350        xml.push_str(&format!(
4351            r#"
4352    <cacheField name="{}" numFmtId="0"><sharedItems/></cacheField>"#,
4353            escape_xml(name)
4354        ));
4355    }
4356
4357    xml.push_str(
4358        r#"
4359  </cacheFields>
4360</pivotCacheDefinition>"#,
4361    );
4362    xml
4363}
4364
4365/// Build a minimal `<pivotTableDefinition>` XML.
4366///
4367/// ECMA-376 Part 1, §18.10.2.
4368#[cfg(feature = "sml-pivot")]
4369fn build_pivot_table_xml(opts: &PivotTableOptions, cache_id: usize, _sheet_name: &str) -> String {
4370    let all_fields = collect_all_fields(opts);
4371    let total_fields = all_fields.len();
4372
4373    // Build field-name to index map.
4374    let field_index: HashMap<&str, usize> = all_fields
4375        .iter()
4376        .enumerate()
4377        .map(|(i, n)| (n.as_str(), i))
4378        .collect();
4379
4380    // Row fields: indices into the cache field list.
4381    let row_field_indices: Vec<usize> = opts
4382        .row_fields
4383        .iter()
4384        .filter_map(|n| field_index.get(n.as_str()).copied())
4385        .collect();
4386
4387    // Col fields: indices into cache field list.
4388    let col_field_indices: Vec<usize> = opts
4389        .col_fields
4390        .iter()
4391        .filter_map(|n| field_index.get(n.as_str()).copied())
4392        .collect();
4393
4394    // Data fields: indices into cache field list.
4395    let data_field_indices: Vec<usize> = opts
4396        .data_fields
4397        .iter()
4398        .filter_map(|n| field_index.get(n.as_str()).copied())
4399        .collect();
4400
4401    // Location: dest_ref gives top-left; estimate a bounding box.
4402    let dest = &opts.dest_ref;
4403    let header_rows = 1u32;
4404    let data_rows = opts.row_fields.len().max(1) as u32;
4405    let data_cols = opts.col_fields.len().max(1) as u32;
4406    // dest..dest+(data_rows+header_rows)x(data_cols+1) — rough estimate.
4407    let (dest_col, dest_row) = parse_cell_ref_for_pivot(dest);
4408    let end_col = dest_col + data_cols;
4409    let end_row = dest_row + header_rows + data_rows;
4410    let location_ref = format!("{}:{}", dest, format_cell_ref(end_col, end_row));
4411
4412    let mut xml = format!(
4413        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4414<pivotTableDefinition xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
4415  name="{name}" cacheId="{cache_id}" dataOnRows="0" dataPosition="0"
4416  dataCaption="Values" createdVersion="3" updatedVersion="3" minRefreshableVersion="3"
4417  useAutoFormatting="1" itemPrintTitles="1" indent="0" outline="0" outlineData="0">
4418  <location ref="{location_ref}" firstHeaderRow="1" firstDataRow="2" firstDataCol="1"/>
4419  <pivotFields count="{total_fields}">"#,
4420        name = escape_xml(&opts.name),
4421        cache_id = cache_id,
4422        location_ref = location_ref,
4423        total_fields = total_fields,
4424    );
4425
4426    // One <pivotField/> per source field.
4427    for _f in &all_fields {
4428        xml.push_str(
4429            r#"
4430    <pivotField showAll="0"/>"#,
4431        );
4432    }
4433
4434    xml.push_str(
4435        r#"
4436  </pivotFields>"#,
4437    );
4438
4439    // rowFields
4440    if !row_field_indices.is_empty() {
4441        xml.push_str(&format!(
4442            r#"
4443  <rowFields count="{}">"#,
4444            row_field_indices.len()
4445        ));
4446        for idx in &row_field_indices {
4447            xml.push_str(&format!(
4448                r#"
4449    <field x="{}"/>"#,
4450                idx
4451            ));
4452        }
4453        xml.push_str(
4454            r#"
4455  </rowFields>"#,
4456        );
4457    }
4458
4459    // colFields: use -2 (data axis placeholder) when there are data fields but no col fields,
4460    // so Excel shows the Values field header on the column axis.
4461    if !col_field_indices.is_empty() {
4462        xml.push_str(&format!(
4463            r#"
4464  <colFields count="{}">"#,
4465            col_field_indices.len()
4466        ));
4467        for idx in &col_field_indices {
4468            xml.push_str(&format!(
4469                r#"
4470    <field x="{}"/>"#,
4471                idx
4472            ));
4473        }
4474        xml.push_str(
4475            r#"
4476  </colFields>"#,
4477        );
4478    } else if !data_field_indices.is_empty() {
4479        // Single data field with no explicit column axis → put values on columns.
4480        xml.push_str(
4481            r#"
4482  <colFields count="1">
4483    <field x="-2"/>
4484  </colFields>"#,
4485        );
4486    }
4487
4488    // dataFields
4489    if !data_field_indices.is_empty() {
4490        xml.push_str(&format!(
4491            r#"
4492  <dataFields count="{}">"#,
4493            data_field_indices.len()
4494        ));
4495        for (di, &fld_idx) in data_field_indices.iter().enumerate() {
4496            let field_name = &opts.data_fields[di];
4497            xml.push_str(&format!(
4498                r#"
4499    <dataField name="Sum of {}" fld="{}" subtotal="sum"/>"#,
4500                escape_xml(field_name),
4501                fld_idx
4502            ));
4503        }
4504        xml.push_str(
4505            r#"
4506  </dataFields>"#,
4507        );
4508    }
4509
4510    xml.push_str(
4511        r#"
4512</pivotTableDefinition>"#,
4513    );
4514    xml
4515}
4516
4517/// Collect all unique field names from a `PivotTableOptions` in declaration order.
4518///
4519/// Row fields come first, then col fields, then data fields (new names only).
4520#[cfg(feature = "sml-pivot")]
4521fn collect_all_fields(opts: &PivotTableOptions) -> Vec<String> {
4522    let mut seen = std::collections::HashSet::new();
4523    let mut fields = Vec::new();
4524    for name in opts
4525        .row_fields
4526        .iter()
4527        .chain(opts.col_fields.iter())
4528        .chain(opts.data_fields.iter())
4529    {
4530        if seen.insert(name.as_str()) {
4531            fields.push(name.clone());
4532        }
4533    }
4534    fields
4535}
4536
4537/// Parse a source reference like `"Sheet1!$A$1:$D$10"` into `("Sheet1", "$A$1:$D$10")`.
4538/// Falls back to `("Sheet1", source_ref)` if no `!` is found.
4539#[cfg(feature = "sml-pivot")]
4540fn parse_source_ref(source_ref: &str) -> (&str, &str) {
4541    if let Some(bang) = source_ref.find('!') {
4542        (&source_ref[..bang], &source_ref[bang + 1..])
4543    } else {
4544        ("Sheet1", source_ref)
4545    }
4546}
4547
4548/// Parse a cell reference like `"A1"` or `"$F$3"` into (col, row) 1-based numbers.
4549#[cfg(feature = "sml-pivot")]
4550fn parse_cell_ref_for_pivot(cell_ref: &str) -> (u32, u32) {
4551    // Strip `$` signs then delegate to the existing parser.
4552    let clean: String = cell_ref.chars().filter(|c| *c != '$').collect();
4553    // Reuse parse_cell_reference which returns (row, col).
4554    if let Some((row, col)) = parse_cell_reference(&clean) {
4555        (col, row)
4556    } else {
4557        (1, 1) // fallback
4558    }
4559}
4560
4561/// Format 1-based (col, row) back to a cell reference like `"B3"`.
4562#[cfg(feature = "sml-pivot")]
4563fn format_cell_ref(col: u32, row: u32) -> String {
4564    format!("{}{}", column_to_letter(col), row)
4565}
4566
4567/// Build a single ConditionalFormatting item from a ConditionalFormat builder.
4568#[cfg(feature = "sml-styling")]
4569fn build_one_conditional_format(cf: &ConditionalFormat) -> types::ConditionalFormatting {
4570    types::ConditionalFormatting {
4571        #[cfg(feature = "sml-pivot")]
4572        pivot: None,
4573        square_reference: Some(cf.range.clone()),
4574        cf_rule: cf
4575            .rules
4576            .iter()
4577            .map(|rule| types::ConditionalRule {
4578                r#type: Some(map_conditional_rule_type(&rule.rule_type)),
4579                dxf_id: rule.dxf_id,
4580                priority: rule.priority as i32,
4581                stop_if_true: None,
4582                above_average: None,
4583                percent: None,
4584                bottom: None,
4585                operator: rule
4586                    .operator
4587                    .as_ref()
4588                    .and_then(|op| parse_conditional_operator(op)),
4589                text: rule.text.clone(),
4590                time_period: None,
4591                rank: None,
4592                std_dev: None,
4593                equal_average: None,
4594                formula: rule.formulas.clone(),
4595                #[cfg(feature = "sml-styling")]
4596                color_scale: None,
4597                #[cfg(feature = "sml-styling")]
4598                data_bar: None,
4599                #[cfg(feature = "sml-styling")]
4600                icon_set: None,
4601                #[cfg(feature = "sml-extensions")]
4602                extension_list: None,
4603                #[cfg(feature = "extra-attrs")]
4604                extra_attrs: Default::default(),
4605                #[cfg(feature = "extra-children")]
4606                extra_children: Vec::new(),
4607            })
4608            .collect(),
4609        #[cfg(feature = "sml-extensions")]
4610        extension_list: None,
4611        #[cfg(feature = "extra-attrs")]
4612        extra_attrs: Default::default(),
4613        #[cfg(feature = "extra-children")]
4614        extra_children: Vec::new(),
4615    }
4616}
4617
4618/// Map ConditionalRuleType to generated ConditionalType.
4619#[cfg(feature = "sml-styling")]
4620fn map_conditional_rule_type(rule_type: &crate::ConditionalRuleType) -> types::ConditionalType {
4621    match rule_type {
4622        crate::ConditionalRuleType::Expression => types::ConditionalType::Expression,
4623        crate::ConditionalRuleType::CellIs => types::ConditionalType::CellIs,
4624        crate::ConditionalRuleType::ColorScale => types::ConditionalType::ColorScale,
4625        crate::ConditionalRuleType::DataBar => types::ConditionalType::DataBar,
4626        crate::ConditionalRuleType::IconSet => types::ConditionalType::IconSet,
4627        crate::ConditionalRuleType::Top10 => types::ConditionalType::Top10,
4628        crate::ConditionalRuleType::UniqueValues => types::ConditionalType::UniqueValues,
4629        crate::ConditionalRuleType::DuplicateValues => types::ConditionalType::DuplicateValues,
4630        crate::ConditionalRuleType::ContainsText => types::ConditionalType::ContainsText,
4631        crate::ConditionalRuleType::NotContainsText => types::ConditionalType::NotContainsText,
4632        crate::ConditionalRuleType::BeginsWith => types::ConditionalType::BeginsWith,
4633        crate::ConditionalRuleType::EndsWith => types::ConditionalType::EndsWith,
4634        crate::ConditionalRuleType::ContainsBlanks => types::ConditionalType::ContainsBlanks,
4635        crate::ConditionalRuleType::NotContainsBlanks => types::ConditionalType::NotContainsBlanks,
4636        crate::ConditionalRuleType::ContainsErrors => types::ConditionalType::ContainsErrors,
4637        crate::ConditionalRuleType::NotContainsErrors => types::ConditionalType::NotContainsErrors,
4638        crate::ConditionalRuleType::TimePeriod => types::ConditionalType::TimePeriod,
4639        crate::ConditionalRuleType::AboveAverage => types::ConditionalType::AboveAverage,
4640    }
4641}
4642
4643/// Parse a conditional operator string to the generated type.
4644#[cfg(feature = "sml-styling")]
4645fn parse_conditional_operator(op: &str) -> Option<types::ConditionalOperator> {
4646    match op {
4647        "lessThan" => Some(types::ConditionalOperator::LessThan),
4648        "lessThanOrEqual" => Some(types::ConditionalOperator::LessThanOrEqual),
4649        "equal" => Some(types::ConditionalOperator::Equal),
4650        "notEqual" => Some(types::ConditionalOperator::NotEqual),
4651        "greaterThanOrEqual" => Some(types::ConditionalOperator::GreaterThanOrEqual),
4652        "greaterThan" => Some(types::ConditionalOperator::GreaterThan),
4653        "between" => Some(types::ConditionalOperator::Between),
4654        "notBetween" => Some(types::ConditionalOperator::NotBetween),
4655        "containsText" => Some(types::ConditionalOperator::ContainsText),
4656        "notContains" => Some(types::ConditionalOperator::NotContains),
4657        "beginsWith" => Some(types::ConditionalOperator::BeginsWith),
4658        "endsWith" => Some(types::ConditionalOperator::EndsWith),
4659        _ => None,
4660    }
4661}
4662
4663/// Build a single DataValidation item from a DataValidationBuilder.
4664#[cfg(feature = "sml-validation")]
4665fn build_one_data_validation(dv: &DataValidationBuilder) -> types::DataValidation {
4666    types::DataValidation {
4667        r#type: map_validation_type(&dv.validation_type),
4668        error_style: map_validation_error_style(&dv.error_style),
4669        ime_mode: None,
4670        operator: map_validation_operator(&dv.operator),
4671        allow_blank: if dv.allow_blank { Some(true) } else { None },
4672        show_drop_down: None,
4673        show_input_message: if dv.show_input_message {
4674            Some(true)
4675        } else {
4676            None
4677        },
4678        show_error_message: if dv.show_error_message {
4679            Some(true)
4680        } else {
4681            None
4682        },
4683        error_title: dv.error_title.clone(),
4684        error: dv.error_message.clone(),
4685        prompt_title: dv.prompt_title.clone(),
4686        prompt: dv.prompt_message.clone(),
4687        square_reference: dv.range.clone(),
4688        formula1: dv.formula1.clone(),
4689        formula2: dv.formula2.clone(),
4690        #[cfg(feature = "extra-attrs")]
4691        extra_attrs: Default::default(),
4692        #[cfg(feature = "extra-children")]
4693        extra_children: Vec::new(),
4694    }
4695}
4696
4697/// Map DataValidationType to generated ValidationType.
4698#[cfg(feature = "sml-validation")]
4699fn map_validation_type(vt: &crate::DataValidationType) -> Option<types::ValidationType> {
4700    match vt {
4701        crate::DataValidationType::None => None, // None type means no validation
4702        crate::DataValidationType::Whole => Some(types::ValidationType::Whole),
4703        crate::DataValidationType::Decimal => Some(types::ValidationType::Decimal),
4704        crate::DataValidationType::List => Some(types::ValidationType::List),
4705        crate::DataValidationType::Date => Some(types::ValidationType::Date),
4706        crate::DataValidationType::Time => Some(types::ValidationType::Time),
4707        crate::DataValidationType::TextLength => Some(types::ValidationType::TextLength),
4708        crate::DataValidationType::Custom => Some(types::ValidationType::Custom),
4709    }
4710}
4711
4712/// Map DataValidationOperator to generated ValidationOperator.
4713#[cfg(feature = "sml-validation")]
4714fn map_validation_operator(
4715    op: &crate::DataValidationOperator,
4716) -> Option<types::ValidationOperator> {
4717    match op {
4718        crate::DataValidationOperator::Between => Some(types::ValidationOperator::Between),
4719        crate::DataValidationOperator::NotBetween => Some(types::ValidationOperator::NotBetween),
4720        crate::DataValidationOperator::Equal => Some(types::ValidationOperator::Equal),
4721        crate::DataValidationOperator::NotEqual => Some(types::ValidationOperator::NotEqual),
4722        crate::DataValidationOperator::LessThan => Some(types::ValidationOperator::LessThan),
4723        crate::DataValidationOperator::LessThanOrEqual => {
4724            Some(types::ValidationOperator::LessThanOrEqual)
4725        }
4726        crate::DataValidationOperator::GreaterThan => Some(types::ValidationOperator::GreaterThan),
4727        crate::DataValidationOperator::GreaterThanOrEqual => {
4728            Some(types::ValidationOperator::GreaterThanOrEqual)
4729        }
4730    }
4731}
4732
4733/// Map DataValidationErrorStyle to generated ValidationErrorStyle.
4734#[cfg(feature = "sml-validation")]
4735fn map_validation_error_style(
4736    style: &crate::DataValidationErrorStyle,
4737) -> Option<types::ValidationErrorStyle> {
4738    match style {
4739        crate::DataValidationErrorStyle::Stop => None, // Stop is the default
4740        crate::DataValidationErrorStyle::Warning => Some(types::ValidationErrorStyle::Warning),
4741        crate::DataValidationErrorStyle::Information => {
4742            Some(types::ValidationErrorStyle::Information)
4743        }
4744    }
4745}
4746
4747/// Parse a cell reference like "A1" into (row, col).
4748fn parse_cell_reference(reference: &str) -> Option<(u32, u32)> {
4749    let mut col_part = String::new();
4750    let mut row_part = String::new();
4751
4752    for c in reference.chars() {
4753        if c.is_ascii_alphabetic() {
4754            col_part.push(c.to_ascii_uppercase());
4755        } else if c.is_ascii_digit() {
4756            row_part.push(c);
4757        }
4758    }
4759
4760    let col = column_letter_to_number(&col_part)?;
4761    let row: u32 = row_part.parse().ok()?;
4762
4763    Some((row, col))
4764}
4765
4766/// Escape XML special characters in attribute values (used in .rels files).
4767fn escape_xml(s: &str) -> String {
4768    s.replace('&', "&amp;")
4769        .replace('<', "&lt;")
4770        .replace('>', "&gt;")
4771        .replace('"', "&quot;")
4772}
4773
4774/// Convert column letters to number (A=1, B=2, ..., Z=26, AA=27).
4775fn column_letter_to_number(letters: &str) -> Option<u32> {
4776    if letters.is_empty() {
4777        return None;
4778    }
4779
4780    let mut result: u32 = 0;
4781    for c in letters.chars() {
4782        if !c.is_ascii_alphabetic() {
4783            return None;
4784        }
4785        result = result * 26 + (c.to_ascii_uppercase() as u32 - 'A' as u32 + 1);
4786    }
4787    Some(result)
4788}
4789
4790/// Convert column number to letters (1=A, 2=B, ..., 26=Z, 27=AA).
4791fn column_to_letter(mut col: u32) -> String {
4792    let mut result = String::new();
4793    while col > 0 {
4794        col -= 1;
4795        result.insert(0, (b'A' + (col % 26) as u8) as char);
4796        col /= 26;
4797    }
4798    result
4799}
4800
4801// =============================================================================
4802// ToXml serialization helpers
4803// =============================================================================
4804
4805/// Namespace declarations for SML root elements (worksheet, etc.).
4806const NS_DECLS: &[(&str, &str)] = &[("xmlns", NS_SPREADSHEET)];
4807
4808/// Namespace declarations for workbook element (includes relationship namespace for r:id).
4809const NS_WORKBOOK_DECLS: &[(&str, &str)] =
4810    &[("xmlns", NS_SPREADSHEET), ("xmlns:r", NS_RELATIONSHIPS)];
4811
4812/// Serialize a ToXml value with namespace declarations and XML declaration.
4813fn serialize_with_namespaces(value: &impl ToXml, tag: &str) -> Result<Vec<u8>> {
4814    serialize_with_ns_decls(value, tag, NS_DECLS)
4815}
4816
4817/// Serialize a workbook with both spreadsheet and relationship namespaces.
4818fn serialize_workbook(value: &impl ToXml) -> Result<Vec<u8>> {
4819    serialize_with_ns_decls(value, "workbook", NS_WORKBOOK_DECLS)
4820}
4821
4822/// Serialize a ToXml value with custom namespace declarations and XML declaration.
4823fn serialize_with_ns_decls(
4824    value: &impl ToXml,
4825    tag: &str,
4826    ns_decls: &[(&str, &str)],
4827) -> Result<Vec<u8>> {
4828    use quick_xml::Writer;
4829    use quick_xml::events::{BytesEnd, BytesStart, Event};
4830
4831    let inner = Vec::new();
4832    let mut writer = Writer::new(inner);
4833
4834    // Write start tag with namespace declarations + type's own attrs
4835    let start = BytesStart::new(tag);
4836    let start = value.write_attrs(start);
4837    let mut start = start;
4838    for &(key, val) in ns_decls {
4839        start.push_attribute((key, val));
4840    }
4841
4842    if value.is_empty_element() {
4843        writer.write_event(Event::Empty(start))?;
4844    } else {
4845        writer.write_event(Event::Start(start))?;
4846        value.write_children(&mut writer)?;
4847        writer.write_event(Event::End(BytesEnd::new(tag)))?;
4848    }
4849
4850    let inner = writer.into_inner();
4851    let mut buf = Vec::with_capacity(
4852        b"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n".len() + inner.len(),
4853    );
4854    buf.extend_from_slice(b"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
4855    buf.extend_from_slice(&inner);
4856    Ok(buf)
4857}
4858
4859/// Build a types::Font from FontStyle.
4860#[cfg(feature = "sml-styling")]
4861fn build_font(font: &FontStyle) -> types::Font {
4862    types::Font {
4863        #[cfg(feature = "sml-styling")]
4864        name: font.name.as_ref().map(|n| {
4865            Box::new(types::FontName {
4866                value: n.clone(),
4867                #[cfg(feature = "extra-attrs")]
4868                extra_attrs: Default::default(),
4869            })
4870        }),
4871        #[cfg(feature = "sml-styling")]
4872        charset: None,
4873        #[cfg(feature = "sml-styling")]
4874        family: None,
4875        #[cfg(feature = "sml-styling")]
4876        b: if font.bold {
4877            Some(Box::new(types::BooleanProperty {
4878                value: None, // Default means "true" when element is present
4879                #[cfg(feature = "extra-attrs")]
4880                extra_attrs: Default::default(),
4881            }))
4882        } else {
4883            None
4884        },
4885        #[cfg(feature = "sml-styling")]
4886        i: if font.italic {
4887            Some(Box::new(types::BooleanProperty {
4888                value: None,
4889                #[cfg(feature = "extra-attrs")]
4890                extra_attrs: Default::default(),
4891            }))
4892        } else {
4893            None
4894        },
4895        #[cfg(feature = "sml-styling")]
4896        strike: if font.strikethrough {
4897            Some(Box::new(types::BooleanProperty {
4898                value: None,
4899                #[cfg(feature = "extra-attrs")]
4900                extra_attrs: Default::default(),
4901            }))
4902        } else {
4903            None
4904        },
4905        #[cfg(feature = "sml-styling")]
4906        outline: None,
4907        #[cfg(feature = "sml-styling")]
4908        shadow: None,
4909        #[cfg(feature = "sml-styling")]
4910        condense: None,
4911        #[cfg(feature = "sml-styling")]
4912        extend: None,
4913        #[cfg(feature = "sml-styling")]
4914        color: font.color.as_ref().map(|c| {
4915            Box::new(types::Color {
4916                auto: None,
4917                indexed: None,
4918                rgb: Some(hex_color_to_bytes(c)),
4919                theme: None,
4920                tint: None,
4921                #[cfg(feature = "extra-attrs")]
4922                extra_attrs: Default::default(),
4923            })
4924        }),
4925        #[cfg(feature = "sml-styling")]
4926        sz: font.size.map(|s| {
4927            Box::new(types::FontSize {
4928                value: s,
4929                #[cfg(feature = "extra-attrs")]
4930                extra_attrs: Default::default(),
4931            })
4932        }),
4933        #[cfg(feature = "sml-styling")]
4934        u: font.underline.map(|u| {
4935            Box::new(types::UnderlineProperty {
4936                value: Some(convert_underline_style(u)),
4937                #[cfg(feature = "extra-attrs")]
4938                extra_attrs: Default::default(),
4939            })
4940        }),
4941        #[cfg(feature = "sml-styling")]
4942        vert_align: None,
4943        #[cfg(feature = "sml-styling")]
4944        scheme: None,
4945        #[cfg(feature = "extra-children")]
4946        extra_children: Vec::new(),
4947    }
4948}
4949
4950/// Build a types::Fill from FillStyle.
4951#[cfg(feature = "sml-styling")]
4952fn build_fill(fill: &FillStyle) -> types::Fill {
4953    types::Fill {
4954        #[cfg(feature = "sml-styling")]
4955        pattern_fill: Some(Box::new(types::PatternFill {
4956            pattern_type: Some(convert_pattern_type(fill.pattern)),
4957            fg_color: fill.fg_color.as_ref().map(|c| {
4958                Box::new(types::Color {
4959                    auto: None,
4960                    indexed: None,
4961                    rgb: Some(hex_color_to_bytes(c)),
4962                    theme: None,
4963                    tint: None,
4964                    #[cfg(feature = "extra-attrs")]
4965                    extra_attrs: Default::default(),
4966                })
4967            }),
4968            bg_color: fill.bg_color.as_ref().map(|c| {
4969                Box::new(types::Color {
4970                    auto: None,
4971                    indexed: None,
4972                    rgb: Some(hex_color_to_bytes(c)),
4973                    theme: None,
4974                    tint: None,
4975                    #[cfg(feature = "extra-attrs")]
4976                    extra_attrs: Default::default(),
4977                })
4978            }),
4979            #[cfg(feature = "extra-attrs")]
4980            extra_attrs: Default::default(),
4981            #[cfg(feature = "extra-children")]
4982            extra_children: Vec::new(),
4983        })),
4984        #[cfg(feature = "sml-styling")]
4985        gradient_fill: None,
4986        #[cfg(feature = "extra-children")]
4987        extra_children: Vec::new(),
4988    }
4989}
4990
4991/// Build a types::Border from BorderStyle.
4992#[cfg(feature = "sml-styling")]
4993fn build_border(border: &BorderStyle) -> types::Border {
4994    types::Border {
4995        #[cfg(feature = "sml-styling")]
4996        diagonal_up: if border.diagonal_up { Some(true) } else { None },
4997        #[cfg(feature = "sml-styling")]
4998        diagonal_down: if border.diagonal_down {
4999            Some(true)
5000        } else {
5001            None
5002        },
5003        #[cfg(feature = "sml-styling")]
5004        outline: None,
5005        start: None,
5006        end: None,
5007        #[cfg(feature = "sml-styling")]
5008        left: build_border_properties(&border.left),
5009        #[cfg(feature = "sml-styling")]
5010        right: build_border_properties(&border.right),
5011        #[cfg(feature = "sml-styling")]
5012        top: build_border_properties(&border.top),
5013        #[cfg(feature = "sml-styling")]
5014        bottom: build_border_properties(&border.bottom),
5015        #[cfg(feature = "sml-styling")]
5016        diagonal: build_border_properties(&border.diagonal),
5017        #[cfg(feature = "sml-styling")]
5018        vertical: None,
5019        #[cfg(feature = "sml-styling")]
5020        horizontal: None,
5021        #[cfg(feature = "extra-attrs")]
5022        extra_attrs: Default::default(),
5023        #[cfg(feature = "extra-children")]
5024        extra_children: Vec::new(),
5025    }
5026}
5027
5028/// Build a types::BorderProperties from BorderSideStyle.
5029#[cfg(feature = "sml-styling")]
5030fn build_border_properties(side: &Option<BorderSideStyle>) -> Option<Box<types::BorderProperties>> {
5031    // Always emit border properties for each side (empty if none)
5032    let (style, color) = if let Some(s) = side {
5033        if s.style != BorderLineStyle::None {
5034            (
5035                Some(convert_border_style(s.style)),
5036                s.color.as_ref().map(|c| {
5037                    Box::new(types::Color {
5038                        auto: None,
5039                        indexed: None,
5040                        rgb: Some(hex_color_to_bytes(c)),
5041                        theme: None,
5042                        tint: None,
5043                        #[cfg(feature = "extra-attrs")]
5044                        extra_attrs: Default::default(),
5045                    })
5046                }),
5047            )
5048        } else {
5049            (None, None)
5050        }
5051    } else {
5052        (None, None)
5053    };
5054
5055    Some(Box::new(types::BorderProperties {
5056        style,
5057        color,
5058        #[cfg(feature = "extra-attrs")]
5059        extra_attrs: Default::default(),
5060        #[cfg(feature = "extra-children")]
5061        extra_children: Vec::new(),
5062    }))
5063}
5064
5065/// Build a types::Format (xf) from CellFormatRecord.
5066#[cfg(feature = "sml-styling")]
5067fn build_cell_format(xf: &CellFormatRecord) -> types::Format {
5068    let has_alignment = xf.horizontal.is_some() || xf.vertical.is_some() || xf.wrap_text;
5069
5070    let alignment = if has_alignment {
5071        Some(Box::new(types::CellAlignment {
5072            horizontal: xf.horizontal.map(convert_horizontal_alignment),
5073            vertical: xf.vertical.map(convert_vertical_alignment),
5074            text_rotation: None,
5075            wrap_text: if xf.wrap_text { Some(true) } else { None },
5076            indent: None,
5077            relative_indent: None,
5078            justify_last_line: None,
5079            shrink_to_fit: None,
5080            reading_order: None,
5081            #[cfg(feature = "extra-attrs")]
5082            extra_attrs: Default::default(),
5083        }))
5084    } else {
5085        None
5086    };
5087
5088    types::Format {
5089        #[cfg(feature = "sml-styling")]
5090        number_format_id: Some(xf.num_fmt_id),
5091        #[cfg(feature = "sml-styling")]
5092        font_id: Some(xf.font_id as u32),
5093        #[cfg(feature = "sml-styling")]
5094        fill_id: Some(xf.fill_id as u32),
5095        #[cfg(feature = "sml-styling")]
5096        border_id: Some(xf.border_id as u32),
5097        #[cfg(feature = "sml-styling")]
5098        format_id: Some(0),
5099        #[cfg(feature = "sml-styling")]
5100        quote_prefix: None,
5101        #[cfg(feature = "sml-pivot")]
5102        pivot_button: None,
5103        #[cfg(feature = "sml-styling")]
5104        apply_number_format: if xf.num_fmt_id > 0 { Some(true) } else { None },
5105        #[cfg(feature = "sml-styling")]
5106        apply_font: if xf.font_id > 0 { Some(true) } else { None },
5107        #[cfg(feature = "sml-styling")]
5108        apply_fill: if xf.fill_id > 0 { Some(true) } else { None },
5109        #[cfg(feature = "sml-styling")]
5110        apply_border: if xf.border_id > 0 { Some(true) } else { None },
5111        #[cfg(feature = "sml-styling")]
5112        apply_alignment: if has_alignment { Some(true) } else { None },
5113        #[cfg(feature = "sml-styling")]
5114        apply_protection: None,
5115        #[cfg(feature = "sml-styling")]
5116        alignment,
5117        #[cfg(feature = "sml-protection")]
5118        protection: None,
5119        #[cfg(feature = "sml-extensions")]
5120        extension_list: None,
5121        #[cfg(feature = "extra-attrs")]
5122        extra_attrs: Default::default(),
5123        #[cfg(feature = "extra-children")]
5124        extra_children: Vec::new(),
5125    }
5126}
5127
5128/// Convert writer's UnderlineStyle to generated types::UnderlineStyle.
5129#[cfg(feature = "sml-styling")]
5130fn convert_underline_style(style: UnderlineStyle) -> types::UnderlineStyle {
5131    match style {
5132        UnderlineStyle::Single => types::UnderlineStyle::Single,
5133        UnderlineStyle::Double => types::UnderlineStyle::Double,
5134        UnderlineStyle::SingleAccounting => types::UnderlineStyle::SingleAccounting,
5135        UnderlineStyle::DoubleAccounting => types::UnderlineStyle::DoubleAccounting,
5136    }
5137}
5138
5139/// Convert writer's FillPattern to generated types::PatternType.
5140#[cfg(feature = "sml-styling")]
5141fn convert_pattern_type(pattern: FillPattern) -> types::PatternType {
5142    match pattern {
5143        FillPattern::None => types::PatternType::None,
5144        FillPattern::Solid => types::PatternType::Solid,
5145        FillPattern::MediumGray => types::PatternType::MediumGray,
5146        FillPattern::DarkGray => types::PatternType::DarkGray,
5147        FillPattern::LightGray => types::PatternType::LightGray,
5148        FillPattern::DarkHorizontal => types::PatternType::DarkHorizontal,
5149        FillPattern::DarkVertical => types::PatternType::DarkVertical,
5150        FillPattern::DarkDown => types::PatternType::DarkDown,
5151        FillPattern::DarkUp => types::PatternType::DarkUp,
5152        FillPattern::DarkGrid => types::PatternType::DarkGrid,
5153        FillPattern::DarkTrellis => types::PatternType::DarkTrellis,
5154        FillPattern::LightHorizontal => types::PatternType::LightHorizontal,
5155        FillPattern::LightVertical => types::PatternType::LightVertical,
5156        FillPattern::LightDown => types::PatternType::LightDown,
5157        FillPattern::LightUp => types::PatternType::LightUp,
5158        FillPattern::LightGrid => types::PatternType::LightGrid,
5159        FillPattern::LightTrellis => types::PatternType::LightTrellis,
5160        FillPattern::Gray125 => types::PatternType::Gray125,
5161        FillPattern::Gray0625 => types::PatternType::Gray0625,
5162    }
5163}
5164
5165/// Convert writer's BorderLineStyle to generated types::BorderStyle.
5166#[cfg(feature = "sml-styling")]
5167fn convert_border_style(style: BorderLineStyle) -> types::BorderStyle {
5168    match style {
5169        BorderLineStyle::None => types::BorderStyle::None,
5170        BorderLineStyle::Thin => types::BorderStyle::Thin,
5171        BorderLineStyle::Medium => types::BorderStyle::Medium,
5172        BorderLineStyle::Dashed => types::BorderStyle::Dashed,
5173        BorderLineStyle::Dotted => types::BorderStyle::Dotted,
5174        BorderLineStyle::Thick => types::BorderStyle::Thick,
5175        BorderLineStyle::Double => types::BorderStyle::Double,
5176        BorderLineStyle::Hair => types::BorderStyle::Hair,
5177        BorderLineStyle::MediumDashed => types::BorderStyle::MediumDashed,
5178        BorderLineStyle::DashDot => types::BorderStyle::DashDot,
5179        BorderLineStyle::MediumDashDot => types::BorderStyle::MediumDashDot,
5180        BorderLineStyle::DashDotDot => types::BorderStyle::DashDotDot,
5181        BorderLineStyle::MediumDashDotDot => types::BorderStyle::MediumDashDotDot,
5182        BorderLineStyle::SlantDashDot => types::BorderStyle::SlantDashDot,
5183    }
5184}
5185
5186/// Convert writer's HorizontalAlignment to generated types::HorizontalAlignment.
5187#[cfg(feature = "sml-styling")]
5188fn convert_horizontal_alignment(align: HorizontalAlignment) -> types::HorizontalAlignment {
5189    match align {
5190        HorizontalAlignment::General => types::HorizontalAlignment::General,
5191        HorizontalAlignment::Left => types::HorizontalAlignment::Left,
5192        HorizontalAlignment::Center => types::HorizontalAlignment::Center,
5193        HorizontalAlignment::Right => types::HorizontalAlignment::Right,
5194        HorizontalAlignment::Fill => types::HorizontalAlignment::Fill,
5195        HorizontalAlignment::Justify => types::HorizontalAlignment::Justify,
5196        HorizontalAlignment::CenterContinuous => types::HorizontalAlignment::CenterContinuous,
5197        HorizontalAlignment::Distributed => types::HorizontalAlignment::Distributed,
5198    }
5199}
5200
5201/// Convert writer's VerticalAlignment to generated types::VerticalAlignment.
5202#[cfg(feature = "sml-styling")]
5203fn convert_vertical_alignment(align: VerticalAlignment) -> types::VerticalAlignment {
5204    match align {
5205        VerticalAlignment::Top => types::VerticalAlignment::Top,
5206        VerticalAlignment::Center => types::VerticalAlignment::Center,
5207        VerticalAlignment::Bottom => types::VerticalAlignment::Bottom,
5208        VerticalAlignment::Justify => types::VerticalAlignment::Justify,
5209        VerticalAlignment::Distributed => types::VerticalAlignment::Distributed,
5210    }
5211}
5212
5213/// Convert a 6-character RGB hex color string (e.g., "FF0000") to ARGB bytes with 0xFF alpha.
5214#[cfg(feature = "sml-styling")]
5215fn hex_color_to_bytes(color: &str) -> Vec<u8> {
5216    // Parse as 24-bit RGB integer, then extract bytes
5217    let rgb = u32::from_str_radix(color, 16).unwrap_or(0);
5218    vec![0xFF, (rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8]
5219}
5220
5221#[cfg(test)]
5222mod tests {
5223    use super::*;
5224
5225    #[test]
5226    fn test_column_letter_to_number() {
5227        assert_eq!(column_letter_to_number("A"), Some(1));
5228        assert_eq!(column_letter_to_number("B"), Some(2));
5229        assert_eq!(column_letter_to_number("Z"), Some(26));
5230        assert_eq!(column_letter_to_number("AA"), Some(27));
5231        assert_eq!(column_letter_to_number("AB"), Some(28));
5232        assert_eq!(column_letter_to_number("AZ"), Some(52));
5233    }
5234
5235    #[test]
5236    fn test_column_to_letter() {
5237        assert_eq!(column_to_letter(1), "A");
5238        assert_eq!(column_to_letter(2), "B");
5239        assert_eq!(column_to_letter(26), "Z");
5240        assert_eq!(column_to_letter(27), "AA");
5241        assert_eq!(column_to_letter(28), "AB");
5242        assert_eq!(column_to_letter(52), "AZ");
5243    }
5244
5245    #[test]
5246    fn test_parse_cell_reference() {
5247        assert_eq!(parse_cell_reference("A1"), Some((1, 1)));
5248        assert_eq!(parse_cell_reference("B2"), Some((2, 2)));
5249        assert_eq!(parse_cell_reference("Z10"), Some((10, 26)));
5250        assert_eq!(parse_cell_reference("AA1"), Some((1, 27)));
5251    }
5252
5253    #[test]
5254    fn test_workbook_builder() {
5255        let mut wb = WorkbookBuilder::new();
5256        let sheet = wb.add_sheet("Test");
5257        sheet.set_cell("A1", "Hello");
5258        sheet.set_cell("B1", 42.0);
5259        sheet.set_cell("C1", true);
5260        sheet.set_formula("D1", "SUM(A1:C1)");
5261
5262        assert_eq!(wb.sheet_count(), 1);
5263    }
5264
5265    #[test]
5266    fn test_roundtrip_simple() {
5267        use std::io::Cursor;
5268
5269        let mut wb = WorkbookBuilder::new();
5270        let sheet = wb.add_sheet("Sheet1");
5271        sheet.set_cell("A1", "Test Value");
5272        sheet.set_cell("B1", 123.45);
5273
5274        // Write to memory
5275        let mut buffer = Cursor::new(Vec::new());
5276        wb.write(&mut buffer).unwrap();
5277
5278        // Read back
5279        buffer.set_position(0);
5280        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5281        let read_sheet = workbook.resolved_sheet(0).unwrap();
5282
5283        assert_eq!(read_sheet.name(), "Sheet1");
5284        assert_eq!(read_sheet.value_at("A1"), Some("Test Value".to_string()));
5285        assert_eq!(read_sheet.number_at("B1"), Some(123.45));
5286    }
5287
5288    #[test]
5289    fn test_roundtrip_merged_cells() {
5290        use std::io::Cursor;
5291
5292        let mut wb = WorkbookBuilder::new();
5293        let sheet = wb.add_sheet("Sheet1");
5294        sheet.set_cell("A1", "Merged Header");
5295        sheet.merge_cells("A1:C1");
5296        sheet.set_cell("A2", "Data 1");
5297        sheet.set_cell("B2", "Data 2");
5298        sheet.merge_cells("A3:B4");
5299        sheet.set_cell("A3", "Block");
5300
5301        // Write to memory
5302        let mut buffer = Cursor::new(Vec::new());
5303        wb.write(&mut buffer).unwrap();
5304
5305        // Read back
5306        buffer.set_position(0);
5307        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5308        let read_sheet = workbook.resolved_sheet(0).unwrap();
5309
5310        // Check merged cells were preserved
5311        let merged = read_sheet.merged_cells().expect("Should have merged cells");
5312        assert_eq!(merged.merge_cell.len(), 2);
5313        assert_eq!(merged.merge_cell[0].reference.as_str(), "A1:C1");
5314        assert_eq!(merged.merge_cell[1].reference.as_str(), "A3:B4");
5315
5316        // Cell values should still be accessible
5317        assert_eq!(read_sheet.value_at("A1"), Some("Merged Header".to_string()));
5318    }
5319
5320    #[test]
5321    #[cfg(feature = "full")]
5322    fn test_roundtrip_dimensions() {
5323        use std::io::Cursor;
5324
5325        let mut wb = WorkbookBuilder::new();
5326        let sheet = wb.add_sheet("Sheet1");
5327        sheet.set_cell("A1", "Header 1");
5328        sheet.set_cell("B1", "Header 2");
5329        sheet.set_cell("C1", "Header 3");
5330        sheet.set_cell("A2", "Data");
5331
5332        // Set column widths
5333        sheet.set_column_width("A", 20.0);
5334        sheet.set_column_width_range("B", "C", 15.5);
5335
5336        // Set row heights
5337        sheet.set_row_height(1, 25.0);
5338        sheet.set_row_height(2, 18.0);
5339
5340        // Write to memory
5341        let mut buffer = Cursor::new(Vec::new());
5342        wb.write(&mut buffer).unwrap();
5343
5344        // Read back
5345        buffer.set_position(0);
5346        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5347        let read_sheet = workbook.resolved_sheet(0).unwrap();
5348
5349        // Check column widths were preserved
5350        // Structure: Worksheet has Vec<Columns>, each Columns has Vec<Column>
5351        let columns = read_sheet.columns();
5352        assert!(!columns.is_empty());
5353
5354        // Collect all column definitions
5355        let all_cols: Vec<_> = columns.iter().flat_map(|c| &c.col).collect();
5356        assert_eq!(all_cols.len(), 2);
5357
5358        // Column A (col 1)
5359        assert_eq!(all_cols[0].start_column, 1);
5360        assert_eq!(all_cols[0].end_column, 1);
5361        assert_eq!(all_cols[0].width, Some(20.0));
5362
5363        // Columns B-C (cols 2-3)
5364        assert_eq!(all_cols[1].start_column, 2);
5365        assert_eq!(all_cols[1].end_column, 3);
5366        assert_eq!(all_cols[1].width, Some(15.5));
5367
5368        // Check row heights were preserved
5369        let row1 = read_sheet.row(1).unwrap();
5370        assert_eq!(row1.height, Some(25.0));
5371
5372        let row2 = read_sheet.row(2).unwrap();
5373        assert_eq!(row2.height, Some(18.0));
5374    }
5375
5376    #[test]
5377    #[cfg(feature = "full")]
5378    fn test_roundtrip_freeze_rows() {
5379        use std::io::Cursor;
5380
5381        let mut wb = WorkbookBuilder::new();
5382        let sheet = wb.add_sheet("Sheet1");
5383        sheet.set_cell("A1", "Header");
5384        sheet.freeze_rows(1);
5385
5386        let mut buffer = Cursor::new(Vec::new());
5387        wb.write(&mut buffer).unwrap();
5388
5389        buffer.set_position(0);
5390        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5391        let read_sheet = workbook.resolved_sheet(0).unwrap();
5392
5393        assert!(read_sheet.has_freeze_panes(), "Should have freeze panes");
5394        let pane = read_sheet.freeze_pane().expect("Should have pane");
5395        assert_eq!(pane.y_split, Some(1.0), "Should freeze 1 row");
5396        assert_eq!(pane.x_split, None, "Should not freeze any columns");
5397        assert_eq!(
5398            pane.state,
5399            Some(crate::types::PaneState::Frozen),
5400            "State should be Frozen"
5401        );
5402    }
5403
5404    #[test]
5405    #[cfg(feature = "full")]
5406    fn test_roundtrip_freeze_cols() {
5407        use std::io::Cursor;
5408
5409        let mut wb = WorkbookBuilder::new();
5410        let sheet = wb.add_sheet("Sheet1");
5411        sheet.set_cell("A1", "Row label");
5412        sheet.freeze_cols(1);
5413
5414        let mut buffer = Cursor::new(Vec::new());
5415        wb.write(&mut buffer).unwrap();
5416
5417        buffer.set_position(0);
5418        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5419        let read_sheet = workbook.resolved_sheet(0).unwrap();
5420
5421        assert!(read_sheet.has_freeze_panes());
5422        let pane = read_sheet.freeze_pane().expect("Should have pane");
5423        assert_eq!(pane.x_split, Some(1.0), "Should freeze 1 column");
5424        assert_eq!(pane.y_split, None, "Should not freeze any rows");
5425    }
5426
5427    #[test]
5428    #[cfg(feature = "full")]
5429    fn test_roundtrip_freeze_both() {
5430        use std::io::Cursor;
5431
5432        let mut wb = WorkbookBuilder::new();
5433        let sheet = wb.add_sheet("Sheet1");
5434        sheet.set_cell("A1", "Header");
5435        sheet.set_freeze_pane(2, 1);
5436
5437        let mut buffer = Cursor::new(Vec::new());
5438        wb.write(&mut buffer).unwrap();
5439
5440        buffer.set_position(0);
5441        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5442        let read_sheet = workbook.resolved_sheet(0).unwrap();
5443
5444        assert!(read_sheet.has_freeze_panes());
5445        let pane = read_sheet.freeze_pane().expect("Should have pane");
5446        assert_eq!(pane.y_split, Some(2.0), "Should freeze 2 rows");
5447        assert_eq!(pane.x_split, Some(1.0), "Should freeze 1 column");
5448        assert_eq!(pane.active_pane, Some(crate::types::PaneType::BottomRight));
5449    }
5450
5451    #[test]
5452    #[cfg(feature = "full")]
5453    fn test_roundtrip_conditional_formatting() {
5454        use std::io::Cursor;
5455
5456        let mut wb = WorkbookBuilder::new();
5457        let sheet = wb.add_sheet("Sheet1");
5458        sheet.set_cell("A1", 10.0);
5459        sheet.set_cell("A2", 20.0);
5460        sheet.set_cell("A3", 30.0);
5461
5462        // Add conditional formatting: highlight cells > 15
5463        let cf = ConditionalFormat::new("A1:A3")
5464            .add_cell_is_rule("greaterThan", "15", 1, None)
5465            .add_expression_rule("$A1>$A2", 2, None);
5466        sheet.add_conditional_format(cf);
5467
5468        // Add another rule for duplicates
5469        let cf2 = ConditionalFormat::new("B1:B10").add_duplicate_values_rule(1, None);
5470        sheet.add_conditional_format(cf2);
5471
5472        // Write to memory
5473        let mut buffer = Cursor::new(Vec::new());
5474        wb.write(&mut buffer).unwrap();
5475
5476        // Read back
5477        buffer.set_position(0);
5478        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5479        let read_sheet = workbook.resolved_sheet(0).unwrap();
5480
5481        // Check conditional formatting was preserved
5482        let cfs = read_sheet.conditional_formatting();
5483        assert_eq!(cfs.len(), 2);
5484
5485        // First conditional format has range A1:A3 and 2 rules
5486        assert_eq!(cfs[0].square_reference.as_deref(), Some("A1:A3"));
5487        assert_eq!(cfs[0].cf_rule.len(), 2);
5488
5489        // Second conditional format has range B1:B10 and 1 rule
5490        assert_eq!(cfs[1].square_reference.as_deref(), Some("B1:B10"));
5491        assert_eq!(cfs[1].cf_rule.len(), 1);
5492    }
5493
5494    #[test]
5495    #[cfg(feature = "full")]
5496    fn test_roundtrip_data_validation() {
5497        use std::io::Cursor;
5498
5499        let mut wb = WorkbookBuilder::new();
5500        let sheet = wb.add_sheet("Sheet1");
5501        sheet.set_cell("A1", 10.0);
5502
5503        // Add a list validation
5504        let dv = DataValidationBuilder::list("A1:A10", "\"Yes,No,Maybe\"")
5505            .with_error("Invalid Input", "Please select from the list")
5506            .with_prompt("Select", "Choose a value");
5507        sheet.add_data_validation(dv);
5508
5509        // Add a whole number validation
5510        let dv2 = DataValidationBuilder::whole_number(
5511            "B1:B10",
5512            crate::DataValidationOperator::GreaterThan,
5513            "0",
5514        )
5515        .with_error("Invalid Number", "Please enter a positive number");
5516        sheet.add_data_validation(dv2);
5517
5518        // Write to memory
5519        let mut buffer = Cursor::new(Vec::new());
5520        wb.write(&mut buffer).unwrap();
5521
5522        // Read back
5523        buffer.set_position(0);
5524        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5525        let read_sheet = workbook.resolved_sheet(0).unwrap();
5526
5527        // Check data validations were preserved
5528        let dvs = read_sheet
5529            .data_validations()
5530            .expect("Should have data validations");
5531        assert_eq!(dvs.data_validation.len(), 2);
5532
5533        // First validation: list for A1:A10
5534        let dv0 = &dvs.data_validation[0];
5535        assert_eq!(dv0.square_reference.as_str(), "A1:A10");
5536        assert_eq!(dv0.error_title.as_deref(), Some("Invalid Input"));
5537        assert_eq!(dv0.error.as_deref(), Some("Please select from the list"));
5538        assert_eq!(dv0.prompt_title.as_deref(), Some("Select"));
5539        assert_eq!(dv0.prompt.as_deref(), Some("Choose a value"));
5540
5541        // Second validation: whole number for B1:B10
5542        let dv1 = &dvs.data_validation[1];
5543        assert_eq!(dv1.square_reference.as_str(), "B1:B10");
5544        assert_eq!(dv1.error_title.as_deref(), Some("Invalid Number"));
5545    }
5546
5547    #[test]
5548    fn test_roundtrip_defined_names() {
5549        use std::io::Cursor;
5550
5551        let mut wb = WorkbookBuilder::new();
5552        wb.add_sheet("Sheet1");
5553        wb.add_sheet("Sheet2");
5554
5555        // Add a global defined name
5556        wb.add_defined_name("GlobalRange", "Sheet1!$A$1:$B$10");
5557
5558        // Add a sheet-scoped defined name
5559        wb.add_defined_name_with_scope("LocalRange", "Sheet1!$C$1:$D$5", 0);
5560
5561        // Add a defined name with comment using builder
5562        let dn = DefinedNameBuilder::new("DataRange", "Sheet2!$A$1:$Z$100")
5563            .with_comment("Main data table");
5564        wb.add_defined_name_builder(dn);
5565
5566        // Add print area
5567        wb.set_print_area(0, "Sheet1!$A$1:$G$20");
5568
5569        // Write to memory
5570        let mut buffer = Cursor::new(Vec::new());
5571        wb.write(&mut buffer).unwrap();
5572
5573        // Read back
5574        buffer.set_position(0);
5575        let workbook = crate::Workbook::from_reader(buffer).unwrap();
5576
5577        // Check defined names were preserved
5578        let names = workbook.defined_names();
5579        assert_eq!(names.len(), 4);
5580
5581        use crate::DefinedNameExt;
5582
5583        // Check global range
5584        let global = workbook.defined_name("GlobalRange").unwrap();
5585        assert_eq!(global.name, "GlobalRange");
5586        assert_eq!(global.text.as_deref(), Some("Sheet1!$A$1:$B$10"));
5587        assert!(global.local_sheet_id.is_none());
5588
5589        // Check sheet-scoped range
5590        let local = workbook.defined_name_in_sheet("LocalRange", 0).unwrap();
5591        assert_eq!(local.name, "LocalRange");
5592        assert_eq!(local.text.as_deref(), Some("Sheet1!$C$1:$D$5"));
5593        assert_eq!(local.local_sheet_id, Some(0));
5594
5595        // Check data range with comment
5596        let data = workbook.defined_name("DataRange").unwrap();
5597        assert_eq!(data.name, "DataRange");
5598        assert_eq!(data.text.as_deref(), Some("Sheet2!$A$1:$Z$100"));
5599        assert_eq!(data.comment.as_deref(), Some("Main data table"));
5600
5601        // Check print area (built-in name)
5602        let print_area = workbook
5603            .defined_name_in_sheet("_xlnm.Print_Area", 0)
5604            .unwrap();
5605        assert_eq!(print_area.text.as_deref(), Some("Sheet1!$A$1:$G$20"));
5606        assert!(print_area.is_builtin());
5607    }
5608
5609    #[cfg(feature = "sml-comments")]
5610    #[test]
5611    fn test_roundtrip_comments() {
5612        use std::io::Cursor;
5613
5614        let mut wb = WorkbookBuilder::new();
5615        let sheet = wb.add_sheet("Sheet1");
5616        sheet.set_cell("A1", "Hello");
5617        sheet.set_cell("B1", 42.0);
5618
5619        // Add comments
5620        sheet.add_comment("A1", "This is a simple comment");
5621        sheet.add_comment_with_author("B1", "Review this value", "John Doe");
5622
5623        // Add a comment using the builder
5624        let comment = CommentBuilder::new("C1", "Builder comment").author("Jane Smith");
5625        sheet.add_comment_builder(comment);
5626
5627        // Write to memory
5628        let mut buffer = Cursor::new(Vec::new());
5629        wb.write(&mut buffer).unwrap();
5630
5631        // Read back
5632        buffer.set_position(0);
5633        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5634        let read_sheet = workbook.resolved_sheet(0).unwrap();
5635
5636        // Check comments were preserved
5637        let comments = read_sheet.comments();
5638        assert_eq!(comments.len(), 3);
5639
5640        // First comment (ext::Comment has public fields)
5641        let c1 = read_sheet.comment("A1").unwrap();
5642        assert_eq!(c1.reference, "A1");
5643        assert_eq!(c1.text, "This is a simple comment");
5644        assert!(c1.author.is_none() || c1.author.as_ref().is_some_and(|a| a.is_empty()));
5645
5646        // Second comment
5647        let c2 = read_sheet.comment("B1").unwrap();
5648        assert_eq!(c2.reference, "B1");
5649        assert_eq!(c2.text, "Review this value");
5650        assert_eq!(c2.author.as_deref(), Some("John Doe"));
5651
5652        // Third comment
5653        let c3 = read_sheet.comment("C1").unwrap();
5654        assert_eq!(c3.reference, "C1");
5655        assert_eq!(c3.text, "Builder comment");
5656        assert_eq!(c3.author.as_deref(), Some("Jane Smith"));
5657
5658        // Check helper method
5659        assert!(read_sheet.has_comment("A1"));
5660        assert!(read_sheet.has_comment("B1"));
5661        assert!(!read_sheet.has_comment("D1"));
5662    }
5663
5664    #[cfg(feature = "sml-filtering")]
5665    #[test]
5666    fn test_roundtrip_auto_filter() {
5667        use std::io::Cursor;
5668
5669        let mut wb = WorkbookBuilder::new();
5670        let sheet = wb.add_sheet("Data");
5671        sheet.set_cell("A1", "Name");
5672        sheet.set_cell("B1", "Score");
5673        sheet.set_cell("A2", "Alice");
5674        sheet.set_cell("B2", 95.0);
5675        sheet.set_auto_filter("A1:B1");
5676
5677        let mut buffer = Cursor::new(Vec::new());
5678        wb.write(&mut buffer).unwrap();
5679
5680        buffer.set_position(0);
5681        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5682        let read_sheet = workbook.resolved_sheet(0).unwrap();
5683
5684        assert!(read_sheet.has_auto_filter());
5685        let af = read_sheet.auto_filter().unwrap();
5686        assert_eq!(af.reference.as_deref(), Some("A1:B1"));
5687    }
5688
5689    #[cfg(feature = "sml-hyperlinks")]
5690    #[test]
5691    fn test_roundtrip_hyperlinks() {
5692        use std::io::Cursor;
5693
5694        let mut wb = WorkbookBuilder::new();
5695        let sheet = wb.add_sheet("Links");
5696        sheet.set_cell("A1", "External");
5697        sheet.add_hyperlink("A1", "https://example.com");
5698        sheet.set_cell("B1", "Internal");
5699        sheet.add_internal_hyperlink("B1", "Sheet2!A1");
5700
5701        let mut buffer = Cursor::new(Vec::new());
5702        wb.write(&mut buffer).unwrap();
5703
5704        buffer.set_position(0);
5705        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5706        let read_sheet = workbook.resolved_sheet(0).unwrap();
5707
5708        // Check the raw hyperlinks from the worksheet
5709        let ws = read_sheet.worksheet();
5710        let hyperlinks = ws.hyperlinks.as_deref().unwrap();
5711        assert_eq!(hyperlinks.hyperlink.len(), 2);
5712
5713        let ext = hyperlinks
5714            .hyperlink
5715            .iter()
5716            .find(|h| h.reference == "A1")
5717            .unwrap();
5718        // External hyperlink has a relationship ID, no inline location
5719        assert!(ext.id.is_some());
5720        assert!(ext.location.as_deref().unwrap_or("").is_empty());
5721
5722        let int = hyperlinks
5723            .hyperlink
5724            .iter()
5725            .find(|h| h.reference == "B1")
5726            .unwrap();
5727        // Internal hyperlink has no relationship ID, just a location
5728        assert!(int.id.is_none());
5729        assert_eq!(int.location.as_deref(), Some("Sheet2!A1"));
5730    }
5731
5732    #[cfg(all(feature = "sml-hyperlinks", feature = "sml-comments"))]
5733    #[test]
5734    fn test_roundtrip_hyperlinks_with_comments() {
5735        use std::io::Cursor;
5736
5737        // Verifies that rel IDs are assigned correctly when a sheet has both
5738        // comments (rId1) and external hyperlinks (rId2, rId3...).
5739        let mut wb = WorkbookBuilder::new();
5740        let sheet = wb.add_sheet("Sheet1");
5741        sheet.set_cell("A1", "Click me");
5742        sheet.add_hyperlink("A1", "https://example.com");
5743        sheet.add_comment("A1", "Visit the site");
5744
5745        let mut buffer = Cursor::new(Vec::new());
5746        wb.write(&mut buffer).unwrap();
5747
5748        buffer.set_position(0);
5749        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5750        let read_sheet = workbook.resolved_sheet(0).unwrap();
5751
5752        // Comment should be preserved
5753        let c = read_sheet.comment("A1").unwrap();
5754        assert_eq!(c.text, "Visit the site");
5755
5756        // Hyperlink should be preserved
5757        let ws = read_sheet.worksheet();
5758        let hyperlinks = ws.hyperlinks.as_deref().unwrap();
5759        assert_eq!(hyperlinks.hyperlink.len(), 1);
5760        assert_eq!(hyperlinks.hyperlink[0].reference, "A1");
5761        assert!(hyperlinks.hyperlink[0].id.is_some());
5762    }
5763
5764    // =========================================================================
5765    // Page setup / margins
5766    // =========================================================================
5767
5768    #[cfg(feature = "sml-layout")]
5769    #[test]
5770    fn test_roundtrip_page_setup() {
5771        use std::io::Cursor;
5772
5773        let mut wb = WorkbookBuilder::new();
5774        let sheet = wb.add_sheet("Sheet1");
5775        sheet.set_cell("A1", "Print me");
5776        sheet.set_page_setup(
5777            PageSetupOptions::new()
5778                .with_orientation(PageOrientation::Landscape)
5779                .with_paper_size(9) // A4
5780                .with_scale(80),
5781        );
5782
5783        let mut buffer = Cursor::new(Vec::new());
5784        wb.write(&mut buffer).unwrap();
5785
5786        buffer.set_position(0);
5787        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5788        let read_sheet = workbook.resolved_sheet(0).unwrap();
5789        let ws = read_sheet.worksheet();
5790
5791        let ps = ws.page_setup.as_deref().expect("page_setup should be set");
5792        assert_eq!(ps.paper_size, Some(9));
5793        assert_eq!(ps.scale, Some(80));
5794        assert_eq!(ps.orientation, Some(crate::types::STOrientation::Landscape));
5795    }
5796
5797    #[cfg(feature = "sml-layout")]
5798    #[test]
5799    fn test_roundtrip_page_margins() {
5800        use std::io::Cursor;
5801
5802        let mut wb = WorkbookBuilder::new();
5803        let sheet = wb.add_sheet("Sheet1");
5804        sheet.set_cell("A1", "Data");
5805        sheet.set_page_margins(0.7, 0.7, 0.75, 0.75, 0.3, 0.3);
5806
5807        let mut buffer = Cursor::new(Vec::new());
5808        wb.write(&mut buffer).unwrap();
5809
5810        buffer.set_position(0);
5811        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5812        let read_sheet = workbook.resolved_sheet(0).unwrap();
5813        let ws = read_sheet.worksheet();
5814
5815        let pm = ws
5816            .page_margins
5817            .as_deref()
5818            .expect("page_margins should be set");
5819        assert!((pm.left - 0.7).abs() < f64::EPSILON);
5820        assert!((pm.right - 0.7).abs() < f64::EPSILON);
5821        assert!((pm.top - 0.75).abs() < f64::EPSILON);
5822        assert!((pm.bottom - 0.75).abs() < f64::EPSILON);
5823        assert!((pm.header - 0.3).abs() < f64::EPSILON);
5824        assert!((pm.footer - 0.3).abs() < f64::EPSILON);
5825    }
5826
5827    // =========================================================================
5828    // Row / column outline grouping
5829    // =========================================================================
5830
5831    #[cfg(all(feature = "sml-structure", feature = "sml-styling"))]
5832    #[test]
5833    fn test_roundtrip_row_outline() {
5834        use std::io::Cursor;
5835
5836        let mut wb = WorkbookBuilder::new();
5837        let sheet = wb.add_sheet("Sheet1");
5838        sheet.set_cell("A1", "Header");
5839        sheet.set_cell("A2", "Detail 1");
5840        sheet.set_cell("A3", "Detail 2");
5841        // Group rows 2–3 at outline level 1
5842        sheet.set_row_outline_level(2, 1);
5843        sheet.set_row_outline_level(3, 1);
5844        // Mark the group as collapsed
5845        sheet.set_row_collapsed(2, true);
5846
5847        let mut buffer = Cursor::new(Vec::new());
5848        wb.write(&mut buffer).unwrap();
5849
5850        buffer.set_position(0);
5851        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5852        let read_sheet = workbook.resolved_sheet(0).unwrap();
5853
5854        let row2 = read_sheet.row(2).expect("row 2 should exist");
5855        assert_eq!(row2.outline_level, Some(1), "row 2 outline level");
5856        assert_eq!(row2.collapsed, Some(true), "row 2 collapsed");
5857
5858        let row3 = read_sheet.row(3).expect("row 3 should exist");
5859        assert_eq!(row3.outline_level, Some(1), "row 3 outline level");
5860        assert_eq!(row3.collapsed, None, "row 3 not collapsed");
5861    }
5862
5863    #[cfg(all(feature = "sml-structure", feature = "sml-styling"))]
5864    #[test]
5865    fn test_roundtrip_col_outline() {
5866        use std::io::Cursor;
5867
5868        let mut wb = WorkbookBuilder::new();
5869        let sheet = wb.add_sheet("Sheet1");
5870        sheet.set_cell("A1", "Label");
5871        sheet.set_cell("B1", "Detail");
5872        sheet.set_column_outline_level("B", 1);
5873        sheet.set_column_collapsed("B", true);
5874
5875        let mut buffer = Cursor::new(Vec::new());
5876        wb.write(&mut buffer).unwrap();
5877
5878        buffer.set_position(0);
5879        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5880        let read_sheet = workbook.resolved_sheet(0).unwrap();
5881
5882        let ws = read_sheet.worksheet();
5883        let col_b = ws
5884            .cols
5885            .iter()
5886            .flat_map(|c| &c.col)
5887            .find(|c| c.start_column <= 2 && 2 <= c.end_column)
5888            .expect("column B should have a definition");
5889        assert_eq!(col_b.outline_level, Some(1), "col B outline level");
5890        assert_eq!(col_b.collapsed, Some(true), "col B collapsed");
5891    }
5892
5893    // =========================================================================
5894    // Ignored errors
5895    // =========================================================================
5896
5897    #[cfg(feature = "sml-validation")]
5898    #[test]
5899    fn test_roundtrip_ignored_errors() {
5900        use std::io::Cursor;
5901
5902        let mut wb = WorkbookBuilder::new();
5903        let sheet = wb.add_sheet("Sheet1");
5904        sheet.set_cell("A1", "123"); // text that looks like a number
5905        sheet.add_ignored_error("A1:A10", IgnoredErrorType::NumberStoredAsText);
5906        sheet.add_ignored_error("B1:B5", IgnoredErrorType::TwoDigitTextYear);
5907
5908        let mut buffer = Cursor::new(Vec::new());
5909        wb.write(&mut buffer).unwrap();
5910
5911        buffer.set_position(0);
5912        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5913        let read_sheet = workbook.resolved_sheet(0).unwrap();
5914        let ws = read_sheet.worksheet();
5915
5916        let ie = ws
5917            .ignored_errors
5918            .as_deref()
5919            .expect("ignored_errors should be set");
5920        assert_eq!(ie.ignored_error.len(), 2);
5921
5922        let first = &ie.ignored_error[0];
5923        assert_eq!(first.square_reference.as_str(), "A1:A10");
5924        assert_eq!(first.number_stored_as_text, Some(true));
5925
5926        let second = &ie.ignored_error[1];
5927        assert_eq!(second.square_reference.as_str(), "B1:B5");
5928        assert_eq!(second.two_digit_text_year, Some(true));
5929    }
5930
5931    // =========================================================================
5932    // Tab color
5933    // =========================================================================
5934
5935    #[cfg(feature = "sml-styling")]
5936    #[test]
5937    fn test_roundtrip_tab_color() {
5938        use std::io::Cursor;
5939
5940        let mut wb = WorkbookBuilder::new();
5941        let sheet = wb.add_sheet("Red Sheet");
5942        sheet.set_cell("A1", "Hello");
5943        sheet.set_tab_color("FF0000"); // red
5944
5945        let mut buffer = Cursor::new(Vec::new());
5946        wb.write(&mut buffer).unwrap();
5947
5948        buffer.set_position(0);
5949        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5950        let read_sheet = workbook.resolved_sheet(0).unwrap();
5951        let ws = read_sheet.worksheet();
5952
5953        let props = ws
5954            .sheet_properties
5955            .as_deref()
5956            .expect("sheet_properties should be set");
5957        let color = props.tab_color.as_deref().expect("tab_color should be set");
5958        // rgb is stored as ARGB bytes: 0xFF, 0xFF, 0x00, 0x00
5959        assert_eq!(
5960            color.rgb.as_deref(),
5961            Some(&[0xFF_u8, 0xFF, 0x00, 0x00] as &[u8])
5962        );
5963    }
5964
5965    // =========================================================================
5966    // Sheet view options
5967    // =========================================================================
5968
5969    #[cfg(feature = "sml-styling")]
5970    #[test]
5971    fn test_roundtrip_show_gridlines() {
5972        use std::io::Cursor;
5973
5974        let mut wb = WorkbookBuilder::new();
5975        let sheet = wb.add_sheet("Sheet1");
5976        sheet.set_cell("A1", "Hello");
5977        sheet.set_show_gridlines(false);
5978
5979        let mut buffer = Cursor::new(Vec::new());
5980        wb.write(&mut buffer).unwrap();
5981
5982        buffer.set_position(0);
5983        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
5984        let read_sheet = workbook.resolved_sheet(0).unwrap();
5985        let ws = read_sheet.worksheet();
5986
5987        let sv = ws
5988            .sheet_views
5989            .as_deref()
5990            .and_then(|v| v.sheet_view.first())
5991            .expect("sheet_views should be set");
5992        assert_eq!(sv.show_grid_lines, Some(false));
5993    }
5994
5995    #[cfg(feature = "sml-styling")]
5996    #[test]
5997    fn test_roundtrip_show_row_col_headers() {
5998        use std::io::Cursor;
5999
6000        let mut wb = WorkbookBuilder::new();
6001        let sheet = wb.add_sheet("Sheet1");
6002        sheet.set_cell("A1", "Hello");
6003        sheet.set_show_row_col_headers(false);
6004
6005        let mut buffer = Cursor::new(Vec::new());
6006        wb.write(&mut buffer).unwrap();
6007
6008        buffer.set_position(0);
6009        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
6010        let read_sheet = workbook.resolved_sheet(0).unwrap();
6011        let ws = read_sheet.worksheet();
6012
6013        let sv = ws
6014            .sheet_views
6015            .as_deref()
6016            .and_then(|v| v.sheet_view.first())
6017            .expect("sheet_views should be set");
6018        assert_eq!(sv.show_row_col_headers, Some(false));
6019    }
6020
6021    #[cfg(all(feature = "sml-styling", feature = "sml-structure"))]
6022    #[test]
6023    fn test_show_gridlines_with_freeze_pane() {
6024        use std::io::Cursor;
6025
6026        // Ensure that setting show_gridlines doesn't clobber a previously set
6027        // freeze pane (both must coexist in the same SheetView).
6028        let mut wb = WorkbookBuilder::new();
6029        let sheet = wb.add_sheet("Sheet1");
6030        sheet.set_cell("A1", "Header");
6031        sheet.freeze_rows(1);
6032        sheet.set_show_gridlines(false);
6033
6034        let mut buffer = Cursor::new(Vec::new());
6035        wb.write(&mut buffer).unwrap();
6036
6037        buffer.set_position(0);
6038        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
6039        let read_sheet = workbook.resolved_sheet(0).unwrap();
6040        let ws = read_sheet.worksheet();
6041
6042        let sv = ws
6043            .sheet_views
6044            .as_deref()
6045            .and_then(|v| v.sheet_view.first())
6046            .expect("sheet_views should be set");
6047        assert_eq!(sv.show_grid_lines, Some(false), "gridlines hidden");
6048        // Freeze pane should still be intact.
6049        let pane = sv.pane.as_deref().expect("freeze pane should be intact");
6050        assert_eq!(pane.y_split, Some(1.0));
6051    }
6052
6053    // =========================================================================
6054    // Sheet protection
6055    // =========================================================================
6056
6057    #[cfg(feature = "sml-protection")]
6058    #[test]
6059    fn test_ooxml_xor_hash_empty() {
6060        // Empty password → 0x0000
6061        let hash = ooxml_xor_hash("");
6062        assert_eq!(hash, vec![0x00, 0x00]);
6063    }
6064
6065    #[cfg(feature = "sml-protection")]
6066    #[test]
6067    fn test_ooxml_xor_hash_known() {
6068        // Known value: "password" hashes to 0xCE4B with the XOR algorithm
6069        // (verified against ECMA-376 §18.2.28 test vectors).
6070        // We don't hard-code the exact value but verify it's non-zero and
6071        // deterministic.
6072        let h1 = ooxml_xor_hash("password");
6073        let h2 = ooxml_xor_hash("password");
6074        assert_eq!(h1, h2, "hash must be deterministic");
6075        assert_ne!(
6076            h1,
6077            vec![0x00, 0x00],
6078            "hash must be non-zero for non-empty password"
6079        );
6080    }
6081
6082    #[cfg(feature = "sml-protection")]
6083    #[test]
6084    fn test_roundtrip_sheet_protection_no_password() {
6085        use std::io::Cursor;
6086
6087        let mut wb = WorkbookBuilder::new();
6088        let sheet = wb.add_sheet("Sheet1");
6089        sheet.set_cell("A1", "Protected");
6090        sheet.set_sheet_protection(SheetProtectionOptions {
6091            sheet: true,
6092            format_cells: true,
6093            insert_rows: true,
6094            ..Default::default()
6095        });
6096
6097        let mut buffer = Cursor::new(Vec::new());
6098        wb.write(&mut buffer).unwrap();
6099
6100        buffer.set_position(0);
6101        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
6102        let read_sheet = workbook.resolved_sheet(0).unwrap();
6103        let ws = read_sheet.worksheet();
6104
6105        let sp = ws
6106            .sheet_protection
6107            .as_deref()
6108            .expect("sheet_protection should be set");
6109        assert_eq!(sp.sheet, Some(true), "sheet locked");
6110        assert_eq!(sp.format_cells, Some(true), "format_cells locked");
6111        assert_eq!(sp.insert_rows, Some(true), "insert_rows locked");
6112        assert_eq!(sp.password, None, "no password");
6113    }
6114
6115    #[cfg(feature = "sml-protection")]
6116    #[test]
6117    fn test_roundtrip_sheet_protection_with_password() {
6118        use std::io::Cursor;
6119
6120        let mut wb = WorkbookBuilder::new();
6121        let sheet = wb.add_sheet("Sheet1");
6122        sheet.set_cell("A1", "Data");
6123        sheet.set_sheet_protection(SheetProtectionOptions {
6124            sheet: true,
6125            password: Some("secret".to_string()),
6126            ..Default::default()
6127        });
6128
6129        let mut buffer = Cursor::new(Vec::new());
6130        wb.write(&mut buffer).unwrap();
6131
6132        buffer.set_position(0);
6133        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
6134        let read_sheet = workbook.resolved_sheet(0).unwrap();
6135        let ws = read_sheet.worksheet();
6136
6137        let sp = ws
6138            .sheet_protection
6139            .as_deref()
6140            .expect("sheet_protection should be set");
6141        assert_eq!(sp.sheet, Some(true));
6142        // Password is stored as a 2-byte hash
6143        let pw = sp.password.as_ref().expect("password should be set");
6144        assert_eq!(pw.len(), 2);
6145        // Verify same password produces same hash
6146        let expected = ooxml_xor_hash("secret");
6147        assert_eq!(pw, &expected);
6148    }
6149
6150    // =========================================================================
6151    // Rich-text comments
6152    // =========================================================================
6153
6154    #[cfg(feature = "sml-comments")]
6155    #[test]
6156    fn test_roundtrip_rich_text_comment() {
6157        use std::io::Cursor;
6158
6159        let mut wb = WorkbookBuilder::new();
6160        let sheet = wb.add_sheet("Sheet1");
6161        sheet.set_cell("A1", "Value");
6162
6163        // Rich-text comment with two runs
6164        let mut cb = CommentBuilder::new_rich("A1");
6165        cb.add_run("Bold prefix: ").set_bold(true);
6166        cb.add_run("normal suffix");
6167        sheet.add_comment_builder(cb);
6168
6169        let mut buffer = Cursor::new(Vec::new());
6170        wb.write(&mut buffer).unwrap();
6171
6172        // Read back and verify the comment text is preserved
6173        buffer.set_position(0);
6174        let mut workbook = crate::Workbook::from_reader(buffer).unwrap();
6175        let read_sheet = workbook.resolved_sheet(0).unwrap();
6176
6177        let comment = read_sheet.comment("A1").expect("comment should exist");
6178        // The reader concatenates all run texts into comment.text
6179        assert!(
6180            comment.text.contains("Bold prefix:"),
6181            "bold run text present: {:?}",
6182            comment.text
6183        );
6184        assert!(
6185            comment.text.contains("normal suffix"),
6186            "normal run text present: {:?}",
6187            comment.text
6188        );
6189    }
6190
6191    // =========================================================================
6192    // Workbook protection
6193    // =========================================================================
6194
6195    #[cfg(feature = "sml-protection")]
6196    #[test]
6197    fn test_roundtrip_workbook_protection() {
6198        use std::io::Cursor;
6199
6200        let mut wb = WorkbookBuilder::new();
6201        wb.add_sheet("Sheet1");
6202        wb.set_workbook_protection(true, false, Some("wb_pass"));
6203
6204        let mut buffer = Cursor::new(Vec::new());
6205        wb.write(&mut buffer).unwrap();
6206
6207        buffer.set_position(0);
6208        let workbook = crate::Workbook::from_reader(buffer).unwrap();
6209
6210        let wp = workbook
6211            .workbook_protection()
6212            .expect("workbook_protection should be set");
6213        assert_eq!(wp.lock_structure, Some(true), "lock_structure");
6214        assert_eq!(wp.lock_windows, None, "lock_windows not set");
6215        let pw = wp
6216            .workbook_password
6217            .as_ref()
6218            .expect("password should be set");
6219        assert_eq!(pw.len(), 2);
6220        let expected = ooxml_xor_hash("wb_pass");
6221        assert_eq!(pw, &expected);
6222    }
6223
6224    #[cfg(feature = "sml-protection")]
6225    #[test]
6226    fn test_roundtrip_workbook_protection_no_password() {
6227        use std::io::Cursor;
6228
6229        let mut wb = WorkbookBuilder::new();
6230        wb.add_sheet("Sheet1");
6231        wb.set_workbook_protection(true, true, None);
6232
6233        let mut buffer = Cursor::new(Vec::new());
6234        wb.write(&mut buffer).unwrap();
6235
6236        buffer.set_position(0);
6237        let workbook = crate::Workbook::from_reader(buffer).unwrap();
6238
6239        let wp = workbook
6240            .workbook_protection()
6241            .expect("workbook_protection should be set");
6242        assert_eq!(wp.lock_structure, Some(true));
6243        assert_eq!(wp.lock_windows, Some(true));
6244        assert_eq!(wp.workbook_password, None, "no password");
6245    }
6246
6247    // =========================================================================
6248    // Named cell styles
6249    // =========================================================================
6250
6251    #[cfg(feature = "sml-styling")]
6252    #[test]
6253    fn test_add_cell_style_returns_index() {
6254        let mut wb = WorkbookBuilder::new();
6255        wb.add_sheet("Sheet1");
6256
6257        // First extra style gets index 1 (Normal = 0)
6258        let idx1 = wb.add_cell_style("Good", 0);
6259        assert_eq!(idx1, 1);
6260
6261        // Second extra style gets index 2
6262        let idx2 = wb.add_cell_style("Bad", 0);
6263        assert_eq!(idx2, 2);
6264    }
6265
6266    #[cfg(feature = "sml-styling")]
6267    #[test]
6268    fn test_roundtrip_cell_styles() {
6269        use std::io::Cursor;
6270
6271        let mut wb = WorkbookBuilder::new();
6272        wb.add_sheet("Sheet1");
6273        wb.add_cell_style("Good", 0);
6274        wb.add_cell_style("Neutral", 0);
6275
6276        let mut buffer = Cursor::new(Vec::new());
6277        wb.write(&mut buffer).unwrap();
6278
6279        buffer.set_position(0);
6280        let workbook = crate::Workbook::from_reader(buffer).unwrap();
6281
6282        // The stylesheet should have Normal + Good + Neutral = 3 cell styles
6283        let stylesheet = workbook.styles();
6284        let cell_styles = stylesheet
6285            .cell_styles
6286            .as_deref()
6287            .expect("cell_styles should be set");
6288        assert_eq!(cell_styles.count, Some(3));
6289        assert_eq!(cell_styles.cell_style[0].name.as_deref(), Some("Normal"));
6290        assert_eq!(cell_styles.cell_style[1].name.as_deref(), Some("Good"));
6291        assert_eq!(cell_styles.cell_style[2].name.as_deref(), Some("Neutral"));
6292        // Custom styles have customBuiltin=true
6293        assert_eq!(cell_styles.cell_style[1].custom_builtin, Some(true));
6294    }
6295
6296    // =========================================================================
6297    // Chart embedding
6298    // =========================================================================
6299
6300    /// Verify that `embed_chart` writes a drawing part and a chart XML part.
6301    #[cfg(feature = "sml-charts")]
6302    #[test]
6303    fn test_embed_chart_creates_drawing_and_chart_parts() {
6304        use std::collections::HashSet;
6305        use std::io::Cursor;
6306
6307        let chart_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
6308<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">
6309  <c:chart/>
6310</c:chartSpace>"#;
6311
6312        let mut wb = WorkbookBuilder::new();
6313        let sheet = wb.add_sheet("Sheet1");
6314        sheet.set_cell("A1", "data");
6315        sheet.embed_chart(chart_xml, 0, 5, 8, 15);
6316
6317        let mut buffer = Cursor::new(Vec::new());
6318        wb.write(&mut buffer).unwrap();
6319
6320        // Unpack the ZIP and check which parts are present.
6321        buffer.set_position(0);
6322        let mut zip = zip::ZipArchive::new(buffer).unwrap();
6323        let names: HashSet<String> = (0..zip.len())
6324            .map(|i| zip.by_index(i).unwrap().name().to_string())
6325            .collect();
6326
6327        // Drawing and chart parts must exist.
6328        assert!(
6329            names.contains("xl/drawings/drawing1.xml"),
6330            "drawing part missing; parts: {names:?}"
6331        );
6332        assert!(
6333            names.contains("xl/charts/chart1.xml"),
6334            "chart part missing; parts: {names:?}"
6335        );
6336        // Drawing rels must exist.
6337        assert!(
6338            names.contains("xl/drawings/_rels/drawing1.xml.rels"),
6339            "drawing rels missing; parts: {names:?}"
6340        );
6341        // Sheet rels must reference the drawing.
6342        assert!(
6343            names.contains("xl/worksheets/_rels/sheet1.xml.rels"),
6344            "sheet rels missing; parts: {names:?}"
6345        );
6346
6347        // The drawing XML must contain a twoCellAnchor with the chart reference.
6348        let drawing_bytes = {
6349            let mut f = zip.by_name("xl/drawings/drawing1.xml").unwrap();
6350            let mut v = Vec::new();
6351            std::io::Read::read_to_end(&mut f, &mut v).unwrap();
6352            v
6353        };
6354        let drawing_str = String::from_utf8_lossy(&drawing_bytes);
6355        assert!(
6356            drawing_str.contains("twoCellAnchor"),
6357            "drawing XML should contain twoCellAnchor"
6358        );
6359        assert!(
6360            drawing_str.contains("rId1"),
6361            "drawing XML should reference rId1 for the chart"
6362        );
6363
6364        // The chart XML must be exactly what was passed in.
6365        let chart_bytes = {
6366            let mut f = zip.by_name("xl/charts/chart1.xml").unwrap();
6367            let mut v = Vec::new();
6368            std::io::Read::read_to_end(&mut f, &mut v).unwrap();
6369            v
6370        };
6371        assert_eq!(chart_bytes, chart_xml);
6372    }
6373
6374    /// Two charts in the same sheet should get separate rId/chart numbers.
6375    #[cfg(feature = "sml-charts")]
6376    #[test]
6377    fn test_embed_two_charts_same_sheet() {
6378        use std::collections::HashSet;
6379        use std::io::Cursor;
6380
6381        let chart_xml = b"<c:chartSpace/>";
6382
6383        let mut wb = WorkbookBuilder::new();
6384        let sheet = wb.add_sheet("Sheet1");
6385        sheet.embed_chart(chart_xml, 0, 0, 4, 10);
6386        sheet.embed_chart(chart_xml, 5, 0, 4, 10);
6387
6388        let mut buffer = Cursor::new(Vec::new());
6389        wb.write(&mut buffer).unwrap();
6390
6391        buffer.set_position(0);
6392        let mut zip = zip::ZipArchive::new(buffer).unwrap();
6393        let names: HashSet<String> = (0..zip.len())
6394            .map(|i| zip.by_index(i).unwrap().name().to_string())
6395            .collect();
6396
6397        // Should have one drawing (for the sheet) but two chart files.
6398        assert!(names.contains("xl/drawings/drawing1.xml"));
6399        assert!(names.contains("xl/charts/chart1.xml"));
6400        assert!(names.contains("xl/charts/chart2.xml"));
6401        // Should NOT have a second drawing file.
6402        assert!(!names.contains("xl/drawings/drawing2.xml"));
6403    }
6404
6405    /// Charts across two different sheets get separate drawing/chart files.
6406    #[cfg(feature = "sml-charts")]
6407    #[test]
6408    fn test_embed_charts_multiple_sheets() {
6409        use std::collections::HashSet;
6410        use std::io::Cursor;
6411
6412        let chart_xml = b"<c:chartSpace/>";
6413
6414        let mut wb = WorkbookBuilder::new();
6415        let s1 = wb.add_sheet("Sheet1");
6416        s1.embed_chart(chart_xml, 0, 0, 4, 10);
6417        let s2 = wb.add_sheet("Sheet2");
6418        s2.embed_chart(chart_xml, 0, 0, 4, 10);
6419
6420        let mut buffer = Cursor::new(Vec::new());
6421        wb.write(&mut buffer).unwrap();
6422
6423        buffer.set_position(0);
6424        let mut zip = zip::ZipArchive::new(buffer).unwrap();
6425        let names: HashSet<String> = (0..zip.len())
6426            .map(|i| zip.by_index(i).unwrap().name().to_string())
6427            .collect();
6428
6429        assert!(names.contains("xl/drawings/drawing1.xml"));
6430        assert!(names.contains("xl/drawings/drawing2.xml"));
6431        assert!(names.contains("xl/charts/chart1.xml"));
6432        assert!(names.contains("xl/charts/chart2.xml"));
6433    }
6434
6435    // =========================================================================
6436    // Pivot tables
6437    // =========================================================================
6438
6439    /// A pivot table produces the expected set of XML parts.
6440    #[cfg(feature = "sml-pivot")]
6441    #[test]
6442    fn test_pivot_table_creates_expected_parts() {
6443        use std::collections::HashSet;
6444        use std::io::Cursor;
6445
6446        let mut wb = WorkbookBuilder::new();
6447        let sheet = wb.add_sheet("Sheet1");
6448        sheet.set_cell("A1", "Region");
6449        sheet.set_cell("B1", "Sales");
6450        sheet.add_pivot_table(PivotTableOptions {
6451            name: "PivotTable1".to_string(),
6452            source_ref: "Sheet1!$A$1:$B$5".to_string(),
6453            dest_ref: "D1".to_string(),
6454            row_fields: vec!["Region".to_string()],
6455            col_fields: vec![],
6456            data_fields: vec!["Sales".to_string()],
6457        });
6458
6459        let mut buffer = Cursor::new(Vec::new());
6460        wb.write(&mut buffer).unwrap();
6461
6462        buffer.set_position(0);
6463        let mut zip = zip::ZipArchive::new(buffer).unwrap();
6464        let names: HashSet<String> = (0..zip.len())
6465            .map(|i| zip.by_index(i).unwrap().name().to_string())
6466            .collect();
6467
6468        assert!(
6469            names.contains("xl/pivotCache/pivotCacheDefinition1.xml"),
6470            "pivot cache definition missing; parts: {names:?}"
6471        );
6472        assert!(
6473            names.contains("xl/pivotCache/pivotCacheRecords1.xml"),
6474            "pivot cache records missing; parts: {names:?}"
6475        );
6476        assert!(
6477            names.contains("xl/pivotTables/pivotTable1.xml"),
6478            "pivot table missing; parts: {names:?}"
6479        );
6480        assert!(
6481            names.contains("xl/pivotTables/_rels/pivotTable1.xml.rels"),
6482            "pivot table rels missing; parts: {names:?}"
6483        );
6484        assert!(
6485            names.contains("xl/pivotCache/_rels/pivotCacheDefinition1.xml.rels"),
6486            "pivot cache definition rels missing; parts: {names:?}"
6487        );
6488        assert!(
6489            names.contains("xl/worksheets/_rels/sheet1.xml.rels"),
6490            "sheet rels missing; parts: {names:?}"
6491        );
6492    }
6493
6494    /// The pivot table XML must contain the expected structure elements.
6495    #[cfg(feature = "sml-pivot")]
6496    #[test]
6497    fn test_pivot_table_xml_structure() {
6498        use std::io::Cursor;
6499
6500        let mut wb = WorkbookBuilder::new();
6501        let sheet = wb.add_sheet("Sheet1");
6502        sheet.add_pivot_table(PivotTableOptions {
6503            name: "MySales".to_string(),
6504            source_ref: "Sheet1!$A$1:$C$10".to_string(),
6505            dest_ref: "E1".to_string(),
6506            row_fields: vec!["Region".to_string()],
6507            col_fields: vec!["Quarter".to_string()],
6508            data_fields: vec!["Revenue".to_string()],
6509        });
6510
6511        let mut buffer = Cursor::new(Vec::new());
6512        wb.write(&mut buffer).unwrap();
6513
6514        buffer.set_position(0);
6515        let mut zip = zip::ZipArchive::new(buffer).unwrap();
6516
6517        // Check pivot table definition content.
6518        let pt_bytes = {
6519            let mut f = zip.by_name("xl/pivotTables/pivotTable1.xml").unwrap();
6520            let mut v = Vec::new();
6521            std::io::Read::read_to_end(&mut f, &mut v).unwrap();
6522            v
6523        };
6524        let pt_str = String::from_utf8_lossy(&pt_bytes);
6525        assert!(
6526            pt_str.contains("MySales"),
6527            "should contain pivot table name"
6528        );
6529        assert!(
6530            pt_str.contains("pivotTableDefinition"),
6531            "should have root element"
6532        );
6533        assert!(
6534            pt_str.contains("rowFields"),
6535            "should have rowFields element"
6536        );
6537        assert!(
6538            pt_str.contains("dataFields"),
6539            "should have dataFields element"
6540        );
6541        assert!(
6542            pt_str.contains("Sum of Revenue"),
6543            "should have data field name"
6544        );
6545
6546        // Check pivot cache definition content.
6547        let cd_bytes = {
6548            let mut f = zip
6549                .by_name("xl/pivotCache/pivotCacheDefinition1.xml")
6550                .unwrap();
6551            let mut v = Vec::new();
6552            std::io::Read::read_to_end(&mut f, &mut v).unwrap();
6553            v
6554        };
6555        let cd_str = String::from_utf8_lossy(&cd_bytes);
6556        assert!(
6557            cd_str.contains("worksheetSource"),
6558            "should have worksheetSource"
6559        );
6560        assert!(cd_str.contains("Sheet1"), "should reference source sheet");
6561        assert!(cd_str.contains("cacheFields"), "should have cacheFields");
6562        assert!(cd_str.contains("Region"), "should list Region field");
6563        assert!(cd_str.contains("Revenue"), "should list Revenue field");
6564    }
6565
6566    /// Two pivot tables in one sheet get separate part numbers.
6567    #[cfg(feature = "sml-pivot")]
6568    #[test]
6569    fn test_two_pivot_tables_same_sheet() {
6570        use std::collections::HashSet;
6571        use std::io::Cursor;
6572
6573        let mut wb = WorkbookBuilder::new();
6574        let sheet = wb.add_sheet("Sheet1");
6575        sheet.add_pivot_table(PivotTableOptions {
6576            name: "Pivot1".to_string(),
6577            source_ref: "Sheet1!$A$1:$B$5".to_string(),
6578            dest_ref: "D1".to_string(),
6579            row_fields: vec!["A".to_string()],
6580            col_fields: vec![],
6581            data_fields: vec!["B".to_string()],
6582        });
6583        sheet.add_pivot_table(PivotTableOptions {
6584            name: "Pivot2".to_string(),
6585            source_ref: "Sheet1!$A$1:$B$5".to_string(),
6586            dest_ref: "G1".to_string(),
6587            row_fields: vec!["A".to_string()],
6588            col_fields: vec![],
6589            data_fields: vec!["B".to_string()],
6590        });
6591
6592        let mut buffer = Cursor::new(Vec::new());
6593        wb.write(&mut buffer).unwrap();
6594
6595        buffer.set_position(0);
6596        let mut zip = zip::ZipArchive::new(buffer).unwrap();
6597        let names: HashSet<String> = (0..zip.len())
6598            .map(|i| zip.by_index(i).unwrap().name().to_string())
6599            .collect();
6600
6601        assert!(names.contains("xl/pivotTables/pivotTable1.xml"));
6602        assert!(names.contains("xl/pivotTables/pivotTable2.xml"));
6603        assert!(names.contains("xl/pivotCache/pivotCacheDefinition1.xml"));
6604        assert!(names.contains("xl/pivotCache/pivotCacheDefinition2.xml"));
6605    }
6606}