Skip to main content

excel_ooxml/
writer.rs

1//! Writing our minimal [`Workbook`] model out to a valid `.xlsx` package.
2
3use std::collections::HashMap;
4use std::io::{Seek, Write};
5
6use opc::{Package, Part, Relationship, Relationships, TargetMode};
7use xml_core::{BytesDecl, BytesEnd, BytesStart, BytesText, Event, Writer};
8
9use crate::error::Result;
10use crate::model::{
11    AutoFilterColumn, Border, BorderEdge, BorderStyle, CalculationMode, CalculationProperties,
12    Cell, CellAnchorPoint, CellComment, CellFormat, CellHyperlink, CellValue, CfvoPosition,
13    ColumnSetting, ComparisonOperator, ConditionalFormattingCondition, ConditionalFormattingRule,
14    CustomPropertyValue, DataValidationKind, DataValidationRule, DefinedName, DocumentProperties,
15    DrawingObject, ExcelDateTime, ExcelTable, ExternalWorkbookLink, GradientFill,
16    HorizontalAlignment, HyperlinkTarget, IconSetType, IgnoredError, NamedCellStyle,
17    PageOrientation, PatternFill, PatternType, PrintSettings, ProtectedRange, RichTextRun,
18    Scenario, Sheet, SheetChart, SheetDrawing, SheetPicture, SheetProtection, SheetVisibility,
19    SortState, TableColumn, TableStyle, TableStyleElementType, TextComparisonOperator,
20    TotalsRowFunction, VerticalAlignment, Workbook, WorkbookProtection, WriteProtection,
21};
22
23const SPREADSHEETML_NAMESPACE: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
24const RELATIONSHIPS_NAMESPACE: &str =
25    "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
26
27const MAIN_WORKBOOK_CONTENT_TYPE: &str =
28    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml";
29const MAIN_WORKBOOK_RELATIONSHIP_TYPE: &str =
30    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument";
31const MAIN_WORKBOOK_PART_NAME: &str = "/xl/workbook.xml";
32const MAIN_WORKBOOK_ENTRY_NAME: &str = "xl/workbook.xml";
33
34const WORKSHEET_CONTENT_TYPE: &str =
35    "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml";
36const WORKSHEET_RELATIONSHIP_TYPE: &str =
37    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet";
38
39// `xl/styles.xml` and `xl/sharedStrings.xml` are, unlike `word-ooxml`'s
40// `word/styles.xml`/`word/numbering.xml`, not optional here: both relationships are created
41// unconditionally the moment a brand-new workbook is created, before the caller adds a single cell
42// — real Excel expects `styles.xml` to carry at least one font, exactly two fills (`none` then
43// `gray125`, in that order — the second is a real, easy-to-miss Excel quirk with no obvious purpose
44// but required nonetheless), one border and one `cellXfs`/`cellStyleXfs` entry each, or it treats
45// the file as needing repair. This crate always writes both parts, matching that real-world minimum
46// rather than the letter of the schema (which technically allows omitting almost everything).
47const STYLES_CONTENT_TYPE: &str =
48    "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml";
49const STYLES_RELATIONSHIP_TYPE: &str =
50    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles";
51const STYLES_PART_NAME: &str = "/xl/styles.xml";
52const STYLES_ENTRY_NAME: &str = "styles.xml";
53
54const SHARED_STRINGS_CONTENT_TYPE: &str =
55    "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml";
56const SHARED_STRINGS_RELATIONSHIP_TYPE: &str =
57    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings";
58const SHARED_STRINGS_PART_NAME: &str = "/xl/sharedStrings.xml";
59const SHARED_STRINGS_ENTRY_NAME: &str = "sharedStrings.xml";
60
61// Package-level parts, same posture (and same fixed minimal content, for now — no
62// `WorkbookProperties` model yet, matching `word-ooxml`'s own before
63// `DocumentProperties`/`ExtendedProperties` existed) as `word-ooxml`'s
64// `docProps/core.xml`/`docProps/app.xml`.
65const CORE_PROPERTIES_CONTENT_TYPE: &str =
66    "application/vnd.openxmlformats-package.core-properties+xml";
67const CORE_PROPERTIES_RELATIONSHIP_TYPE: &str =
68    "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties";
69const CORE_PROPERTIES_PART_NAME: &str = "/docProps/core.xml";
70const CORE_PROPERTIES_ENTRY_NAME: &str = "docProps/core.xml";
71const CORE_PROPERTIES_NAMESPACE: &str =
72    "http://schemas.openxmlformats.org/package/2006/metadata/core-properties";
73const DC_NAMESPACE: &str = "http://purl.org/dc/elements/1.1/";
74const DCTERMS_NAMESPACE: &str = "http://purl.org/dc/terms/";
75const XSI_NAMESPACE: &str = "http://www.w3.org/2001/XMLSchema-instance";
76
77const EXTENDED_PROPERTIES_CONTENT_TYPE: &str =
78    "application/vnd.openxmlformats-officedocument.extended-properties+xml";
79const EXTENDED_PROPERTIES_RELATIONSHIP_TYPE: &str =
80    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties";
81const EXTENDED_PROPERTIES_PART_NAME: &str = "/docProps/app.xml";
82const EXTENDED_PROPERTIES_ENTRY_NAME: &str = "docProps/app.xml";
83const EXTENDED_PROPERTIES_NAMESPACE: &str =
84    "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties";
85
86// `docProps/custom.xml`. Genuinely optional (unlike `core.xml`/`app.xml` above, always written):
87// only added when `Workbook.properties.custom_properties` is non-empty, mirroring `word-ooxml`'s
88// own `CUSTOM_PROPERTIES_*` constants and posture exactly.
89const CUSTOM_PROPERTIES_CONTENT_TYPE: &str =
90    "application/vnd.openxmlformats-officedocument.custom-properties+xml";
91const CUSTOM_PROPERTIES_RELATIONSHIP_TYPE: &str =
92    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties";
93const CUSTOM_PROPERTIES_PART_NAME: &str = "/docProps/custom.xml";
94const CUSTOM_PROPERTIES_ENTRY_NAME: &str = "docProps/custom.xml";
95const CUSTOM_PROPERTIES_NAMESPACE: &str =
96    "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties";
97const CUSTOM_PROPERTIES_VT_NAMESPACE: &str =
98    "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes";
99/// The fixed `fmtid` every custom document property uses — the well-known GUID confirmed against
100/// real `docProps/custom.xml` fixtures (e.g. the Open-XML-SDK test suite), same constant
101/// `word-ooxml`'s own `CUSTOM_PROPERTY_FMTID` uses.
102const CUSTOM_PROPERTY_FMTID: &str = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}";
103/// The first `pid` assigned to a custom property, in declaration order — `0`/`1` are reserved by
104/// the format, same convention `word-ooxml`'s own `FIRST_CUSTOM_PROPERTY_PID` uses.
105const FIRST_CUSTOM_PROPERTY_PID: i32 = 2;
106
107// Per-worksheet related parts (hyperlinks, structured tables, legacy comments + their VML
108// drawing).
109const HYPERLINK_RELATIONSHIP_TYPE: &str =
110    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
111
112const TABLE_CONTENT_TYPE: &str =
113    "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml";
114const TABLE_RELATIONSHIP_TYPE: &str =
115    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/table";
116
117const COMMENTS_CONTENT_TYPE: &str =
118    "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml";
119const COMMENTS_RELATIONSHIP_TYPE: &str =
120    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
121
122// a sheet's drawing (embedded pictures/charts).
123const DRAWING_CONTENT_TYPE: &str = "application/vnd.openxmlformats-officedocument.drawing+xml";
124const DRAWING_RELATIONSHIP_TYPE: &str =
125    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing";
126const IMAGE_RELATIONSHIP_TYPE: &str =
127    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
128const CHART_CONTENT_TYPE: &str =
129    "application/vnd.openxmlformats-officedocument.drawingml.chart+xml";
130const CHART_RELATIONSHIP_TYPE: &str =
131    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart";
132const DRAWINGML_NAMESPACE: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
133const SPREADSHEET_DRAWING_NAMESPACE: &str =
134    "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing";
135const CHART_NAMESPACE: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart";
136
137// The legacy VML comment-shape drawing real Excel still expects alongside `xl/commentsN.xml` for
138// the comment indicator/popup to render correctly in every Excel version.
139const VML_DRAWING_CONTENT_TYPE: &str = "application/vnd.openxmlformats-officedocument.vmlDrawing";
140const VML_DRAWING_RELATIONSHIP_TYPE: &str =
141    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing";
142
143const THEME_CONTENT_TYPE: &str = "application/vnd.openxmlformats-officedocument.theme+xml";
144const THEME_RELATIONSHIP_TYPE: &str =
145    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme";
146const THEME_PART_NAME: &str = "/xl/theme/theme1.xml";
147const THEME_ENTRY_NAME: &str = "theme/theme1.xml";
148
149/// The workbook part's own content type once a VBA project is present — the `.xlsm` "macro-enabled"
150/// variant of the ordinary `.xlsx` main workbook content type, otherwise identical in every other
151/// respect (same schema, same `[Content_Types].xml` `Override` mechanism).
152const MACRO_ENABLED_WORKBOOK_CONTENT_TYPE: &str =
153    "application/vnd.ms-excel.sheet.macroEnabled.main+xml";
154const VBA_PROJECT_CONTENT_TYPE: &str = "application/vnd.ms-office.vbaProject";
155const VBA_PROJECT_RELATIONSHIP_TYPE: &str =
156    "http://schemas.microsoft.com/office/2006/relationships/vbaProject";
157const VBA_PROJECT_PART_NAME: &str = "/xl/vbaProject.bin";
158const VBA_PROJECT_ENTRY_NAME: &str = "vbaProject.bin";
159
160const EXTERNAL_LINK_CONTENT_TYPE: &str =
161    "application/vnd.openxmlformats-officedocument.spreadsheetml.externalLink+xml";
162const EXTERNAL_LINK_RELATIONSHIP_TYPE: &str =
163    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/externalLink";
164const EXTERNAL_LINK_TARGET_RELATIONSHIP_TYPE: &str =
165    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/externalLinkPath";
166
167/// The first `numFmtId` this crate ever assigns to a custom number format — ids below this are
168/// reserved by Excel for its ~40 built-in formats (`0` = "General", `2` = `"0.00"`..); `164` is the
169/// conventional first free id, the same convention `word-ooxml`'s custom document properties follow
170/// for `pid` (starting at 2, past the 2 reserved slots).
171const FIRST_CUSTOM_NUM_FMT_ID: u32 = 164;
172
173impl Workbook {
174    /// Writes this workbook out as a valid `.xlsx` package.
175    ///
176    /// Writes `[Content_Types].xml`, `_rels/.rels`, `xl/workbook.xml`,
177    /// `xl/_rels/workbook.xml.rels`, one `xl/worksheets/sheetN.xml` per sheet, `xl/styles.xml`,
178    /// `xl/sharedStrings.xml`, `docProps/core.xml`, `docProps/app.xml`, and `xl/theme/theme1.xml`
179    /// (always the fixed default "Office" theme —; this crate has no notion of a customizable theme
180    /// model, unlike `word-ooxml`'s `Theme`). `xl/calcChain.xml` is still never written — it only
181    /// matters for formula dependency ordering, which this crate leaves to Excel to recompute on
182    /// open. `xl/vbaProject.bin` and `xl/externalLinks/externalLinkN.xml` are written only when
183    /// [`Workbook::vba_project`]/[`Workbook::external_links`] are non-empty.
184    pub fn write_to<W: Write + Seek>(&self, writer: W) -> Result<W> {
185        let mut package = Package::new();
186
187        let workbook_content_type = if self.vba_project.is_some() {
188            MACRO_ENABLED_WORKBOOK_CONTENT_TYPE
189        } else {
190            MAIN_WORKBOOK_CONTENT_TYPE
191        };
192
193        package.add_relationship(Relationship {
194            id: "rId1".to_string(),
195            rel_type: MAIN_WORKBOOK_RELATIONSHIP_TYPE.to_string(),
196            target: MAIN_WORKBOOK_ENTRY_NAME.to_string(),
197            target_mode: TargetMode::Internal,
198        });
199        package.add_relationship(Relationship {
200            id: "rId2".to_string(),
201            rel_type: CORE_PROPERTIES_RELATIONSHIP_TYPE.to_string(),
202            target: CORE_PROPERTIES_ENTRY_NAME.to_string(),
203            target_mode: TargetMode::Internal,
204        });
205        package.add_relationship(Relationship {
206            id: "rId3".to_string(),
207            rel_type: EXTENDED_PROPERTIES_RELATIONSHIP_TYPE.to_string(),
208            target: EXTENDED_PROPERTIES_ENTRY_NAME.to_string(),
209            target_mode: TargetMode::Internal,
210        });
211        // `docProps/custom.xml` is a genuinely optional part (see its constants' doc comment above)
212        // — its package relationship is only added when there's actually something to write, unlike
213        // `rId1`/`rId2`/`rId3` above which are unconditional. The point 56, mirrors `word-ooxml`'s
214        // own posture exactly.
215        if !self.properties.custom_properties.is_empty() {
216            package.add_relationship(Relationship {
217                id: "rId4".to_string(),
218                rel_type: CUSTOM_PROPERTIES_RELATIONSHIP_TYPE.to_string(),
219                target: CUSTOM_PROPERTIES_ENTRY_NAME.to_string(),
220                target_mode: TargetMode::Internal,
221            });
222        }
223
224        let shared_strings = collect_shared_strings(self);
225        let cell_formats = collect_cell_formats(self);
226        let dxfs = collect_dxfs(self);
227
228        // `xl/workbook.xml`'s own relationships: one per sheet (in order), then styles, then shared
229        // strings, then theme, then (optionally) external links and a VBA project — mirrors real
230        // Excel output's id ordering closely enough without trying to match it exactly (Excel
231        // itself doesn't guarantee a fixed id assignment order either).
232        let mut workbook_relationships = Relationships::new();
233        let sheet_relationship_ids: Vec<String> = (0..self.sheets.len())
234            .map(|index| format!("rId{}", index + 1))
235            .collect();
236        for (index, relationship_id) in sheet_relationship_ids.iter().enumerate() {
237            workbook_relationships.add(Relationship {
238                id: relationship_id.clone(),
239                rel_type: WORKSHEET_RELATIONSHIP_TYPE.to_string(),
240                target: format!("worksheets/sheet{}.xml", index + 1),
241                target_mode: TargetMode::Internal,
242            });
243        }
244        let mut next_workbook_relationship_id = self.sheets.len() + 1;
245
246        let styles_relationship_id = format!("rId{next_workbook_relationship_id}");
247        next_workbook_relationship_id += 1;
248        workbook_relationships.add(Relationship {
249            id: styles_relationship_id,
250            rel_type: STYLES_RELATIONSHIP_TYPE.to_string(),
251            target: STYLES_ENTRY_NAME.to_string(),
252            target_mode: TargetMode::Internal,
253        });
254        let shared_strings_relationship_id = format!("rId{next_workbook_relationship_id}");
255        next_workbook_relationship_id += 1;
256        workbook_relationships.add(Relationship {
257            id: shared_strings_relationship_id,
258            rel_type: SHARED_STRINGS_RELATIONSHIP_TYPE.to_string(),
259            target: SHARED_STRINGS_ENTRY_NAME.to_string(),
260            target_mode: TargetMode::Internal,
261        });
262        let theme_relationship_id = format!("rId{next_workbook_relationship_id}");
263        next_workbook_relationship_id += 1;
264        workbook_relationships.add(Relationship {
265            id: theme_relationship_id,
266            rel_type: THEME_RELATIONSHIP_TYPE.to_string(),
267            target: THEME_ENTRY_NAME.to_string(),
268            target_mode: TargetMode::Internal,
269        });
270
271        let mut external_link_relationship_ids: Vec<String> = Vec::new();
272        for (index, _link) in self.external_links.iter().enumerate() {
273            let id = format!("rId{next_workbook_relationship_id}");
274            next_workbook_relationship_id += 1;
275            workbook_relationships.add(Relationship {
276                id: id.clone(),
277                rel_type: EXTERNAL_LINK_RELATIONSHIP_TYPE.to_string(),
278                target: format!("externalLinks/externalLink{}.xml", index + 1),
279                target_mode: TargetMode::Internal,
280            });
281            external_link_relationship_ids.push(id);
282        }
283
284        if self.vba_project.is_some() {
285            let id = format!("rId{next_workbook_relationship_id}");
286            next_workbook_relationship_id += 1;
287            workbook_relationships.add(Relationship {
288                id,
289                rel_type: VBA_PROJECT_RELATIONSHIP_TYPE.to_string(),
290                target: VBA_PROJECT_ENTRY_NAME.to_string(),
291                target_mode: TargetMode::Internal,
292            });
293        }
294        let _ = next_workbook_relationship_id; // last write not read further, avoids an unused-assignment warning
295
296        // `_xlnm.Print_Area`/`_xlnm.Print_Titles` are, despite living on `Sheet.print_settings`,
297        // actually workbook-level `<definedNames>` entries scoped to their sheet — synthesized here
298        // and appended after the caller's own `self.defined_names`, see `PrintSettings`'s doc
299        // comment.
300        let mut all_defined_names = self.defined_names.clone();
301        all_defined_names.extend(synthesized_print_defined_names(self));
302
303        let workbook_xml = to_workbook_xml(
304            self,
305            &sheet_relationship_ids,
306            &all_defined_names,
307            &external_link_relationship_ids,
308        )?;
309        let mut workbook_part =
310            Part::new(MAIN_WORKBOOK_PART_NAME, workbook_content_type, workbook_xml);
311        workbook_part.relationships = workbook_relationships;
312        package.add_part(workbook_part);
313
314        package.add_part(Part::new(
315            THEME_PART_NAME,
316            THEME_CONTENT_TYPE,
317            to_theme_xml(),
318        ));
319
320        if let Some(vba_project) = &self.vba_project {
321            package.add_part(Part::new(
322                VBA_PROJECT_PART_NAME,
323                VBA_PROJECT_CONTENT_TYPE,
324                vba_project.clone(),
325            ));
326        }
327
328        for (index, link) in self.external_links.iter().enumerate() {
329            let mut link_relationships = Relationships::new();
330            link_relationships.add(Relationship {
331                id: "rId1".to_string(),
332                rel_type: EXTERNAL_LINK_TARGET_RELATIONSHIP_TYPE.to_string(),
333                target: link.target.clone(),
334                target_mode: TargetMode::External,
335            });
336            let mut link_part = Part::new(
337                format!("/xl/externalLinks/externalLink{}.xml", index + 1),
338                EXTERNAL_LINK_CONTENT_TYPE,
339                to_external_link_xml(link)?,
340            );
341            link_part.relationships = link_relationships;
342            package.add_part(link_part);
343        }
344
345        // `xl/media/imageN.<ext>` and `xl/charts/chartN.xml` are numbered globally across the whole
346        // workbook (not per-sheet, unlike `xl/tables/tableN.xml`), matching how real Excel itself
347        // shares a single `xl/media` folder and a single chart-numbering sequence across every
348        // sheet's drawing.
349        let mut next_media_index = 1usize;
350        let mut next_chart_index = 1usize;
351
352        for (index, sheet) in self.sheets.iter().enumerate() {
353            // Each worksheet's own `.rels` (hyperlinks/table/legacy comment drawing) is built
354            // alongside its XML, since the `r:id`s embedded in the XML must match exactly — see
355            // `WorksheetRelationshipIds`'s doc comment.
356            let mut sheet_relationships = Relationships::new();
357            let mut next_sheet_relationship_id = 1usize;
358
359            let hyperlink_ids: Vec<Option<String>> = sheet
360                .hyperlinks
361                .iter()
362                .map(|hyperlink| match &hyperlink.target {
363                    HyperlinkTarget::External(url) => {
364                        let id = format!("rId{next_sheet_relationship_id}");
365                        next_sheet_relationship_id += 1;
366                        sheet_relationships.add(Relationship {
367                            id: id.clone(),
368                            rel_type: HYPERLINK_RELATIONSHIP_TYPE.to_string(),
369                            target: url.clone(),
370                            target_mode: TargetMode::External,
371                        });
372                        Some(id)
373                    }
374                    HyperlinkTarget::Internal(_) => None,
375                })
376                .collect();
377
378            let table_id = if sheet.table.is_some() {
379                let id = format!("rId{next_sheet_relationship_id}");
380                next_sheet_relationship_id += 1;
381                sheet_relationships.add(Relationship {
382                    id: id.clone(),
383                    rel_type: TABLE_RELATIONSHIP_TYPE.to_string(),
384                    target: format!("../tables/table{}.xml", index + 1),
385                    target_mode: TargetMode::Internal,
386                });
387                Some(id)
388            } else {
389                None
390            };
391
392            let legacy_drawing_id = if !sheet.comments.is_empty() {
393                let id = format!("rId{next_sheet_relationship_id}");
394                next_sheet_relationship_id += 1;
395                sheet_relationships.add(Relationship {
396                    id: id.clone(),
397                    rel_type: VML_DRAWING_RELATIONSHIP_TYPE.to_string(),
398                    target: format!("../drawings/vmlDrawing{}.vml", index + 1),
399                    target_mode: TargetMode::Internal,
400                });
401                // `xl/commentsN.xml` is referenced from the *workbook* relationship-wise the same
402                // way a worksheet is (a plain internal relationship declared on the worksheet
403                // part), even though the comments themselves are conceptually "for" this sheet —
404                // matches how real Excel wires the two together.
405                let comments_id = format!("rId{next_sheet_relationship_id}");
406                sheet_relationships.add(Relationship {
407                    id: comments_id,
408                    rel_type: COMMENTS_RELATIONSHIP_TYPE.to_string(),
409                    target: format!("../comments{}.xml", index + 1),
410                    target_mode: TargetMode::Internal,
411                });
412                Some(id)
413            } else {
414                None
415            };
416
417            // the sheet's own drawing part (`xl/drawings/drawingN.xml`), if any. Its relationship
418            // id is resolved here (alongside the others above) so it can be embedded in `<drawing
419            // r:id="..">`, but the drawing part itself — and the image/chart parts it in turn
420            // references — is only built and added to the package further down, once this
421            // worksheet's own XML/part has been created, mirroring the existing
422            // table/legacy-drawing ordering.
423            let drawing_id = if sheet.drawing.is_some() {
424                // No `+= 1` here: this is the last id computed for this sheet's relationships in
425                // this loop iteration, so the incremented value would never be read before
426                // `next_sheet_relationship_id` gets reset to `1` at the top of the next iteration —
427                // `cargo build` correctly flagged the previous unconditional increment as dead.
428                let id = format!("rId{next_sheet_relationship_id}");
429                sheet_relationships.add(Relationship {
430                    id: id.clone(),
431                    rel_type: DRAWING_RELATIONSHIP_TYPE.to_string(),
432                    target: format!("../drawings/drawing{}.xml", index + 1),
433                    target_mode: TargetMode::Internal,
434                });
435                Some(id)
436            } else {
437                None
438            };
439
440            let relationship_ids = WorksheetRelationshipIds {
441                hyperlinks: hyperlink_ids,
442                table: table_id,
443                legacy_drawing: legacy_drawing_id,
444                drawing: drawing_id,
445            };
446
447            let worksheet_xml = to_worksheet_xml(
448                sheet,
449                &shared_strings,
450                &cell_formats,
451                &dxfs,
452                &relationship_ids,
453            )?;
454            let mut worksheet_part = Part::new(
455                format!("/xl/worksheets/sheet{}.xml", index + 1),
456                WORKSHEET_CONTENT_TYPE,
457                worksheet_xml,
458            );
459            worksheet_part.relationships = sheet_relationships;
460            package.add_part(worksheet_part);
461
462            if let Some(sheet_drawing) = &sheet.drawing {
463                let mut drawing_relationships = Relationships::new();
464                let drawing_xml = to_drawing_xml(
465                    sheet_drawing,
466                    &mut drawing_relationships,
467                    &mut next_media_index,
468                    &mut next_chart_index,
469                    &mut package,
470                )?;
471                let mut drawing_part = Part::new(
472                    format!("/xl/drawings/drawing{}.xml", index + 1),
473                    DRAWING_CONTENT_TYPE,
474                    drawing_xml,
475                );
476                drawing_part.relationships = drawing_relationships;
477                package.add_part(drawing_part);
478            }
479
480            if let Some(table) = &sheet.table {
481                package.add_part(Part::new(
482                    format!("/xl/tables/table{}.xml", index + 1),
483                    TABLE_CONTENT_TYPE,
484                    to_table_xml(table, index + 1)?,
485                ));
486            }
487
488            if !sheet.comments.is_empty() {
489                package.add_part(Part::new(
490                    format!("/xl/comments{}.xml", index + 1),
491                    COMMENTS_CONTENT_TYPE,
492                    to_comments_xml(&sheet.comments)?,
493                ));
494                package.add_part(Part::new(
495                    format!("/xl/drawings/vmlDrawing{}.vml", index + 1),
496                    VML_DRAWING_CONTENT_TYPE,
497                    to_comments_vml(&sheet.comments),
498                ));
499            }
500        }
501
502        package.add_part(Part::new(
503            STYLES_PART_NAME,
504            STYLES_CONTENT_TYPE,
505            to_styles_xml(
506                &cell_formats,
507                &dxfs,
508                &self.table_styles,
509                &self.named_cell_styles,
510            )?,
511        ));
512        package.add_part(Part::new(
513            SHARED_STRINGS_PART_NAME,
514            SHARED_STRINGS_CONTENT_TYPE,
515            to_shared_strings_xml(&shared_strings)?,
516        ));
517        package.add_part(Part::new(
518            CORE_PROPERTIES_PART_NAME,
519            CORE_PROPERTIES_CONTENT_TYPE,
520            to_core_properties_xml(&self.properties)?,
521        ));
522        package.add_part(Part::new(
523            EXTENDED_PROPERTIES_PART_NAME,
524            EXTENDED_PROPERTIES_CONTENT_TYPE,
525            to_extended_properties_xml(&self.properties)?,
526        ));
527        if !self.properties.custom_properties.is_empty() {
528            package.add_part(Part::new(
529                CUSTOM_PROPERTIES_PART_NAME,
530                CUSTOM_PROPERTIES_CONTENT_TYPE,
531                to_custom_properties_xml(&self.properties.custom_properties)?,
532            ));
533        }
534
535        Ok(package.write_to(writer)?)
536    }
537}
538
539/// One entry of a workbook's shared strings table — either a plain text value (`CellValue::Text`)
540/// or a rich-text value with per-run formatting (`CellValue::RichText`). Both share the same index
541/// space (a cell's `<v>` simply indexes into `xl/sharedStrings.xml`'s flat `<si>` list regardless
542/// of which form each entry takes), so this crate models them as one `Vec` of this enum rather than
543/// two separate collections.
544#[derive(Clone, PartialEq)]
545enum SharedStringEntry {
546    Plain(String),
547    Rich(Vec<RichTextRun>),
548}
549
550/// The distinct text/rich-text values used anywhere in a workbook's cells (`xl/sharedStrings.xml`,
551/// `CT_Sst`), collected in first-appearance order (matching real Excel output, which also assigns
552/// indices in the order strings are first encountered while saving) — `plain_indices` maps each
553/// distinct plain string to its assigned index for `write_cell` to look up (a `HashMap` fast path,
554/// since plain text is the overwhelmingly common case); a `CellValue::RichText`'s index is instead
555/// resolved by linear scan through `entries` (see `SharedStrings::index_of_rich`) — rich text is
556/// rare enough in practice that this isn't worth a second `HashMap` (`Vec<RichTextRun>` isn't
557/// `Hash` either, since `RichTextRun::font_size` is an `f64`). `total_count` is the total number of
558/// text/rich-text cells across the whole workbook, counting repeats (`CT_Sst/@count`, distinct from
559/// `@uniqueCount`, which is simply `entries.len()`).
560struct SharedStrings {
561    entries: Vec<SharedStringEntry>,
562    plain_indices: HashMap<String, usize>,
563    total_count: usize,
564}
565
566impl SharedStrings {
567    fn index_of_rich(&self, runs: &[RichTextRun]) -> Option<usize> {
568        self.entries.iter().position(
569            |entry| matches!(entry, SharedStringEntry::Rich(existing) if existing == runs),
570        )
571    }
572}
573
574fn collect_shared_strings(workbook: &Workbook) -> SharedStrings {
575    let mut entries = Vec::new();
576    let mut plain_indices = HashMap::new();
577    let mut total_count = 0usize;
578
579    for sheet in &workbook.sheets {
580        for row in &sheet.rows {
581            for cell in &row.cells {
582                match &cell.value {
583                    CellValue::Text(text) => {
584                        total_count += 1;
585                        if !plain_indices.contains_key(text) {
586                            plain_indices.insert(text.clone(), entries.len());
587                            entries.push(SharedStringEntry::Plain(text.clone()));
588                        }
589                    }
590                    CellValue::RichText(runs) => {
591                        total_count += 1;
592                        let already_present =
593                            entries.iter().any(|entry| matches!(entry, SharedStringEntry::Rich(existing) if existing == runs));
594                        if !already_present {
595                            entries.push(SharedStringEntry::Rich(runs.clone()));
596                        }
597                    }
598                    _ => {}
599                }
600            }
601        }
602    }
603
604    SharedStrings {
605        entries,
606        plain_indices,
607        total_count,
608    }
609}
610
611/// The distinct [`CellFormat`]s used anywhere in a workbook's cells, collected in first-appearance
612/// order — exactly like `collect_shared_strings`, but for cell styles rather than text. A cell's
613/// assigned `cellXfs` index is `1 + position_in_this_vec` (index 0 is always reserved for "no
614/// format", the default style) — see `write_cell`'s `style_index` lookup and `build_styles`, which
615/// turns this flat list into the `numFmts`/`fonts`/`fills`/`cellXfs` collections `xl/styles.xml`
616/// actually needs.
617fn collect_cell_formats(workbook: &Workbook) -> Vec<CellFormat> {
618    let mut formats: Vec<CellFormat> = Vec::new();
619
620    for sheet in &workbook.sheets {
621        for row in &sheet.rows {
622            if let Some(format) = &row.default_format {
623                if !formats.contains(format) {
624                    formats.push(format.clone());
625                }
626            }
627            for cell in &row.cells {
628                if let Some(format) = &cell.format {
629                    if !formats.contains(format) {
630                        formats.push(format.clone());
631                    }
632                }
633            }
634        }
635        // `<col style="n">` — point 49, same shared `cellXfs` index space as a cell's own `s`
636        // attribute.
637        for setting in &sheet.column_settings {
638            if let Some(format) = &setting.style {
639                if !formats.contains(format) {
640                    formats.push(format.clone());
641                }
642            }
643        }
644    }
645
646    formats
647}
648
649/// The distinct [`CellFormat`]s used as a [`ConditionalFormattingRule`]'s `format` anywhere in a
650/// workbook, collected in first-appearance order — same dedup principle as `collect_cell_formats`,
651/// but a separate index space: a rule's `dxfId` is its position in this `Vec` (no "index 0
652/// reserved" convention here, since every conditional formatting rule always carries a format —
653/// there's no "no format" case to reserve for). Also collects each [`TableStyleElement::format`]
654/// from `workbook.table_styles` into this same shared index space — point 50
655/// (`ExcelTable::table_style_name` referencing a [`TableStyle`], each of whose elements' `dxfId`
656/// resolves against this very `<dxfs>` collection, exactly like a conditional formatting rule's own
657/// `dxfId`).
658fn collect_dxfs(workbook: &Workbook) -> Vec<CellFormat> {
659    let mut dxfs: Vec<CellFormat> = Vec::new();
660
661    for sheet in &workbook.sheets {
662        for rule in &sheet.conditional_formatting_rules {
663            if !dxfs.contains(&rule.format) {
664                dxfs.push(rule.format.clone());
665            }
666        }
667    }
668
669    for table_style in &workbook.table_styles {
670        for element in &table_style.elements {
671            if !dxfs.contains(&element.format) {
672                dxfs.push(element.format.clone());
673            }
674        }
675    }
676
677    dxfs
678}
679
680/// Serializes `xl/workbook.xml`'s `<sheets>` list — one `<sheet>` per entry in `workbook.sheets`,
681/// in order, with a 1-based `sheetId` (this crate's own convention, not schema-mandated —
682/// `ST_SheetId` just needs to be unique, ascending-from-1 is simplest) and the matching `r:id` from
683/// `sheet_relationship_ids` (resolved against `xl/_rels/workbook.xml.rels` by the caller).
684fn to_workbook_xml(
685    workbook: &Workbook,
686    sheet_relationship_ids: &[String],
687    defined_names: &[DefinedName],
688    external_link_relationship_ids: &[String],
689) -> Result<Vec<u8>> {
690    let mut writer = Writer::new(Vec::new());
691
692    writer.write_event(Event::Decl(BytesDecl::new(
693        "1.0",
694        Some("UTF-8"),
695        Some("yes"),
696    )))?;
697
698    let mut root = BytesStart::new("workbook");
699    root.push_attribute(("xmlns", SPREADSHEETML_NAMESPACE));
700    root.push_attribute(("xmlns:r", RELATIONSHIPS_NAMESPACE));
701    writer.write_event(Event::Start(root))?;
702
703    // `CT_Workbook`'s sequence: `fileVersion?`, `fileSharing?`, `workbookPr?`
704    // (`fileVersion`/`workbookPr` not written by this crate), `workbookProtection?`, `bookViews?`,
705    // `sheets` — so write-protection, structure protection, and the active-tab view all come
706    // *before* `<sheets>`, and (`fileSharing`, which comes first of the three).
707    if let Some(write_protection) = &workbook.write_protection {
708        write_file_sharing(&mut writer, write_protection)?;
709    }
710
711    if let Some(protection) = &workbook.protection {
712        write_workbook_protection(&mut writer, protection)?;
713    }
714
715    if workbook.active_tab.is_some() {
716        writer.write_event(Event::Start(BytesStart::new("bookViews")))?;
717        let mut workbook_view = BytesStart::new("workbookView");
718        if let Some(active_tab) = workbook.active_tab {
719            let active_tab_text = active_tab.to_string();
720            workbook_view.push_attribute(("activeTab", active_tab_text.as_str()));
721        }
722        writer.write_event(Event::Empty(workbook_view))?;
723        writer.write_event(Event::End(BytesEnd::new("bookViews")))?;
724    }
725
726    writer.write_event(Event::Start(BytesStart::new("sheets")))?;
727    for (index, sheet) in workbook.sheets.iter().enumerate() {
728        let sheet_id = (index + 1).to_string();
729        let mut sheet_start = BytesStart::new("sheet");
730        sheet_start.push_attribute(("name", sheet.name.as_str()));
731        sheet_start.push_attribute(("sheetId", sheet_id.as_str()));
732        // `state="hidden"/"veryHidden"` — extended to the three-state form.
733        match sheet.visibility {
734            SheetVisibility::Visible => {}
735            SheetVisibility::Hidden => sheet_start.push_attribute(("state", "hidden")),
736            SheetVisibility::VeryHidden => sheet_start.push_attribute(("state", "veryHidden")),
737        }
738        sheet_start.push_attribute(("r:id", sheet_relationship_ids[index].as_str()));
739        writer.write_event(Event::Empty(sheet_start))?;
740    }
741    writer.write_event(Event::End(BytesEnd::new("sheets")))?;
742
743    // `CT_Workbook`'s sequence puts `externalReferences` right after `sheets` (and
744    // `functionGroups`, not written by this crate).
745    if !workbook.external_links.is_empty() {
746        writer.write_event(Event::Start(BytesStart::new("externalReferences")))?;
747        for relationship_id in external_link_relationship_ids {
748            let mut external_reference = BytesStart::new("externalReference");
749            external_reference.push_attribute(("r:id", relationship_id.as_str()));
750            writer.write_event(Event::Empty(external_reference))?;
751        }
752        writer.write_event(Event::End(BytesEnd::new("externalReferences")))?;
753    }
754
755    // `definedNames` follows `externalReferences` and comes before `calcPr` (not written either) —
756    // so in this crate's own output, `<definedNames>` is simply the last child of `<workbook>` when
757    // present. `defined_names` here is the caller's own `workbook.defined_names` plus any
758    // `_xlnm.Print_Area`/`_xlnm.Print_Titles` names synthesized from each sheet's `print_settings`
759    // (see `synthesized_print_defined_names`) — merged together before this function is ever
760    // called.
761    if !defined_names.is_empty() {
762        write_defined_names(&mut writer, defined_names)?;
763    }
764
765    // `calcPr` is `CT_Workbook`'s very last modeled child.
766    if let Some(calculation) = &workbook.calculation {
767        write_calculation_properties(&mut writer, calculation)?;
768    }
769
770    writer.write_event(Event::End(BytesEnd::new("workbook")))?;
771
772    Ok(writer.into_inner())
773}
774
775/// Serializes `<calcPr>` (`CT_CalcPr`) from a workbook's [`CalculationProperties`].
776fn write_calculation_properties<W: Write>(
777    writer: &mut Writer<W>,
778    calculation: &CalculationProperties,
779) -> Result<()> {
780    let mut start = BytesStart::new("calcPr");
781    if let Some(mode) = calculation.mode {
782        start.push_attribute(("calcMode", calculation_mode_str(mode)));
783    }
784    if calculation.iterate {
785        start.push_attribute(("iterate", "1"));
786    }
787    writer.write_event(Event::Empty(start))?;
788    Ok(())
789}
790
791fn calculation_mode_str(mode: CalculationMode) -> &'static str {
792    match mode {
793        CalculationMode::Automatic => "auto",
794        CalculationMode::AutomaticExceptTables => "autoNoTable",
795        CalculationMode::Manual => "manual",
796    }
797}
798
799/// Serializes `<workbookProtection>` (`CT_WorkbookProtection`) from a [`WorkbookProtection`]. Uses
800/// the same legacy password hash as `write_sheet_protection`, see `legacy_password_hash`'s doc
801/// comment.
802fn write_workbook_protection<W: Write>(
803    writer: &mut Writer<W>,
804    protection: &WorkbookProtection,
805) -> Result<()> {
806    let password_text = protection.password.as_deref().map(legacy_password_hash_hex);
807    let mut start = BytesStart::new("workbookProtection");
808    if protection.lock_structure {
809        start.push_attribute(("lockStructure", "1"));
810    }
811    if protection.lock_windows {
812        start.push_attribute(("lockWindows", "1"));
813    }
814    if let Some(password_text) = &password_text {
815        start.push_attribute(("workbookPassword", password_text.as_str()));
816    }
817    writer.write_event(Event::Empty(start))?;
818    Ok(())
819}
820
821/// Serializes `<fileSharing>` (`CT_FileSharing`) from a [`WriteProtection`]. Uses the same legacy
822/// password hash as `write_workbook_protection`/`write_sheet_protection`, see
823/// `legacy_password_hash`'s doc comment.
824fn write_file_sharing<W: Write>(
825    writer: &mut Writer<W>,
826    write_protection: &WriteProtection,
827) -> Result<()> {
828    let password_text = write_protection
829        .password
830        .as_deref()
831        .map(legacy_password_hash_hex);
832    let mut start = BytesStart::new("fileSharing");
833    if write_protection.read_only_recommended {
834        start.push_attribute(("readOnlyRecommended", "1"));
835    }
836    if let Some(user_name) = &write_protection.user_name {
837        start.push_attribute(("userName", user_name.as_str()));
838    }
839    if let Some(password_text) = &password_text {
840        start.push_attribute(("reservationPassword", password_text.as_str()));
841    }
842    writer.write_event(Event::Empty(start))?;
843    Ok(())
844}
845
846/// Synthesizes `_xlnm.Print_Area`/`_xlnm.Print_Titles` [`DefinedName`]s from every sheet's
847/// [`PrintSettings`] — see `PrintSettings`'s doc comment for why these are workbook-level defined
848/// names rather than a worksheet XML attribute.
849fn synthesized_print_defined_names(workbook: &Workbook) -> Vec<DefinedName> {
850    let mut names = Vec::new();
851
852    for (index, sheet) in workbook.sheets.iter().enumerate() {
853        if let Some(print_area) = &sheet.print_settings.print_area {
854            let refers_to = qualify_sheet_reference(&sheet.name, print_area);
855            names.push(
856                DefinedName::new("_xlnm.Print_Area", refers_to)
857                    .with_local_sheet_id(index)
858                    .with_hidden(true),
859            );
860        }
861
862        let repeat_rows = sheet.print_settings.repeat_rows.as_deref();
863        let repeat_columns = sheet.print_settings.repeat_columns.as_deref();
864        if repeat_rows.is_some() || repeat_columns.is_some() {
865            let mut parts = Vec::new();
866            if let Some(columns) = repeat_columns {
867                parts.push(qualify_sheet_reference(&sheet.name, columns));
868            }
869            if let Some(rows) = repeat_rows {
870                parts.push(qualify_sheet_reference(&sheet.name, rows));
871            }
872            names.push(
873                DefinedName::new("_xlnm.Print_Titles", parts.join(","))
874                    .with_local_sheet_id(index)
875                    .with_hidden(true),
876            );
877        }
878    }
879
880    names
881}
882
883/// Prefixes a range reference with its sheet name (e.g. `"Feuil1"` + `"$A$1:$F$30"` →
884/// `"Feuil1!$A$1:$F$30"`) — the shape `_xlnm.Print_Area`/ `_xlnm.Print_Titles` need, since (unlike
885/// a plain `DefinedName` created through `DefinedName::new`)
886/// `PrintSettings.print_area`/`repeat_rows`/ `repeat_columns` are just the bare range, not the
887/// caller's responsibility to already sheet-qualify. A sheet name containing a space or one of
888/// SpreadsheetML's reserved characters would need wrapping in single quotes for the result to be a
889/// valid reference — not handled here (out of scope, same posture as `Sheet.name`'s own doc comment
890/// about not validating Excel's naming rules).
891fn qualify_sheet_reference(sheet_name: &str, range: &str) -> String {
892    format!("{sheet_name}!{range}")
893}
894
895/// Serializes `<definedNames>` (`CT_DefinedNames`) from a workbook's [`DefinedName`]s. Unlike a
896/// conditional formatting rule's `<formula>` or a data validation rule's `<formula1>`, a
897/// `<definedName>` has no dedicated child element for its reference/formula — `CT_DefinedName` has
898/// simple content (`ST_Formula`), so the reference text is written directly as the element's own
899/// text.
900fn write_defined_names<W: Write>(
901    writer: &mut Writer<W>,
902    defined_names: &[DefinedName],
903) -> Result<()> {
904    writer.write_event(Event::Start(BytesStart::new("definedNames")))?;
905
906    for defined_name in defined_names {
907        let mut start = BytesStart::new("definedName");
908        start.push_attribute(("name", defined_name.name.as_str()));
909        let local_sheet_id_text = defined_name.local_sheet_id.map(|id| id.to_string());
910        if let Some(local_sheet_id_text) = &local_sheet_id_text {
911            start.push_attribute(("localSheetId", local_sheet_id_text.as_str()));
912        }
913        if defined_name.hidden {
914            start.push_attribute(("hidden", "1"));
915        }
916        if let Some(comment) = &defined_name.comment {
917            start.push_attribute(("comment", comment.as_str()));
918        }
919        writer.write_event(Event::Start(start))?;
920        writer.write_event(Event::Text(BytesText::new(&defined_name.refers_to)))?;
921        writer.write_event(Event::End(BytesEnd::new("definedName")))?;
922    }
923
924    writer.write_event(Event::End(BytesEnd::new("definedNames")))?;
925
926    Ok(())
927}
928
929/// Per-worksheet relationship ids resolved by `Workbook::write_to` before calling
930/// `to_worksheet_xml` — hyperlinks/tables/legacy-comment-drawings each need an `r:id` embedded in
931/// the worksheet's own XML that must match exactly the id used in that worksheet's own `.rels` part
932/// (built alongside, see `write_to`'s per-sheet loop), so both have to be resolved together before
933/// either is written.
934struct WorksheetRelationshipIds {
935    /// One entry per `Sheet.hyperlinks`, in order — `Some(rId)` for an external hyperlink (resolved
936    /// against this worksheet's `.rels`), `None` for an internal one (no relationship needed, see
937    /// `HyperlinkTarget::Internal`).
938    hyperlinks: Vec<Option<String>>,
939    /// The relationship id for this sheet's `xl/tables/tableN.xml`, if any.
940    table: Option<String>,
941    /// The relationship id for this sheet's legacy comment VML drawing, if any (`<legacyDrawing
942    /// r:id="..">`).
943    legacy_drawing: Option<String>,
944    /// The relationship id for this sheet's DrawingML drawing part (embedded pictures/charts), if
945    /// any (`<drawing r:id="..">`). Written *before* `legacy_drawing` in `to_worksheet_xml`,
946    /// matching `CT_Worksheet`'s own schema sequence (`drawing?` comes before `legacyDrawing?`).
947    drawing: Option<String>,
948}
949
950/// Serializes one sheet's `xl/worksheets/sheetN.xml` (`CT_Worksheet`). Only the child elements this
951/// crate actually models are written, always in `CT_Worksheet`'s own schema sequence (per
952/// ECMA-376/datypic.com): `sheetPr?`, `dimension`, `sheetViews?`, `cols?`, `sheetData`,
953/// `sheetProtection?`, `protectedRanges?`, `autoFilter?`, `mergeCells?`, `conditionalFormatting*`,
954/// `dataValidations?`, `hyperlinks?`, `pageSetup?`, `headerFooter?`, `ignoredErrors?`,
955/// `legacyDrawing?`, `tableParts?` — everything else (`sheetCalcPr`, `dataConsolidate`,
956/// `customSheetViews`, `phoneticPr`, `printOptions`, `pageMargins`, `drawing`..) is skipped, all
957/// `minOccurs="0"` and either not modeled or not needed for a file that still opens cleanly (Excel
958/// fills in its own defaults for anything omitted). `dimension` is the one exception written
959/// unconditionally (even for an empty sheet, as `"A1"`) despite being schema-optional: every real
960/// Excel- generated file always includes it, and its absence is what caused real Excel to reject
961/// and strip a structured table on open — note `dimension` comes *after* `sheetPr`, not before (an
962/// ordering bug in this fix's first version, also found and fixed during that same investigation).
963/// A row's number and a cell's column letter are derived from their position (see
964/// `Sheet.rows`/`Row.cells`'s own doc comments), not stored on the model.
965fn to_worksheet_xml(
966    sheet: &Sheet,
967    shared_strings: &SharedStrings,
968    cell_formats: &[CellFormat],
969    dxfs: &[CellFormat],
970    relationship_ids: &WorksheetRelationshipIds,
971) -> Result<Vec<u8>> {
972    let mut writer = Writer::new(Vec::new());
973
974    writer.write_event(Event::Decl(BytesDecl::new(
975        "1.0",
976        Some("UTF-8"),
977        Some("yes"),
978    )))?;
979
980    let mut root = BytesStart::new("worksheet");
981    root.push_attribute(("xmlns", SPREADSHEETML_NAMESPACE));
982    root.push_attribute(("xmlns:r", RELATIONSHIPS_NAMESPACE));
983    writer.write_event(Event::Start(root))?;
984
985    // `CT_SheetPr`'s sequence is `tabColor?`, `outlinePr?`, `pageSetUpPr?` — (`pageSetUpPr`),
986    // extended for `tabColor` and for `outlinePr`. `sheetPr` comes *before* `dimension` in
987    // `CT_Worksheet`'s own schema sequence — writing `dimension` first is schema-invalid and is
988    // what caused real Excel to reject any sheet that *also* writes a `sheetPr` (tab color and/or
989    // fit-to-page), even though sheets with neither happened to still open fine (nothing came
990    // before `dimension` for them, so the swap was invisible) — confirmed via bisection down to
991    // sheet 38 alone (tab color only, no table) still failing after the table-specific fixes.
992    let fits_to_page =
993        sheet.print_settings.fit_to_width.is_some() || sheet.print_settings.fit_to_height.is_some();
994    let has_outline_pr =
995        sheet.outline_summary_below.is_some() || sheet.outline_summary_right.is_some();
996    if fits_to_page || sheet.tab_color.is_some() || has_outline_pr {
997        writer.write_event(Event::Start(BytesStart::new("sheetPr")))?;
998        if let Some(tab_color) = &sheet.tab_color {
999            let mut tab_color_start = BytesStart::new("tabColor");
1000            tab_color_start.push_attribute(("rgb", tab_color.as_str()));
1001            writer.write_event(Event::Empty(tab_color_start))?;
1002        }
1003        if has_outline_pr {
1004            let mut outline_pr = BytesStart::new("outlinePr");
1005            if let Some(summary_below) = sheet.outline_summary_below {
1006                outline_pr.push_attribute(("summaryBelow", if summary_below { "1" } else { "0" }));
1007            }
1008            if let Some(summary_right) = sheet.outline_summary_right {
1009                outline_pr.push_attribute(("summaryRight", if summary_right { "1" } else { "0" }));
1010            }
1011            writer.write_event(Event::Empty(outline_pr))?;
1012        }
1013        if fits_to_page {
1014            let mut page_setup_pr = BytesStart::new("pageSetUpPr");
1015            page_setup_pr.push_attribute(("fitToPage", "1"));
1016            writer.write_event(Event::Empty(page_setup_pr))?;
1017        }
1018        writer.write_event(Event::End(BytesEnd::new("sheetPr")))?;
1019    }
1020
1021    let mut dimension = BytesStart::new("dimension");
1022    let dimension_ref = compute_dimension_ref(sheet);
1023    dimension.push_attribute(("ref", dimension_ref.as_str()));
1024    writer.write_event(Event::Empty(dimension))?;
1025
1026    // (freeze panes)/29 (zoom), extended for `showGridLines`/
1027    // `showRowColHeaders`/`showZeros`/`rightToLeft` and `<selection>`.
1028    if sheet.freeze_panes.is_some()
1029        || sheet.zoom_scale.is_some()
1030        || sheet.show_grid_lines.is_some()
1031        || sheet.show_row_col_headers.is_some()
1032        || sheet.show_zeros.is_some()
1033        || sheet.right_to_left.is_some()
1034        || sheet.active_cell.is_some()
1035    {
1036        write_sheet_views(&mut writer, sheet)?;
1037    }
1038
1039    // `CT_Worksheet`'s sequence puts `sheetFormatPr` right after `sheetViews`, before `cols`.
1040    // `defaultRowHeight` is written whenever `sheetFormatPr` is written at all (a fixed `15`
1041    // fallback when only `default_column_width` is set), matching real Excel's own always-present
1042    // convention for this one attribute — see `Sheet.default_row_height`'s doc comment.
1043    if sheet.default_column_width.is_some() || sheet.default_row_height.is_some() {
1044        let mut sheet_format_pr = BytesStart::new("sheetFormatPr");
1045        if let Some(width) = sheet.default_column_width {
1046            let width_text = width.to_string();
1047            sheet_format_pr.push_attribute(("defaultColWidth", width_text.as_str()));
1048        }
1049        let row_height_text = sheet.default_row_height.unwrap_or(15.0).to_string();
1050        sheet_format_pr.push_attribute(("defaultRowHeight", row_height_text.as_str()));
1051        writer.write_event(Event::Empty(sheet_format_pr))?;
1052    }
1053
1054    if !sheet.column_settings.is_empty() {
1055        write_cols(&mut writer, &sheet.column_settings, cell_formats)?;
1056    }
1057
1058    writer.write_event(Event::Start(BytesStart::new("sheetData")))?;
1059    for (row_index, row) in sheet.rows.iter().enumerate() {
1060        let row_number = row_index + 1;
1061        let row_number_text = row_number.to_string();
1062        let mut row_start = BytesStart::new("row");
1063        row_start.push_attribute(("r", row_number_text.as_str()));
1064        let height_text = row.height.map(|height| height.to_string());
1065        if let Some(height_text) = &height_text {
1066            row_start.push_attribute(("ht", height_text.as_str()));
1067            row_start.push_attribute(("customHeight", "1"));
1068        }
1069        if row.hidden {
1070            row_start.push_attribute(("hidden", "1"));
1071        }
1072        if row.outline_level != 0 {
1073            let outline_level_text = row.outline_level.to_string();
1074            row_start.push_attribute(("outlineLevel", outline_level_text.as_str()));
1075        }
1076        if row.collapsed {
1077            row_start.push_attribute(("collapsed", "1"));
1078        }
1079        // `<row s="n" customFormat="1">`, same shared `cellXfs` index space as a cell's own `s`
1080        // attribute. Always paired with `customFormat="1"` when written (see `Row.default_format`'s
1081        // doc comment).
1082        let row_style_text =
1083            style_index(&row.default_format, cell_formats).map(|index| index.to_string());
1084        if let Some(row_style_text) = &row_style_text {
1085            row_start.push_attribute(("s", row_style_text.as_str()));
1086            row_start.push_attribute(("customFormat", "1"));
1087        }
1088        writer.write_event(Event::Start(row_start))?;
1089
1090        for (column_index, cell) in row.cells.iter().enumerate() {
1091            write_cell(
1092                &mut writer,
1093                column_index,
1094                row_number,
1095                cell,
1096                shared_strings,
1097                cell_formats,
1098            )?;
1099        }
1100
1101        writer.write_event(Event::End(BytesEnd::new("row")))?;
1102    }
1103    writer.write_event(Event::End(BytesEnd::new("sheetData")))?;
1104
1105    if let Some(protection) = &sheet.protection {
1106        write_sheet_protection(&mut writer, protection)?;
1107    }
1108
1109    // `CT_Worksheet`'s sequence puts `protectedRanges` right after `sheetProtection` and before
1110    // `scenarios` — point 51 (confirmed via `sml.xsd`, previously not written by this crate at
1111    // all).
1112    if !sheet.protected_ranges.is_empty() {
1113        write_protected_ranges(&mut writer, &sheet.protected_ranges)?;
1114    }
1115
1116    // `CT_Worksheet`'s sequence puts `scenarios` right after `sheetProtection`/`protectedRanges`
1117    // and before `autoFilter`.
1118    if !sheet.scenarios.is_empty() {
1119        write_scenarios(&mut writer, &sheet.scenarios)?;
1120    }
1121
1122    if let Some(range) = &sheet.auto_filter_range {
1123        // `CT_AutoFilter`'s own sequence puts `sortState` right after `filterColumn*`, nested
1124        // *inside* `<autoFilter>` (per `sml.xsd`) — distinct from a table's own `<sortState>`,
1125        // which is a sibling of `<autoFilter>` (see `to_table_xml`).
1126        if sheet.auto_filter_columns.is_empty() && sheet.sort_state.is_none() {
1127            let mut auto_filter = BytesStart::new("autoFilter");
1128            auto_filter.push_attribute(("ref", range.as_str()));
1129            writer.write_event(Event::Empty(auto_filter))?;
1130        } else {
1131            let mut auto_filter = BytesStart::new("autoFilter");
1132            auto_filter.push_attribute(("ref", range.as_str()));
1133            writer.write_event(Event::Start(auto_filter))?;
1134            write_filter_columns(&mut writer, &sheet.auto_filter_columns)?;
1135            if let Some(sort_state) = &sheet.sort_state {
1136                write_sort_state(&mut writer, sort_state)?;
1137            }
1138            writer.write_event(Event::End(BytesEnd::new("autoFilter")))?;
1139        }
1140    }
1141
1142    if !sheet.merged_ranges.is_empty() {
1143        let count_text = sheet.merged_ranges.len().to_string();
1144        let mut merge_cells_start = BytesStart::new("mergeCells");
1145        merge_cells_start.push_attribute(("count", count_text.as_str()));
1146        writer.write_event(Event::Start(merge_cells_start))?;
1147        for range in &sheet.merged_ranges {
1148            let mut merge_cell = BytesStart::new("mergeCell");
1149            merge_cell.push_attribute(("ref", range.as_str()));
1150            writer.write_event(Event::Empty(merge_cell))?;
1151        }
1152        writer.write_event(Event::End(BytesEnd::new("mergeCells")))?;
1153    }
1154
1155    for (index, rule) in sheet.conditional_formatting_rules.iter().enumerate() {
1156        write_conditional_formatting(&mut writer, index, rule, dxfs)?;
1157    }
1158    if !sheet.data_validation_rules.is_empty() {
1159        write_data_validations(&mut writer, &sheet.data_validation_rules)?;
1160    }
1161
1162    if !sheet.hyperlinks.is_empty() {
1163        write_hyperlinks(&mut writer, &sheet.hyperlinks, &relationship_ids.hyperlinks)?;
1164    }
1165
1166    if sheet.print_settings.orientation.is_some()
1167        || sheet.print_settings.fit_to_width.is_some()
1168        || sheet.print_settings.fit_to_height.is_some()
1169        || sheet.print_settings.scale.is_some()
1170    {
1171        write_page_setup(&mut writer, &sheet.print_settings)?;
1172    }
1173
1174    if sheet.print_settings.header.is_some()
1175        || sheet.print_settings.footer.is_some()
1176        || sheet.print_settings.header_first.is_some()
1177        || sheet.print_settings.footer_first.is_some()
1178        || sheet.print_settings.header_even.is_some()
1179        || sheet.print_settings.footer_even.is_some()
1180    {
1181        write_header_footer(&mut writer, &sheet.print_settings)?;
1182    }
1183
1184    // `CT_Worksheet`'s sequence puts `rowBreaks`/`colBreaks` right after `headerFooter`.
1185    if !sheet.row_breaks.is_empty() {
1186        write_page_breaks(&mut writer, "rowBreaks", &sheet.row_breaks, 16383)?;
1187    }
1188    if !sheet.column_breaks.is_empty() {
1189        write_page_breaks(&mut writer, "colBreaks", &sheet.column_breaks, 1048575)?;
1190    }
1191
1192    // `CT_Worksheet`'s sequence puts `ignoredErrors` right after `cellWatches` (not modeled by this
1193    // crate) and before `smartTags`/ `drawing`/`legacyDrawing`/`tableParts`.
1194    if !sheet.ignored_errors.is_empty() {
1195        write_ignored_errors(&mut writer, &sheet.ignored_errors)?;
1196    }
1197
1198    // `CT_Worksheet`'s sequence puts `drawing?` immediately before `legacyDrawing?`
1199    if let Some(drawing_id) = &relationship_ids.drawing {
1200        let mut drawing_start = BytesStart::new("drawing");
1201        drawing_start.push_attribute(("r:id", drawing_id.as_str()));
1202        writer.write_event(Event::Empty(drawing_start))?;
1203    }
1204
1205    if let Some(legacy_drawing_id) = &relationship_ids.legacy_drawing {
1206        let mut legacy_drawing = BytesStart::new("legacyDrawing");
1207        legacy_drawing.push_attribute(("r:id", legacy_drawing_id.as_str()));
1208        writer.write_event(Event::Empty(legacy_drawing))?;
1209    }
1210
1211    if let (Some(_table), Some(table_id)) = (&sheet.table, &relationship_ids.table) {
1212        // `count="1"` — this crate only ever writes at most one table per sheet, see
1213        // `Sheet.table`'s doc comment.
1214        let mut table_parts_start = BytesStart::new("tableParts");
1215        table_parts_start.push_attribute(("count", "1"));
1216        writer.write_event(Event::Start(table_parts_start))?;
1217        let mut table_part = BytesStart::new("tablePart");
1218        table_part.push_attribute(("r:id", table_id.as_str()));
1219        writer.write_event(Event::Empty(table_part))?;
1220        writer.write_event(Event::End(BytesEnd::new("tableParts")))?;
1221    }
1222
1223    writer.write_event(Event::End(BytesEnd::new("worksheet")))?;
1224
1225    Ok(writer.into_inner())
1226}
1227
1228/// Serializes `<sheetViews><sheetView.><pane./><selection./></sheetView></sheetViews>` from a
1229/// sheet's view-related settings (freeze panes), extended for `zoomScale` and for
1230/// `showGridLines`/`showRowColHeaders`/`showZeros`/`rightToLeft` and `<selection>`. `activePane`
1231/// follows real Excel's own convention: `"bottomRight"` when both rows and columns are frozen,
1232/// `"topRight"` for columns only, `"bottomLeft"` for rows only (confirmed via `CT_Pane`'s
1233/// `ST_PaneType`/`ST_Pane` semantics — the active pane is always the one below/right of the
1234/// freeze). `topLeftCell` is the first visible (scrollable) cell, one row/column past the frozen
1235/// region. `CT_SheetView`'s child sequence is `pane?`, `selection*`. — `<selection>` always comes
1236/// after `<pane>` when both are present.
1237fn write_sheet_views<W: Write>(writer: &mut Writer<W>, sheet: &Sheet) -> Result<()> {
1238    writer.write_event(Event::Start(BytesStart::new("sheetViews")))?;
1239    let mut sheet_view = BytesStart::new("sheetView");
1240    if let Some(show_grid_lines) = sheet.show_grid_lines {
1241        sheet_view.push_attribute(("showGridLines", if show_grid_lines { "1" } else { "0" }));
1242    }
1243    if let Some(show_row_col_headers) = sheet.show_row_col_headers {
1244        sheet_view.push_attribute((
1245            "showRowColHeaders",
1246            if show_row_col_headers { "1" } else { "0" },
1247        ));
1248    }
1249    if let Some(show_zeros) = sheet.show_zeros {
1250        sheet_view.push_attribute(("showZeros", if show_zeros { "1" } else { "0" }));
1251    }
1252    if let Some(right_to_left) = sheet.right_to_left {
1253        sheet_view.push_attribute(("rightToLeft", if right_to_left { "1" } else { "0" }));
1254    }
1255    let zoom_scale_text = sheet.zoom_scale.map(|value| value.to_string());
1256    if let Some(zoom_scale_text) = &zoom_scale_text {
1257        sheet_view.push_attribute(("zoomScale", zoom_scale_text.as_str()));
1258    }
1259    sheet_view.push_attribute(("workbookViewId", "0"));
1260
1261    if sheet.freeze_panes.is_none() && sheet.active_cell.is_none() {
1262        writer.write_event(Event::Empty(sheet_view))?;
1263        writer.write_event(Event::End(BytesEnd::new("sheetViews")))?;
1264        return Ok(());
1265    }
1266    writer.write_event(Event::Start(sheet_view))?;
1267
1268    if let Some(freeze_panes) = &sheet.freeze_panes {
1269        let x_split_text = freeze_panes.frozen_columns.to_string();
1270        let y_split_text = freeze_panes.frozen_rows.to_string();
1271        let top_left_cell =
1272            cell_reference(freeze_panes.frozen_columns, freeze_panes.frozen_rows + 1);
1273        let active_pane = match (
1274            freeze_panes.frozen_columns > 0,
1275            freeze_panes.frozen_rows > 0,
1276        ) {
1277            (true, true) => "bottomRight",
1278            (true, false) => "topRight",
1279            (false, true) => "bottomLeft",
1280            (false, false) => "topLeft",
1281        };
1282
1283        let mut pane = BytesStart::new("pane");
1284        if freeze_panes.frozen_columns > 0 {
1285            pane.push_attribute(("xSplit", x_split_text.as_str()));
1286        }
1287        if freeze_panes.frozen_rows > 0 {
1288            pane.push_attribute(("ySplit", y_split_text.as_str()));
1289        }
1290        pane.push_attribute(("topLeftCell", top_left_cell.as_str()));
1291        pane.push_attribute(("activePane", active_pane));
1292        pane.push_attribute(("state", "frozen"));
1293        writer.write_event(Event::Empty(pane))?;
1294    }
1295
1296    if let Some(active_cell) = &sheet.active_cell {
1297        let mut selection = BytesStart::new("selection");
1298        selection.push_attribute(("activeCell", active_cell.as_str()));
1299        selection.push_attribute(("sqref", active_cell.as_str()));
1300        writer.write_event(Event::Empty(selection))?;
1301    }
1302
1303    writer.write_event(Event::End(BytesEnd::new("sheetView")))?;
1304    writer.write_event(Event::End(BytesEnd::new("sheetViews")))?;
1305    Ok(())
1306}
1307
1308/// Serializes `<cols><col../></cols>` from a sheet's [`ColumnSetting`]s. Each setting is written as
1309/// its own `<col min="N" max="N"../>` (see `Sheet.column_settings`'s doc comment for why this crate
1310/// doesn't bother collapsing adjacent columns into a shared range).
1311fn write_cols<W: Write>(
1312    writer: &mut Writer<W>,
1313    column_settings: &[ColumnSetting],
1314    cell_formats: &[CellFormat],
1315) -> Result<()> {
1316    writer.write_event(Event::Start(BytesStart::new("cols")))?;
1317    for setting in column_settings {
1318        let index_text = (setting.column + 1).to_string();
1319        let width_text = setting.width.map(|width| width.to_string());
1320        // `<col style="n">`, same shared `cellXfs` index space as a cell's own `s` attribute (see
1321        // `style_index`).
1322        let style_text = style_index(&setting.style, cell_formats).map(|index| index.to_string());
1323
1324        let mut col = BytesStart::new("col");
1325        col.push_attribute(("min", index_text.as_str()));
1326        col.push_attribute(("max", index_text.as_str()));
1327        if let Some(width_text) = &width_text {
1328            col.push_attribute(("width", width_text.as_str()));
1329            col.push_attribute(("customWidth", "1"));
1330        }
1331        if let Some(style_text) = &style_text {
1332            col.push_attribute(("style", style_text.as_str()));
1333        }
1334        if setting.hidden {
1335            col.push_attribute(("hidden", "1"));
1336        }
1337        if setting.outline_level != 0 {
1338            let outline_level_text = setting.outline_level.to_string();
1339            col.push_attribute(("outlineLevel", outline_level_text.as_str()));
1340        }
1341        if setting.collapsed {
1342            col.push_attribute(("collapsed", "1"));
1343        }
1344        writer.write_event(Event::Empty(col))?;
1345    }
1346    writer.write_event(Event::End(BytesEnd::new("cols")))?;
1347    Ok(())
1348}
1349
1350/// Serializes `<sheetProtection>` from a [`SheetProtection`]. See `SheetProtection`'s doc comment
1351/// for why only `sheet`/`password` are written.
1352fn write_sheet_protection<W: Write>(
1353    writer: &mut Writer<W>,
1354    protection: &SheetProtection,
1355) -> Result<()> {
1356    let password_text = protection.password.as_deref().map(legacy_password_hash_hex);
1357    let mut start = BytesStart::new("sheetProtection");
1358    start.push_attribute(("sheet", "1"));
1359    if let Some(password_text) = &password_text {
1360        start.push_attribute(("password", password_text.as_str()));
1361    }
1362    writer.write_event(Event::Empty(start))?;
1363    Ok(())
1364}
1365
1366/// Serializes `<protectedRanges>`/`<protectedRange>` (`CT_ProtectedRanges`/ `CT_ProtectedRange`)
1367/// from a sheet's [`ProtectedRange`]s. Same legacy 16-bit password hash as
1368/// `write_sheet_protection`.
1369fn write_protected_ranges<W: Write>(
1370    writer: &mut Writer<W>,
1371    protected_ranges: &[ProtectedRange],
1372) -> Result<()> {
1373    writer.write_event(Event::Start(BytesStart::new("protectedRanges")))?;
1374    for range in protected_ranges {
1375        let password_text = range.password.as_deref().map(legacy_password_hash_hex);
1376        let mut start = BytesStart::new("protectedRange");
1377        start.push_attribute(("name", range.name.as_str()));
1378        start.push_attribute(("sqref", range.range.as_str()));
1379        if let Some(password_text) = &password_text {
1380            start.push_attribute(("password", password_text.as_str()));
1381        }
1382        writer.write_event(Event::Empty(start))?;
1383    }
1384    writer.write_event(Event::End(BytesEnd::new("protectedRanges")))?;
1385    Ok(())
1386}
1387
1388/// Serializes `<scenarios>`/`<scenario>`/`<inputCells>` (`CT_Scenarios`/
1389/// `CT_Scenario`/`CT_InputCells`) from a sheet's [`Scenario`]s. Every scenario is written
1390/// `locked="1"` (Excel's own default for a newly-created scenario) with no scenario marked
1391/// `current`/`show` (both `CT_Scenarios` attributes, 0-based indices into the list — left unset
1392/// since this crate has no notion of "the currently applied scenario").
1393fn write_scenarios<W: Write>(writer: &mut Writer<W>, scenarios: &[Scenario]) -> Result<()> {
1394    writer.write_event(Event::Start(BytesStart::new("scenarios")))?;
1395    for scenario in scenarios {
1396        let count_text = scenario.inputs.len().to_string();
1397        let mut scenario_start = BytesStart::new("scenario");
1398        scenario_start.push_attribute(("name", scenario.name.as_str()));
1399        scenario_start.push_attribute(("locked", "1"));
1400        scenario_start.push_attribute(("count", count_text.as_str()));
1401        if let Some(comment) = &scenario.comment {
1402            scenario_start.push_attribute(("comment", comment.as_str()));
1403        }
1404        writer.write_event(Event::Start(scenario_start))?;
1405        for (cell, value) in &scenario.inputs {
1406            let mut input_cell = BytesStart::new("inputCells");
1407            input_cell.push_attribute(("r", cell.as_str()));
1408            input_cell.push_attribute(("val", value.as_str()));
1409            writer.write_event(Event::Empty(input_cell))?;
1410        }
1411        writer.write_event(Event::End(BytesEnd::new("scenario")))?;
1412    }
1413    writer.write_event(Event::End(BytesEnd::new("scenarios")))?;
1414    Ok(())
1415}
1416
1417/// Serializes `<ignoredErrors>`/`<ignoredError>` (`CT_IgnoredErrors`/ `CT_IgnoredError`) from a
1418/// sheet's [`IgnoredError`]s. One `<ignoredError>` element per entry (see `IgnoredError`'s own doc
1419/// comment for why this crate doesn't bother merging entries); each boolean flag attribute is only
1420/// written when `true` (`CT_IgnoredError`'s schema default for every flag is `false`, confirmed via
1421/// `sml.xsd`), matching this crate's usual "omit schema-default attributes" convention elsewhere
1422/// (e.g. `Row`/`ColumnSetting` not writing `hidden="0"`).
1423fn write_ignored_errors<W: Write>(
1424    writer: &mut Writer<W>,
1425    ignored_errors: &[IgnoredError],
1426) -> Result<()> {
1427    writer.write_event(Event::Start(BytesStart::new("ignoredErrors")))?;
1428    for entry in ignored_errors {
1429        let mut start = BytesStart::new("ignoredError");
1430        start.push_attribute(("sqref", entry.range.as_str()));
1431        if entry.eval_error {
1432            start.push_attribute(("evalError", "1"));
1433        }
1434        if entry.two_digit_text_year {
1435            start.push_attribute(("twoDigitTextYear", "1"));
1436        }
1437        if entry.number_stored_as_text {
1438            start.push_attribute(("numberStoredAsText", "1"));
1439        }
1440        if entry.formula {
1441            start.push_attribute(("formula", "1"));
1442        }
1443        if entry.formula_range {
1444            start.push_attribute(("formulaRange", "1"));
1445        }
1446        if entry.unlocked_formula {
1447            start.push_attribute(("unlockedFormula", "1"));
1448        }
1449        if entry.empty_cell_reference {
1450            start.push_attribute(("emptyCellReference", "1"));
1451        }
1452        if entry.list_data_validation {
1453            start.push_attribute(("listDataValidation", "1"));
1454        }
1455        if entry.calculated_column {
1456            start.push_attribute(("calculatedColumn", "1"));
1457        }
1458        writer.write_event(Event::Empty(start))?;
1459    }
1460    writer.write_event(Event::End(BytesEnd::new("ignoredErrors")))?;
1461    Ok(())
1462}
1463
1464/// Serializes each `<filterColumn>`/`<filters>`/`<filter>`
1465/// (`CT_FilterColumn`/`CT_Filters`/`CT_Filter`) from a sheet's [`AutoFilterColumn`]s, as children
1466/// of an already-opened `<autoFilter>`. See `AutoFilterColumn`'s doc comment for the scope this
1467/// supports (a fixed list of checked values only).
1468fn write_filter_columns<W: Write>(
1469    writer: &mut Writer<W>,
1470    columns: &[AutoFilterColumn],
1471) -> Result<()> {
1472    for column in columns {
1473        let col_id_text = column.column_offset.to_string();
1474        let mut filter_column = BytesStart::new("filterColumn");
1475        filter_column.push_attribute(("colId", col_id_text.as_str()));
1476        writer.write_event(Event::Start(filter_column))?;
1477        writer.write_event(Event::Start(BytesStart::new("filters")))?;
1478        for value in &column.visible_values {
1479            let mut filter = BytesStart::new("filter");
1480            filter.push_attribute(("val", value.as_str()));
1481            writer.write_event(Event::Empty(filter))?;
1482        }
1483        writer.write_event(Event::End(BytesEnd::new("filters")))?;
1484        writer.write_event(Event::End(BytesEnd::new("filterColumn")))?;
1485    }
1486    Ok(())
1487}
1488
1489/// Serializes `<sortState ref="..">`/`<sortCondition ref=".." descending="1">`
1490/// (`CT_SortState`/`CT_SortCondition`) from a [`SortState`]. Shared by both call sites (a
1491/// worksheet-level `<autoFilter>`'s child and a table's own sibling element) since `CT_SortState`'s
1492/// own shape is identical in both places, only its *position* in the surrounding sequence differs
1493/// (see `Sheet.sort_state`/`ExcelTable.sort_state`'s doc comments).
1494fn write_sort_state<W: Write>(writer: &mut Writer<W>, sort_state: &SortState) -> Result<()> {
1495    let mut start = BytesStart::new("sortState");
1496    start.push_attribute(("ref", sort_state.range.as_str()));
1497    if sort_state.conditions.is_empty() {
1498        writer.write_event(Event::Empty(start))?;
1499    } else {
1500        writer.write_event(Event::Start(start))?;
1501        for condition in &sort_state.conditions {
1502            let mut condition_start = BytesStart::new("sortCondition");
1503            condition_start.push_attribute(("ref", condition.range.as_str()));
1504            if condition.descending {
1505                condition_start.push_attribute(("descending", "1"));
1506            }
1507            writer.write_event(Event::Empty(condition_start))?;
1508        }
1509        writer.write_event(Event::End(BytesEnd::new("sortState")))?;
1510    }
1511    Ok(())
1512}
1513
1514/// Serializes `<rowBreaks>`/`<colBreaks>` (`CT_PageBreak`, sharing the same shape for both rows and
1515/// columns) from a sheet's manual page breaks. `max` is each break's `<brk>`'s own `max` attribute
1516/// — the highest row/column index the break spans, conventionally the format's absolute maximum
1517/// (`16383` for a row-break's column extent, `1048575` for a column-break's row extent,
1518/// SpreadsheetML's own row/column count limits) when the break should apply across the sheet's full
1519/// width/height, matching what real Excel itself writes.
1520fn write_page_breaks<W: Write>(
1521    writer: &mut Writer<W>,
1522    element_name: &str,
1523    breaks: &[u32],
1524    max: u32,
1525) -> Result<()> {
1526    let count_text = breaks.len().to_string();
1527    let max_text = max.to_string();
1528    let mut start = BytesStart::new(element_name);
1529    start.push_attribute(("count", count_text.as_str()));
1530    start.push_attribute(("manualBreakCount", count_text.as_str()));
1531    writer.write_event(Event::Start(start))?;
1532    for id in breaks {
1533        let id_text = id.to_string();
1534        let mut brk = BytesStart::new("brk");
1535        brk.push_attribute(("id", id_text.as_str()));
1536        brk.push_attribute(("max", max_text.as_str()));
1537        brk.push_attribute(("man", "1"));
1538        writer.write_event(Event::Empty(brk))?;
1539    }
1540    writer.write_event(Event::End(BytesEnd::new(element_name)))?;
1541    Ok(())
1542}
1543
1544/// Computes Excel's legacy 16-bit password hash, formatted as 4 uppercase hex digits
1545/// (`CT_SheetProtection`/`CT_WorkbookProtection`'s `password` attribute) — the algorithm ECMA-376
1546/// Part 4 §3.3.1.81 documents is itself wrong (confirmed by multiple independent
1547/// reverse-engineering efforts, e.g. Kohei Yoshida's LibreOffice-contributor writeup and Wouter van
1548/// Vugt's "Hashing password for use in SpreadsheetML" post); this implements the corrected
1549/// algorithm those sources converged on, which every version of Excel (including current ones, for
1550/// backward compatibility) still accepts: rotate a running 15-bit hash left by one bit and XOR each
1551/// password byte in, in *reverse* order, then a final XOR with a fixed constant and the password's
1552/// byte length. Only ASCII/ single-byte-per-character passwords are handled correctly — a
1553/// multi-byte UTF-8 character would need the extra "high byte" conversion step ECMA-376 also
1554/// documents, which isn't implemented (out of scope, same posture as other scope reductions).
1555fn legacy_password_hash(password: &str) -> u16 {
1556    let bytes = password.as_bytes();
1557    let mut hash: u16 = 0;
1558    for &byte in bytes.iter().rev() {
1559        hash = ((hash >> 14) & 0x01) | ((hash << 1) & 0x7fff);
1560        hash ^= byte as u16;
1561    }
1562    hash = ((hash >> 14) & 0x01) | ((hash << 1) & 0x7fff);
1563    hash ^= 0xCE4B; // 0x8000 | ('N' as u16) << 8 | ('K' as u16)
1564    hash ^= bytes.len() as u16;
1565    hash
1566}
1567
1568fn legacy_password_hash_hex(password: &str) -> String {
1569    format!("{:04X}", legacy_password_hash(password))
1570}
1571
1572/// Serializes `<hyperlinks><hyperlink../></hyperlinks>` from a sheet's [`CellHyperlink`]s.
1573/// `relationship_ids[i]` is `Some(rId)` for `hyperlinks[i]` when it's an external link (resolved
1574/// against this worksheet's own `.rels`, computed up front by `Workbook::write_to`), `None` for an
1575/// internal one (no relationship needed — `location` carries the target directly).
1576fn write_hyperlinks<W: Write>(
1577    writer: &mut Writer<W>,
1578    hyperlinks: &[CellHyperlink],
1579    relationship_ids: &[Option<String>],
1580) -> Result<()> {
1581    writer.write_event(Event::Start(BytesStart::new("hyperlinks")))?;
1582    for (hyperlink, relationship_id) in hyperlinks.iter().zip(relationship_ids) {
1583        let mut start = BytesStart::new("hyperlink");
1584        start.push_attribute(("ref", hyperlink.cell.as_str()));
1585        match (&hyperlink.target, relationship_id) {
1586            (HyperlinkTarget::External(_), Some(relationship_id)) => {
1587                start.push_attribute(("r:id", relationship_id.as_str()));
1588            }
1589            (HyperlinkTarget::Internal(location), _) => {
1590                start.push_attribute(("location", location.as_str()));
1591            }
1592            // An external target with no resolved relationship id would be a bug in
1593            // `Workbook::write_to`'s own bookkeeping — writing just `ref` with neither `r:id` nor
1594            // `location` is at least schema-tolerable (both are optional) rather than panicking.
1595            (HyperlinkTarget::External(_), None) => {}
1596        }
1597        if let Some(tooltip) = &hyperlink.tooltip {
1598            start.push_attribute(("tooltip", tooltip.as_str()));
1599        }
1600        writer.write_event(Event::Empty(start))?;
1601    }
1602    writer.write_event(Event::End(BytesEnd::new("hyperlinks")))?;
1603    Ok(())
1604}
1605
1606/// Serializes `<pageSetup>` (`CT_PageSetup`) from a sheet's [`PrintSettings`].
1607/// `fitToWidth`/`fitToHeight` default to `"0"` ("as many pages as needed") per `CT_PageSetup`'s own
1608/// convention when only one of the two is set — see `PrintSettings::fit_to_width`'s doc comment.
1609fn write_page_setup<W: Write>(
1610    writer: &mut Writer<W>,
1611    print_settings: &PrintSettings,
1612) -> Result<()> {
1613    let mut start = BytesStart::new("pageSetup");
1614    if let Some(orientation) = print_settings.orientation {
1615        start.push_attribute(("orientation", page_orientation_str(orientation)));
1616    }
1617    // see `PrintSettings.scale`'s doc comment for why this is the caller's own responsibility not
1618    // to combine confusingly with `fit_to_width`/`fit_to_height`.
1619    let scale_text = print_settings.scale.map(|value| value.to_string());
1620    if let Some(scale_text) = &scale_text {
1621        start.push_attribute(("scale", scale_text.as_str()));
1622    }
1623    let fit_to_width_text = print_settings.fit_to_width.unwrap_or(0).to_string();
1624    let fit_to_height_text = print_settings.fit_to_height.unwrap_or(0).to_string();
1625    if print_settings.fit_to_width.is_some() || print_settings.fit_to_height.is_some() {
1626        start.push_attribute(("fitToWidth", fit_to_width_text.as_str()));
1627        start.push_attribute(("fitToHeight", fit_to_height_text.as_str()));
1628    }
1629    writer.write_event(Event::Empty(start))?;
1630    Ok(())
1631}
1632
1633fn page_orientation_str(orientation: PageOrientation) -> &'static str {
1634    match orientation {
1635        PageOrientation::Portrait => "portrait",
1636        PageOrientation::Landscape => "landscape",
1637    }
1638}
1639
1640/// Serializes `<headerFooter><oddHeader>.</oddHeader><oddFooter>.</oddFooter></headerFooter>` from
1641/// a sheet's [`PrintSettings`] — extended for `differentFirst`/`differentOddEven`
1642/// first-page/even-page variants. Header/footer text is written verbatim, see
1643/// `PrintSettings.header`'s doc comment. `CT_HeaderFooter`'s child sequence is `oddHeader?`,
1644/// `oddFooter?`, `evenHeader?`, `evenFooter?`, `firstHeader?`, `firstFooter?`.
1645fn write_header_footer<W: Write>(
1646    writer: &mut Writer<W>,
1647    print_settings: &PrintSettings,
1648) -> Result<()> {
1649    let mut start = BytesStart::new("headerFooter");
1650    if print_settings.header_first.is_some() || print_settings.footer_first.is_some() {
1651        start.push_attribute(("differentFirst", "1"));
1652    }
1653    if print_settings.header_even.is_some() || print_settings.footer_even.is_some() {
1654        start.push_attribute(("differentOddEven", "1"));
1655    }
1656    writer.write_event(Event::Start(start))?;
1657    if let Some(header) = &print_settings.header {
1658        write_element_text(writer, "oddHeader", header)?;
1659    }
1660    if let Some(footer) = &print_settings.footer {
1661        write_element_text(writer, "oddFooter", footer)?;
1662    }
1663    if let Some(header) = &print_settings.header_even {
1664        write_element_text(writer, "evenHeader", header)?;
1665    }
1666    if let Some(footer) = &print_settings.footer_even {
1667        write_element_text(writer, "evenFooter", footer)?;
1668    }
1669    if let Some(header) = &print_settings.header_first {
1670        write_element_text(writer, "firstHeader", header)?;
1671    }
1672    if let Some(footer) = &print_settings.footer_first {
1673        write_element_text(writer, "firstFooter", footer)?;
1674    }
1675    writer.write_event(Event::End(BytesEnd::new("headerFooter")))?;
1676    Ok(())
1677}
1678
1679/// Serializes one conditional formatting rule as its own `<conditionalFormatting
1680/// sqref=".."><cfRule../></conditionalFormatting>` block (see `ConditionalFormattingRule`'s doc
1681/// comment for why this crate doesn't group multiple rules under one block the way real Excel
1682/// does). `index` is this rule's position in `Sheet.conditional_formatting_rules`, used both to
1683/// compute `priority` (1-based) and, via `dxfs`, to resolve `dxfId`.
1684fn write_conditional_formatting<W: Write>(
1685    writer: &mut Writer<W>,
1686    index: usize,
1687    rule: &ConditionalFormattingRule,
1688    dxfs: &[CellFormat],
1689) -> Result<()> {
1690    let mut conditional_formatting_start = BytesStart::new("conditionalFormatting");
1691    conditional_formatting_start.push_attribute(("sqref", rule.range.as_str()));
1692    writer.write_event(Event::Start(conditional_formatting_start))?;
1693
1694    // `ColorScale`/`DataBar`/`IconSet` ignore `rule.format` entirely (see their own doc comments) —
1695    // resolving a `dxfId` for them would be meaningless, so this lookup is only used by the
1696    // variants that actually reference one.
1697    let dxf_id = dxfs
1698        .iter()
1699        .position(|format| format == &rule.format)
1700        .unwrap_or(0);
1701    let dxf_id_text = dxf_id.to_string();
1702    let priority_text = (index + 1).to_string();
1703    let anchor_cell = range_anchor_cell(&rule.range);
1704
1705    let mut cf_rule_start = BytesStart::new("cfRule");
1706    match &rule.condition {
1707        ConditionalFormattingCondition::CellIs { operator, .. } => {
1708            cf_rule_start.push_attribute(("type", "cellIs"));
1709            cf_rule_start.push_attribute(("dxfId", dxf_id_text.as_str()));
1710            cf_rule_start.push_attribute(("priority", priority_text.as_str()));
1711            cf_rule_start.push_attribute(("operator", comparison_operator_str(*operator)));
1712        }
1713        ConditionalFormattingCondition::Expression { .. } => {
1714            cf_rule_start.push_attribute(("type", "expression"));
1715            cf_rule_start.push_attribute(("dxfId", dxf_id_text.as_str()));
1716            cf_rule_start.push_attribute(("priority", priority_text.as_str()));
1717        }
1718        ConditionalFormattingCondition::ColorScale(_) => {
1719            cf_rule_start.push_attribute(("type", "colorScale"));
1720            cf_rule_start.push_attribute(("priority", priority_text.as_str()));
1721        }
1722        ConditionalFormattingCondition::DataBar { .. } => {
1723            cf_rule_start.push_attribute(("type", "dataBar"));
1724            cf_rule_start.push_attribute(("priority", priority_text.as_str()));
1725        }
1726        ConditionalFormattingCondition::IconSet(_) => {
1727            cf_rule_start.push_attribute(("type", "iconSet"));
1728            cf_rule_start.push_attribute(("priority", priority_text.as_str()));
1729        }
1730        ConditionalFormattingCondition::Top10 {
1731            rank,
1732            percent,
1733            bottom,
1734        } => {
1735            cf_rule_start.push_attribute(("type", "top10"));
1736            cf_rule_start.push_attribute(("dxfId", dxf_id_text.as_str()));
1737            cf_rule_start.push_attribute(("priority", priority_text.as_str()));
1738            let rank_text = rank.to_string();
1739            cf_rule_start.push_attribute(("rank", rank_text.as_str()));
1740            if *percent {
1741                cf_rule_start.push_attribute(("percent", "1"));
1742            }
1743            if *bottom {
1744                cf_rule_start.push_attribute(("bottom", "1"));
1745            }
1746            writer.write_event(Event::Empty(cf_rule_start))?;
1747            writer.write_event(Event::End(BytesEnd::new("conditionalFormatting")))?;
1748            return Ok(());
1749        }
1750        ConditionalFormattingCondition::AboveAverage {
1751            above,
1752            equal_average,
1753        } => {
1754            cf_rule_start.push_attribute(("type", "aboveAverage"));
1755            cf_rule_start.push_attribute(("dxfId", dxf_id_text.as_str()));
1756            cf_rule_start.push_attribute(("priority", priority_text.as_str()));
1757            if !*above {
1758                cf_rule_start.push_attribute(("aboveAverage", "0"));
1759            }
1760            if *equal_average {
1761                cf_rule_start.push_attribute(("equalAverage", "1"));
1762            }
1763            writer.write_event(Event::Empty(cf_rule_start))?;
1764            writer.write_event(Event::End(BytesEnd::new("conditionalFormatting")))?;
1765            return Ok(());
1766        }
1767        ConditionalFormattingCondition::DuplicateValues => {
1768            cf_rule_start.push_attribute(("type", "duplicateValues"));
1769            cf_rule_start.push_attribute(("dxfId", dxf_id_text.as_str()));
1770            cf_rule_start.push_attribute(("priority", priority_text.as_str()));
1771            writer.write_event(Event::Empty(cf_rule_start))?;
1772            writer.write_event(Event::End(BytesEnd::new("conditionalFormatting")))?;
1773            return Ok(());
1774        }
1775        ConditionalFormattingCondition::UniqueValues => {
1776            cf_rule_start.push_attribute(("type", "uniqueValues"));
1777            cf_rule_start.push_attribute(("dxfId", dxf_id_text.as_str()));
1778            cf_rule_start.push_attribute(("priority", priority_text.as_str()));
1779            writer.write_event(Event::Empty(cf_rule_start))?;
1780            writer.write_event(Event::End(BytesEnd::new("conditionalFormatting")))?;
1781            return Ok(());
1782        }
1783        ConditionalFormattingCondition::ContainsText { operator, .. } => {
1784            cf_rule_start.push_attribute(("type", text_comparison_operator_type_str(*operator)));
1785            cf_rule_start.push_attribute(("dxfId", dxf_id_text.as_str()));
1786            cf_rule_start.push_attribute(("priority", priority_text.as_str()));
1787            cf_rule_start
1788                .push_attribute(("operator", text_comparison_operator_type_str(*operator)));
1789        }
1790    }
1791    let text_for_attribute;
1792    if let ConditionalFormattingCondition::ContainsText { text, .. } = &rule.condition {
1793        text_for_attribute = text.clone();
1794        cf_rule_start.push_attribute(("text", text_for_attribute.as_str()));
1795    }
1796    writer.write_event(Event::Start(cf_rule_start))?;
1797
1798    match &rule.condition {
1799        ConditionalFormattingCondition::CellIs {
1800            formula1, formula2, ..
1801        } => {
1802            write_element_text(writer, "formula", formula1)?;
1803            if let Some(formula2) = formula2 {
1804                write_element_text(writer, "formula", formula2)?;
1805            }
1806        }
1807        ConditionalFormattingCondition::Expression { formula } => {
1808            write_element_text(writer, "formula", formula)?;
1809        }
1810        ConditionalFormattingCondition::ColorScale(stops) => {
1811            writer.write_event(Event::Start(BytesStart::new("colorScale")))?;
1812            for stop in stops {
1813                write_cfvo(writer, &stop.position)?;
1814            }
1815            for stop in stops {
1816                let mut color = BytesStart::new("color");
1817                color.push_attribute(("rgb", stop.color.as_str()));
1818                writer.write_event(Event::Empty(color))?;
1819            }
1820            writer.write_event(Event::End(BytesEnd::new("colorScale")))?;
1821        }
1822        ConditionalFormattingCondition::DataBar { min, max, color } => {
1823            writer.write_event(Event::Start(BytesStart::new("dataBar")))?;
1824            write_cfvo(writer, min)?;
1825            write_cfvo(writer, max)?;
1826            let mut color_start = BytesStart::new("color");
1827            color_start.push_attribute(("rgb", color.as_str()));
1828            writer.write_event(Event::Empty(color_start))?;
1829            writer.write_event(Event::End(BytesEnd::new("dataBar")))?;
1830        }
1831        ConditionalFormattingCondition::IconSet(icon_set) => {
1832            let mut icon_set_start = BytesStart::new("iconSet");
1833            icon_set_start.push_attribute(("iconSet", icon_set_type_str(*icon_set)));
1834            writer.write_event(Event::Start(icon_set_start))?;
1835            for percent in [0, 33, 67] {
1836                write_cfvo(writer, &CfvoPosition::Percent(percent as f64))?;
1837            }
1838            writer.write_event(Event::End(BytesEnd::new("iconSet")))?;
1839        }
1840        ConditionalFormattingCondition::ContainsText { operator, text } => {
1841            write_element_text(
1842                writer,
1843                "formula",
1844                &text_comparison_formula(*operator, anchor_cell, text),
1845            )?;
1846        }
1847        ConditionalFormattingCondition::Top10 { .. }
1848        | ConditionalFormattingCondition::AboveAverage { .. }
1849        | ConditionalFormattingCondition::DuplicateValues
1850        | ConditionalFormattingCondition::UniqueValues => unreachable!("returned early above"),
1851    }
1852
1853    writer.write_event(Event::End(BytesEnd::new("cfRule")))?;
1854    writer.write_event(Event::End(BytesEnd::new("conditionalFormatting")))?;
1855
1856    Ok(())
1857}
1858
1859/// The first cell reference of a range (e.g. `"A1"` from `"A1:D10"`) — used as the anchor cell in
1860/// an auto-generated `containsText`/. formula (see `write_conditional_formatting`'s `ContainsText`
1861/// arm) — real Excel always anchors these formulas on the top-left cell of the rule's range.
1862fn range_anchor_cell(range: &str) -> &str {
1863    range.split(':').next().unwrap_or(range)
1864}
1865
1866/// Doubles any literal `"` in `text` — Excel formula string literals escape an embedded quote by
1867/// doubling it (`""`), not backslash-escaping it.
1868fn escape_formula_string(text: &str) -> String {
1869    text.replace('"', "\"\"")
1870}
1871
1872fn text_comparison_formula(
1873    operator: TextComparisonOperator,
1874    anchor_cell: &str,
1875    text: &str,
1876) -> String {
1877    let escaped = escape_formula_string(text);
1878    match operator {
1879        TextComparisonOperator::Contains => {
1880            format!(r#"NOT(ISERROR(SEARCH("{escaped}",{anchor_cell})))"#)
1881        }
1882        TextComparisonOperator::NotContains => {
1883            format!(r#"ISERROR(SEARCH("{escaped}",{anchor_cell}))"#)
1884        }
1885        TextComparisonOperator::BeginsWith => {
1886            format!(r#"LEFT({anchor_cell},LEN("{escaped}"))="{escaped}""#)
1887        }
1888        TextComparisonOperator::EndsWith => {
1889            format!(r#"RIGHT({anchor_cell},LEN("{escaped}"))="{escaped}""#)
1890        }
1891    }
1892}
1893
1894fn text_comparison_operator_type_str(operator: TextComparisonOperator) -> &'static str {
1895    match operator {
1896        TextComparisonOperator::Contains => "containsText",
1897        TextComparisonOperator::NotContains => "notContainsText",
1898        TextComparisonOperator::BeginsWith => "beginsWith",
1899        TextComparisonOperator::EndsWith => "endsWith",
1900    }
1901}
1902
1903fn icon_set_type_str(icon_set: IconSetType) -> &'static str {
1904    match icon_set {
1905        IconSetType::ThreeTrafficLights => "3TrafficLights1",
1906        IconSetType::ThreeArrows => "3Arrows",
1907        IconSetType::ThreeFlags => "3Flags",
1908        IconSetType::ThreeSymbols => "3Symbols",
1909    }
1910}
1911
1912/// Writes one `<cfvo type=".." val="..">` (`CT_Cfvo`) — `val` is omitted entirely for `Min`/`Max`
1913/// (see [`CfvoPosition`]'s doc comment).
1914fn write_cfvo<W: Write>(writer: &mut Writer<W>, position: &CfvoPosition) -> Result<()> {
1915    let mut cfvo = BytesStart::new("cfvo");
1916    match position {
1917        CfvoPosition::Min => {
1918            cfvo.push_attribute(("type", "min"));
1919        }
1920        CfvoPosition::Max => {
1921            cfvo.push_attribute(("type", "max"));
1922        }
1923        CfvoPosition::Percent(value) => {
1924            cfvo.push_attribute(("type", "percent"));
1925            let value_text = value.to_string();
1926            cfvo.push_attribute(("val", value_text.as_str()));
1927            writer.write_event(Event::Empty(cfvo))?;
1928            return Ok(());
1929        }
1930        CfvoPosition::Number(value) => {
1931            cfvo.push_attribute(("type", "num"));
1932            let value_text = value.to_string();
1933            cfvo.push_attribute(("val", value_text.as_str()));
1934            writer.write_event(Event::Empty(cfvo))?;
1935            return Ok(());
1936        }
1937    }
1938    writer.write_event(Event::Empty(cfvo))?;
1939    Ok(())
1940}
1941
1942/// Serializes `<dataValidations count="N">..</dataValidations>` from a sheet's data validation
1943/// rules.
1944fn write_data_validations<W: Write>(
1945    writer: &mut Writer<W>,
1946    rules: &[DataValidationRule],
1947) -> Result<()> {
1948    let count_text = rules.len().to_string();
1949    let mut start = BytesStart::new("dataValidations");
1950    start.push_attribute(("count", count_text.as_str()));
1951    writer.write_event(Event::Start(start))?;
1952
1953    for rule in rules {
1954        let (type_str, operator, formula1, formula2): (
1955            &str,
1956            Option<ComparisonOperator>,
1957            &str,
1958            Option<&str>,
1959        ) = match &rule.kind {
1960            DataValidationKind::List { source } => ("list", None, source.as_str(), None),
1961            DataValidationKind::WholeNumber {
1962                operator,
1963                formula1,
1964                formula2,
1965            } => (
1966                "whole",
1967                Some(*operator),
1968                formula1.as_str(),
1969                formula2.as_deref(),
1970            ),
1971            DataValidationKind::Decimal {
1972                operator,
1973                formula1,
1974                formula2,
1975            } => (
1976                "decimal",
1977                Some(*operator),
1978                formula1.as_str(),
1979                formula2.as_deref(),
1980            ),
1981            DataValidationKind::Date {
1982                operator,
1983                formula1,
1984                formula2,
1985            } => (
1986                "date",
1987                Some(*operator),
1988                formula1.as_str(),
1989                formula2.as_deref(),
1990            ),
1991            DataValidationKind::Time {
1992                operator,
1993                formula1,
1994                formula2,
1995            } => (
1996                "time",
1997                Some(*operator),
1998                formula1.as_str(),
1999                formula2.as_deref(),
2000            ),
2001            DataValidationKind::TextLength {
2002                operator,
2003                formula1,
2004                formula2,
2005            } => (
2006                "textLength",
2007                Some(*operator),
2008                formula1.as_str(),
2009                formula2.as_deref(),
2010            ),
2011            DataValidationKind::Custom { formula } => ("custom", None, formula.as_str(), None),
2012        };
2013
2014        let mut data_validation_start = BytesStart::new("dataValidation");
2015        data_validation_start.push_attribute(("type", type_str));
2016        if let Some(operator) = operator {
2017            data_validation_start.push_attribute(("operator", comparison_operator_str(operator)));
2018        }
2019        if rule.allow_blank {
2020            data_validation_start.push_attribute(("allowBlank", "1"));
2021        }
2022        if rule.input_message.is_some() {
2023            data_validation_start.push_attribute(("showInputMessage", "1"));
2024        }
2025        if rule.error_message.is_some() {
2026            data_validation_start.push_attribute(("showErrorMessage", "1"));
2027        }
2028        if let Some(message) = &rule.input_message {
2029            if let Some(title) = &message.title {
2030                data_validation_start.push_attribute(("promptTitle", title.as_str()));
2031            }
2032            data_validation_start.push_attribute(("prompt", message.text.as_str()));
2033        }
2034        if let Some(message) = &rule.error_message {
2035            if let Some(title) = &message.title {
2036                data_validation_start.push_attribute(("errorTitle", title.as_str()));
2037            }
2038            data_validation_start.push_attribute(("error", message.text.as_str()));
2039        }
2040        data_validation_start.push_attribute(("sqref", rule.range.as_str()));
2041        writer.write_event(Event::Start(data_validation_start))?;
2042
2043        write_element_text(writer, "formula1", formula1)?;
2044        if let Some(formula2) = formula2 {
2045            write_element_text(writer, "formula2", formula2)?;
2046        }
2047
2048        writer.write_event(Event::End(BytesEnd::new("dataValidation")))?;
2049    }
2050
2051    writer.write_event(Event::End(BytesEnd::new("dataValidations")))?;
2052
2053    Ok(())
2054}
2055
2056/// Writes a simple `<name>text</name>` element — shared by `<formula>` (conditional formatting) and
2057/// `<formula1>`/`<formula2>` (data validation), which are otherwise identical in shape.
2058fn write_element_text<W: Write>(writer: &mut Writer<W>, name: &str, text: &str) -> Result<()> {
2059    writer.write_event(Event::Start(BytesStart::new(name)))?;
2060    writer.write_event(Event::Text(BytesText::new(text)))?;
2061    writer.write_event(Event::End(BytesEnd::new(name)))?;
2062    Ok(())
2063}
2064
2065/// Maps a [`ComparisonOperator`] to its ECMA-376 token — shared by
2066/// `ST_ConditionalFormattingOperator` and `ST_DataValidationOperator`, which happen to define the
2067/// exact same 8 tokens for these values.
2068fn comparison_operator_str(operator: ComparisonOperator) -> &'static str {
2069    match operator {
2070        ComparisonOperator::LessThan => "lessThan",
2071        ComparisonOperator::LessThanOrEqual => "lessThanOrEqual",
2072        ComparisonOperator::Equal => "equal",
2073        ComparisonOperator::NotEqual => "notEqual",
2074        ComparisonOperator::GreaterThanOrEqual => "greaterThanOrEqual",
2075        ComparisonOperator::GreaterThan => "greaterThan",
2076        ComparisonOperator::Between => "between",
2077        ComparisonOperator::NotBetween => "notBetween",
2078    }
2079}
2080
2081/// Resolves a cell's `cellXfs` index (its `<c>`'s `s` attribute) from its optional [`CellFormat`] —
2082/// `None` for a cell with no format (implicitly index 0, so `s` is simply omitted), or `1 +
2083/// position` of the matching entry in `cell_formats` (see `collect_cell_formats`'s doc comment for
2084/// why index 0 is always reserved).
2085fn style_index(format: &Option<CellFormat>, cell_formats: &[CellFormat]) -> Option<usize> {
2086    format
2087        .as_ref()
2088        .and_then(|format| {
2089            cell_formats
2090                .iter()
2091                .position(|candidate| candidate == format)
2092        })
2093        .map(|index| index + 1)
2094}
2095
2096/// Writes a single `<c>` element, or nothing at all for a plain `CellValue::Empty` cell with no
2097/// format — real Excel never writes a `<c>` for a genuinely empty, unstyled cell either, it simply
2098/// omits it (see `CellValue::Empty`'s doc comment). A styled-but-empty cell (a format but no value)
2099/// is still written, as a self-closing `<c r=".." s=".."/>` with no `<v>` at all.
2100fn write_cell<W: Write>(
2101    writer: &mut Writer<W>,
2102    column_index: usize,
2103    row_number: usize,
2104    cell: &Cell,
2105    shared_strings: &SharedStrings,
2106    cell_formats: &[CellFormat],
2107) -> Result<()> {
2108    let style = style_index(&cell.format, cell_formats);
2109
2110    match &cell.value {
2111        CellValue::Empty => {
2112            if let Some(style) = style {
2113                let reference = cell_reference(column_index, row_number);
2114                let style_text = style.to_string();
2115                let mut start = BytesStart::new("c");
2116                start.push_attribute(("r", reference.as_str()));
2117                start.push_attribute(("s", style_text.as_str()));
2118                writer.write_event(Event::Empty(start))?;
2119            }
2120        }
2121
2122        CellValue::Text(text) => {
2123            let reference = cell_reference(column_index, row_number);
2124            let index = shared_strings.plain_indices.get(text).copied().unwrap_or(0);
2125            let index_text = index.to_string();
2126            let style_text = style.map(|value| value.to_string());
2127
2128            let mut start = BytesStart::new("c");
2129            start.push_attribute(("r", reference.as_str()));
2130            if let Some(style_text) = &style_text {
2131                start.push_attribute(("s", style_text.as_str()));
2132            }
2133            start.push_attribute(("t", "s"));
2134            writer.write_event(Event::Start(start))?;
2135            writer.write_event(Event::Start(BytesStart::new("v")))?;
2136            writer.write_event(Event::Text(BytesText::new(&index_text)))?;
2137            writer.write_event(Event::End(BytesEnd::new("v")))?;
2138            writer.write_event(Event::End(BytesEnd::new("c")))?;
2139        }
2140
2141        // `RichText` shares the exact same `<c t="s"><v>N</v></c>` shape as plain `Text` — the only
2142        // difference is which kind of `<si>` entry `N` resolves to in `xl/sharedStrings.xml` (see
2143        // `SharedStrings`/`to_shared_strings_xml`).
2144        CellValue::RichText(runs) => {
2145            let reference = cell_reference(column_index, row_number);
2146            let index = shared_strings.index_of_rich(runs).unwrap_or(0);
2147            let index_text = index.to_string();
2148            let style_text = style.map(|value| value.to_string());
2149
2150            let mut start = BytesStart::new("c");
2151            start.push_attribute(("r", reference.as_str()));
2152            if let Some(style_text) = &style_text {
2153                start.push_attribute(("s", style_text.as_str()));
2154            }
2155            start.push_attribute(("t", "s"));
2156            writer.write_event(Event::Start(start))?;
2157            writer.write_event(Event::Start(BytesStart::new("v")))?;
2158            writer.write_event(Event::Text(BytesText::new(&index_text)))?;
2159            writer.write_event(Event::End(BytesEnd::new("v")))?;
2160            writer.write_event(Event::End(BytesEnd::new("c")))?;
2161        }
2162
2163        // A boolean cell (`t="b"`, `<v>0</v>`/`<v>1</v>`).
2164        CellValue::Boolean(value) => {
2165            let reference = cell_reference(column_index, row_number);
2166            let value_text = if *value { "1" } else { "0" };
2167            let style_text = style.map(|value| value.to_string());
2168
2169            let mut start = BytesStart::new("c");
2170            start.push_attribute(("r", reference.as_str()));
2171            if let Some(style_text) = &style_text {
2172                start.push_attribute(("s", style_text.as_str()));
2173            }
2174            start.push_attribute(("t", "b"));
2175            writer.write_event(Event::Start(start))?;
2176            writer.write_event(Event::Start(BytesStart::new("v")))?;
2177            writer.write_event(Event::Text(BytesText::new(value_text)))?;
2178            writer.write_event(Event::End(BytesEnd::new("v")))?;
2179            writer.write_event(Event::End(BytesEnd::new("c")))?;
2180        }
2181
2182        // A date value — written as a plain numeric cell (SpreadsheetML has no dedicated date cell
2183        // type, see `CellValue::Date`'s doc comment).
2184        CellValue::Date(date) => {
2185            let reference = cell_reference(column_index, row_number);
2186            let serial = excel_serial_datetime(date);
2187            let number_text = serial.to_string();
2188            let style_text = style.map(|value| value.to_string());
2189
2190            let mut start = BytesStart::new("c");
2191            start.push_attribute(("r", reference.as_str()));
2192            if let Some(style_text) = &style_text {
2193                start.push_attribute(("s", style_text.as_str()));
2194            }
2195            writer.write_event(Event::Start(start))?;
2196            writer.write_event(Event::Start(BytesStart::new("v")))?;
2197            writer.write_event(Event::Text(BytesText::new(&number_text)))?;
2198            writer.write_event(Event::End(BytesEnd::new("v")))?;
2199            writer.write_event(Event::End(BytesEnd::new("c")))?;
2200        }
2201
2202        CellValue::Number(number) => {
2203            let reference = cell_reference(column_index, row_number);
2204            let number_text = number.to_string();
2205            let style_text = style.map(|value| value.to_string());
2206
2207            let mut start = BytesStart::new("c");
2208            start.push_attribute(("r", reference.as_str()));
2209            if let Some(style_text) = &style_text {
2210                start.push_attribute(("s", style_text.as_str()));
2211            }
2212            writer.write_event(Event::Start(start))?;
2213            writer.write_event(Event::Start(BytesStart::new("v")))?;
2214            writer.write_event(Event::Text(BytesText::new(&number_text)))?;
2215            writer.write_event(Event::End(BytesEnd::new("v")))?;
2216            writer.write_event(Event::End(BytesEnd::new("c")))?;
2217        }
2218
2219        CellValue::Formula {
2220            formula,
2221            cached_value,
2222        } => {
2223            let reference = cell_reference(column_index, row_number);
2224            let style_text = style.map(|value| value.to_string());
2225
2226            let mut start = BytesStart::new("c");
2227            start.push_attribute(("r", reference.as_str()));
2228            if let Some(style_text) = &style_text {
2229                start.push_attribute(("s", style_text.as_str()));
2230            }
2231            writer.write_event(Event::Start(start))?;
2232
2233            // `formula: None` writes no `<f>` at all — an honest "we don't have the text" rather
2234            // than fabricating one (a real `t="shared"` cell with no text now reads back as
2235            // `CellValue::SharedFormula` instead, see its own doc comment, point 52).
2236            if let Some(formula_text) = formula {
2237                writer.write_event(Event::Start(BytesStart::new("f")))?;
2238                writer.write_event(Event::Text(BytesText::new(formula_text)))?;
2239                writer.write_event(Event::End(BytesEnd::new("f")))?;
2240            }
2241            if let Some(value) = cached_value {
2242                let value_text = value.to_string();
2243                writer.write_event(Event::Start(BytesStart::new("v")))?;
2244                writer.write_event(Event::Text(BytesText::new(&value_text)))?;
2245                writer.write_event(Event::End(BytesEnd::new("v")))?;
2246            }
2247
2248            writer.write_event(Event::End(BytesEnd::new("c")))?;
2249        }
2250
2251        // An array (CSE) formula's anchor cell (`t="array"`). See `CellValue::ArrayFormula`'s doc
2252        // comment: this crate only ever writes the literal `<f>` on the anchor cell, matching real
2253        // Excel's own convention.
2254        CellValue::ArrayFormula {
2255            formula,
2256            range,
2257            cached_value,
2258        } => {
2259            let reference = cell_reference(column_index, row_number);
2260            let style_text = style.map(|value| value.to_string());
2261
2262            let mut start = BytesStart::new("c");
2263            start.push_attribute(("r", reference.as_str()));
2264            if let Some(style_text) = &style_text {
2265                start.push_attribute(("s", style_text.as_str()));
2266            }
2267            writer.write_event(Event::Start(start))?;
2268
2269            let mut f_start = BytesStart::new("f");
2270            f_start.push_attribute(("t", "array"));
2271            f_start.push_attribute(("ref", range.as_str()));
2272            writer.write_event(Event::Start(f_start))?;
2273            writer.write_event(Event::Text(BytesText::new(formula)))?;
2274            writer.write_event(Event::End(BytesEnd::new("f")))?;
2275
2276            if let Some(value) = cached_value {
2277                let value_text = value.to_string();
2278                writer.write_event(Event::Start(BytesStart::new("v")))?;
2279                writer.write_event(Event::Text(BytesText::new(&value_text)))?;
2280                writer.write_event(Event::End(BytesEnd::new("v")))?;
2281            }
2282
2283            writer.write_event(Event::End(BytesEnd::new("c")))?;
2284        }
2285
2286        // A shared-formula group cell (`t="shared"`). `ref` is only written on the master cell (see
2287        // `CellValue::SharedFormula`'s doc comment) — a follower's `<f>` carries just `t`/`si`,
2288        // matching real Excel's own convention.
2289        CellValue::SharedFormula {
2290            formula,
2291            group_index,
2292            range,
2293            cached_value,
2294        } => {
2295            let reference = cell_reference(column_index, row_number);
2296            let style_text = style.map(|value| value.to_string());
2297            let group_index_text = group_index.to_string();
2298
2299            let mut start = BytesStart::new("c");
2300            start.push_attribute(("r", reference.as_str()));
2301            if let Some(style_text) = &style_text {
2302                start.push_attribute(("s", style_text.as_str()));
2303            }
2304            writer.write_event(Event::Start(start))?;
2305
2306            let mut f_start = BytesStart::new("f");
2307            f_start.push_attribute(("t", "shared"));
2308            f_start.push_attribute(("si", group_index_text.as_str()));
2309            if let Some(range) = range {
2310                f_start.push_attribute(("ref", range.as_str()));
2311            }
2312            if let Some(formula_text) = formula {
2313                writer.write_event(Event::Start(f_start))?;
2314                writer.write_event(Event::Text(BytesText::new(formula_text)))?;
2315                writer.write_event(Event::End(BytesEnd::new("f")))?;
2316            } else {
2317                writer.write_event(Event::Empty(f_start))?;
2318            }
2319
2320            if let Some(value) = cached_value {
2321                let value_text = value.to_string();
2322                writer.write_event(Event::Start(BytesStart::new("v")))?;
2323                writer.write_event(Event::Text(BytesText::new(&value_text)))?;
2324                writer.write_event(Event::End(BytesEnd::new("v")))?;
2325            }
2326
2327            writer.write_event(Event::End(BytesEnd::new("c")))?;
2328        }
2329    }
2330
2331    Ok(())
2332}
2333
2334/// A cell's `r` attribute (e.g. `"C2"`) from its 0-based column index and 1-based row number.
2335fn cell_reference(column_index: usize, row_number: usize) -> String {
2336    format!("{}{row_number}", column_letters(column_index))
2337}
2338
2339/// Computes `<dimension>`'s `ref` attribute — the bounding box of every cell a sheet actually
2340/// claims, in `sheetData` and, if present, its structured table's own `ref` range (which can extend
2341/// one row past `sheet.rows` for a totals row that's implied rather than stored, see `ExcelTable`'s
2342/// doc comment). Every row/cell in this crate is anchored at column A, row 1 (see
2343/// `Sheet.rows`/`Row.cells`'s own doc comments), so the bounding box always starts at `"A1"`. Falls
2344/// back to `"A1"` alone for a genuinely empty sheet with no table.
2345fn compute_dimension_ref(sheet: &Sheet) -> String {
2346    let mut max_row = 0usize;
2347    let mut max_column_count = 0usize;
2348
2349    for (row_index, row) in sheet.rows.iter().enumerate() {
2350        if !row.cells.is_empty() {
2351            max_row = max_row.max(row_index + 1);
2352            max_column_count = max_column_count.max(row.cells.len());
2353        }
2354    }
2355
2356    if let Some(table) = &sheet.table {
2357        if let Some((end_column_index, end_row)) = parse_range_end(&table.range) {
2358            max_row = max_row.max(end_row);
2359            max_column_count = max_column_count.max(end_column_index + 1);
2360        }
2361    }
2362
2363    if max_row == 0 || max_column_count == 0 {
2364        "A1".to_string()
2365    } else {
2366        format!("A1:{}{max_row}", column_letters(max_column_count - 1))
2367    }
2368}
2369
2370/// Parses a `"start:end"` range reference's end cell into a 0-based column index and 1-based row
2371/// number (e.g. `"A1:C10"` → `(2, 10)`) — the inverse of `cell_reference`, used by
2372/// `compute_dimension_ref`. Returns `None` if `range` isn't in the expected shape.
2373fn parse_range_end(range: &str) -> Option<(usize, usize)> {
2374    let (_, end) = range.split_once(':')?;
2375    let split_at = end.find(|character: char| character.is_ascii_digit())?;
2376    let (letters, digits) = end.split_at(split_at);
2377    if letters.is_empty() {
2378        return None;
2379    }
2380
2381    let mut column_index: usize = 0;
2382    for character in letters.chars() {
2383        if !character.is_ascii_alphabetic() {
2384            return None;
2385        }
2386        let digit = (character.to_ascii_uppercase() as u8 - b'A' + 1) as usize;
2387        column_index = column_index * 26 + digit;
2388    }
2389
2390    let row = digits.parse::<usize>().ok()?;
2391    Some((column_index - 1, row))
2392}
2393
2394/// Converts an [`ExcelDateTime`] to the serial-day number Excel actually stores for a date/time
2395/// value (a plain `f64`, the integer part counting days since Excel's own epoch, the fractional
2396/// part a time-of-day) — see `CellValue::Date`'s doc comment for the scope this supports (proleptic
2397/// Gregorian calendar, "1900 date system" only).
2398///
2399/// Uses Howard Hinnant's well-known `days_from_civil` algorithm (public domain,
2400/// http://howardhinnant.github.io/date_algorithms.html) to convert the calendar date to a day count
2401/// relative to the Unix epoch (1970-01-01), then shifts by 25569 — the number of days between
2402/// Excel's epoch and the Unix epoch *as Excel itself counts them*, which already bakes in Excel's
2403/// own well-known "1900 was a leap year" bug (it wasn't, but Lotus 1-2-3 had that bug first and
2404/// Excel deliberately reproduced it for compatibility) — so no separate leap-year-bug correction is
2405/// needed here, the constant already accounts for it.
2406fn excel_serial_datetime(date: &ExcelDateTime) -> f64 {
2407    let days_since_unix_epoch =
2408        days_from_civil(date.year as i64, date.month as i64, date.day as i64);
2409    let serial_day = days_since_unix_epoch + 25569;
2410    let fraction_of_day =
2411        (date.hour as f64 * 3600.0 + date.minute as f64 * 60.0 + date.second as f64) / 86400.0;
2412    serial_day as f64 + fraction_of_day
2413}
2414
2415/// Howard Hinnant's `days_from_civil`: the number of days since 1970-01-01 for a given
2416/// proleptic-Gregorian civil date. `y`/`m`/`d` are a full year (e.g. `2026`, not `26`), a 1-based
2417/// month, and a 1-based day of month.
2418fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
2419    let y = if m <= 2 { y - 1 } else { y };
2420    let era = if y >= 0 { y } else { y - 399 } / 400;
2421    let yoe = y - era * 400; // [0, 399]
2422    let mp = (m + 9) % 12; // [0, 11]
2423    let doy = (153 * mp + 2) / 5 + d - 1; // [0, 365]
2424    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
2425    era * 146097 + doe - 719468
2426}
2427
2428/// Converts a 0-based column index into Excel's bijective base-26 column letters (`0` → `"A"`, `25`
2429/// → `"Z"`, `26` → `"AA"`, `27` → `"AB"`..).
2430fn column_letters(mut index: usize) -> String {
2431    let mut letters = Vec::new();
2432    loop {
2433        let remainder = index % 26;
2434        letters.push((b'A' + remainder as u8) as char);
2435        if index < 26 {
2436            break;
2437        }
2438        index = index / 26 - 1;
2439    }
2440    letters.iter().rev().collect()
2441}
2442
2443/// One resolved font entry in the eventual `xl/styles.xml`'s `<fonts>` collection.
2444/// `name`/`underline`/`strike` are.
2445struct FontEntry {
2446    bold: bool,
2447    italic: bool,
2448    size: f64,
2449    color: String,
2450    name: String,
2451    underline: bool,
2452    strike: bool,
2453}
2454
2455/// One resolved fill entry in the eventual `<fills>` collection — the two mandatory entries
2456/// (`None`/`Gray125`, see this module's own `STYLES_CONTENT_TYPE` doc comment) plus a `Solid`
2457/// variant for a [`CellFormat::fill_color`]. `Pattern`/`Gradient` are, — see
2458/// `CellFormat::pattern_fill`'s doc comment for the precedence rule when a format sets more than
2459/// one fill field.
2460enum FillEntry {
2461    None,
2462    Gray125,
2463    Solid(String),
2464    Pattern(PatternFill),
2465    Gradient(GradientFill),
2466}
2467
2468/// One resolved `<border>` entry in the eventual `<borders>` collection. Index 0 is always the
2469/// mandatory empty border (see `STYLES_CONTENT_TYPE`'s doc comment), matching `fonts`/`fills`'s own
2470/// "index 0 reserved" convention.
2471#[derive(Clone, PartialEq)]
2472struct BorderEntry {
2473    top: Option<(BorderStyle, String)>,
2474    bottom: Option<(BorderStyle, String)>,
2475    left: Option<(BorderStyle, String)>,
2476    right: Option<(BorderStyle, String)>,
2477    diagonal: Option<(BorderStyle, String)>,
2478    diagonal_up: bool,
2479    diagonal_down: bool,
2480}
2481
2482/// One resolved `<xf>` entry in the eventual `<cellXfs>` collection.
2483/// `wrap_text`/`indent`/`text_rotation`/`shrink_to_fit` and `locked`/`formula_hidden` are.
2484struct XfEntry {
2485    num_fmt_id: u32,
2486    font_id: usize,
2487    fill_id: usize,
2488    border_id: usize,
2489    /// This entry's own `xfId` (index into `cellStyleXfs`) — `0` (the implicit `"Normal"` entry)
2490    /// unless [`CellFormat::named_style`] resolved to one of `Workbook::named_cell_styles`. Always
2491    /// `0` on a `cellStyleXfs` entry itself (this crate doesn't model style-to-style chaining).
2492    xf_id: usize,
2493    horizontal_alignment: Option<HorizontalAlignment>,
2494    vertical_alignment: Option<VerticalAlignment>,
2495    wrap_text: bool,
2496    indent: Option<u32>,
2497    text_rotation: Option<u16>,
2498    shrink_to_fit: bool,
2499    locked: Option<bool>,
2500    formula_hidden: Option<bool>,
2501}
2502
2503/// A dxf's inline font delta — unlike `FontEntry`, only the attributes a conditional-formatting
2504/// rule is actually likely to override (bold, italic, color); `size`/`name`/`family` are
2505/// deliberately not modeled, since a dxf font is a delta applied on top of the cell's own font
2506/// rather than a full replacement.
2507struct DxfFontEntry {
2508    bold: bool,
2509    italic: bool,
2510    color: Option<String>,
2511}
2512
2513/// One resolved `<dxf>` entry in the eventual `<dxfs>` collection — unlike `XfEntry`, every child
2514/// is inline/self-contained; it does NOT index into the shared `fonts`/`fills` collections the way
2515/// `CT_Xf` does.
2516struct DxfEntry {
2517    font: Option<DxfFontEntry>,
2518    num_fmt: Option<(u32, String)>,
2519    fill_color: Option<String>,
2520    horizontal_alignment: Option<HorizontalAlignment>,
2521    vertical_alignment: Option<VerticalAlignment>,
2522}
2523
2524/// The fully resolved collections `xl/styles.xml` needs, built from a flat list of distinct
2525/// [`CellFormat`]s (see `collect_cell_formats`) — mirrors how real Excel's `StylesTable` maintains
2526/// separate, deduplicated `numFmts`/`fonts`/`fills`/`cellXfs` collections rather than one entry per
2527/// cell. `cell_xfs[0]` is always the mandatory Excel default (no number format, default font, no
2528/// fill, no alignment); `cell_xfs[i]` for `i >= 1` corresponds to `cell_formats[i - 1]` — the same
2529/// indexing `style_index` uses when writing a cell's `s` attribute. `dxfs[i]` corresponds to
2530/// `dxf_formats[i]` — see `collect_dxfs`'s doc comment for why this is a separate index space with
2531/// no reserved slot.
2532struct StylesBuild {
2533    num_fmts: Vec<(u32, String)>,
2534    fonts: Vec<FontEntry>,
2535    fills: Vec<FillEntry>,
2536    borders: Vec<BorderEntry>,
2537    cell_xfs: Vec<XfEntry>,
2538    /// `cellStyleXfs` entries built from `named_cell_styles`, in the same order —
2539    /// `cell_style_xfs[i]` corresponds to `named_cell_styles[i]`, and is written at `cellStyleXfs`/
2540    /// `cellStyles` index `i + 1` (index `0` is always the hardcoded `"Normal"` entry, see
2541    /// `to_styles_xml`).
2542    cell_style_xfs: Vec<XfEntry>,
2543    dxfs: Vec<DxfEntry>,
2544}
2545
2546/// Converts a [`BorderEdge`] into the `(style, color)` pair `BorderEntry` stores (`None` color
2547/// resolves to opaque black, Excel's own default).
2548fn resolve_border_edge(edge: &BorderEdge) -> (BorderStyle, String) {
2549    (
2550        edge.style,
2551        edge.color.clone().unwrap_or_else(|| "FF000000".to_string()),
2552    )
2553}
2554
2555/// Resolves one [`CellFormat`]'s `numFmtId`/`fontId`/`fillId`/`borderId` against the shared,
2556/// deduplicated `num_fmts`/`fonts`/`fills`/`borders` collections being built up — appending a new
2557/// entry to whichever collection(s) this format doesn't already match one of. Factored out of
2558/// `build_styles`'s main loop so the exact same resolution logic (and the exact same shared
2559/// collections, so e.g. a font reused between an ordinary cell format and a [`NamedCellStyle`]'s
2560/// own format gets deduplicated too) can also resolve `cellStyleXfs` entries.
2561fn resolve_format_ids(
2562    format: &CellFormat,
2563    num_fmts: &mut Vec<(u32, String)>,
2564    next_num_fmt_id: &mut u32,
2565    fonts: &mut Vec<FontEntry>,
2566    fills: &mut Vec<FillEntry>,
2567    borders: &mut Vec<BorderEntry>,
2568) -> (u32, usize, usize, usize) {
2569    let num_fmt_id = match &format.number_format {
2570        None => 0,
2571        Some(code) => match num_fmts.iter().find(|(_, existing)| existing == code) {
2572            Some((id, _)) => *id,
2573            None => {
2574                let id = *next_num_fmt_id;
2575                *next_num_fmt_id += 1;
2576                num_fmts.push((id, code.clone()));
2577                id
2578            }
2579        },
2580    };
2581
2582    let font_id = if !format.bold
2583        && !format.italic
2584        && format.font_size.is_none()
2585        && format.font_color.is_none()
2586        && format.font_name.is_none()
2587        && !format.underline
2588        && !format.strike
2589    {
2590        0
2591    } else {
2592        let size = format.font_size.unwrap_or(11.0);
2593        let color = format
2594            .font_color
2595            .clone()
2596            .unwrap_or_else(|| "FF000000".to_string());
2597        let name = format
2598            .font_name
2599            .clone()
2600            .unwrap_or_else(|| "Calibri".to_string());
2601        match fonts.iter().position(|font| {
2602            font.bold == format.bold
2603                && font.italic == format.italic
2604                && font.size == size
2605                && font.color == color
2606                && font.name == name
2607                && font.underline == format.underline
2608                && font.strike == format.strike
2609        }) {
2610            Some(index) => index,
2611            None => {
2612                fonts.push(FontEntry {
2613                    bold: format.bold,
2614                    italic: format.italic,
2615                    size,
2616                    color,
2617                    name,
2618                    underline: format.underline,
2619                    strike: format.strike,
2620                });
2621                fonts.len() - 1
2622            }
2623        }
2624    };
2625
2626    // Gradient, then pattern, then plain solid fill color — see `CellFormat::pattern_fill`'s doc
2627    // comment for this precedence rule.
2628    let fill_id =
2629        if let Some(gradient) = &format.gradient_fill {
2630            match fills.iter().position(
2631                |fill| matches!(fill, FillEntry::Gradient(existing) if existing == gradient),
2632            ) {
2633                Some(index) => index,
2634                None => {
2635                    fills.push(FillEntry::Gradient(gradient.clone()));
2636                    fills.len() - 1
2637                }
2638            }
2639        } else if let Some(pattern) = &format.pattern_fill {
2640            match fills.iter().position(
2641                |fill| matches!(fill, FillEntry::Pattern(existing) if existing == pattern),
2642            ) {
2643                Some(index) => index,
2644                None => {
2645                    fills.push(FillEntry::Pattern(pattern.clone()));
2646                    fills.len() - 1
2647                }
2648            }
2649        } else {
2650            match &format.fill_color {
2651                None => 0,
2652                Some(color) => {
2653                    match fills.iter().position(
2654                        |fill| matches!(fill, FillEntry::Solid(existing) if existing == color),
2655                    ) {
2656                        Some(index) => index,
2657                        None => {
2658                            fills.push(FillEntry::Solid(color.clone()));
2659                            fills.len() - 1
2660                        }
2661                    }
2662                }
2663            }
2664        };
2665
2666    let border_id = if format.border == Border::default() {
2667        0
2668    } else {
2669        let candidate = BorderEntry {
2670            top: format.border.top.as_ref().map(resolve_border_edge),
2671            bottom: format.border.bottom.as_ref().map(resolve_border_edge),
2672            left: format.border.left.as_ref().map(resolve_border_edge),
2673            right: format.border.right.as_ref().map(resolve_border_edge),
2674            diagonal: format.border.diagonal.as_ref().map(resolve_border_edge),
2675            diagonal_up: format.border.diagonal_up,
2676            diagonal_down: format.border.diagonal_down,
2677        };
2678        match borders.iter().position(|existing| existing == &candidate) {
2679            Some(index) => index,
2680            None => {
2681                borders.push(candidate);
2682                borders.len() - 1
2683            }
2684        }
2685    };
2686
2687    (num_fmt_id, font_id, fill_id, border_id)
2688}
2689
2690fn build_styles(
2691    cell_formats: &[CellFormat],
2692    dxf_formats: &[CellFormat],
2693    named_cell_styles: &[NamedCellStyle],
2694) -> StylesBuild {
2695    let mut num_fmts: Vec<(u32, String)> = Vec::new();
2696    let mut fonts = vec![FontEntry {
2697        bold: false,
2698        italic: false,
2699        size: 11.0,
2700        color: "FF000000".to_string(),
2701        name: "Calibri".to_string(),
2702        underline: false,
2703        strike: false,
2704    }];
2705    let mut fills = vec![FillEntry::None, FillEntry::Gray125];
2706    let mut borders = vec![BorderEntry {
2707        top: None,
2708        bottom: None,
2709        left: None,
2710        right: None,
2711        diagonal: None,
2712        diagonal_up: false,
2713        diagonal_down: false,
2714    }];
2715    let mut cell_xfs = vec![XfEntry {
2716        num_fmt_id: 0,
2717        font_id: 0,
2718        fill_id: 0,
2719        border_id: 0,
2720        xf_id: 0,
2721        horizontal_alignment: None,
2722        vertical_alignment: None,
2723        wrap_text: false,
2724        indent: None,
2725        text_rotation: None,
2726        shrink_to_fit: false,
2727        locked: None,
2728        formula_hidden: None,
2729    }];
2730
2731    let mut next_num_fmt_id = FIRST_CUSTOM_NUM_FMT_ID;
2732
2733    for format in cell_formats {
2734        let (num_fmt_id, font_id, fill_id, border_id) = resolve_format_ids(
2735            format,
2736            &mut num_fmts,
2737            &mut next_num_fmt_id,
2738            &mut fonts,
2739            &mut fills,
2740            &mut borders,
2741        );
2742
2743        // Resolves `named_style` to this style's index in `named_cell_styles` —
2744        // `cellStyleXfs`/`cellStyles` index `0` is always the implicit `"Normal"` entry this crate
2745        // hardcodes (see `to_styles_xml`), so a named entry found at position `i` in
2746        // `named_cell_styles` gets `xfId = i + 1`. A name not found here, or `named_style: None`,
2747        // resolves to `0` (`"Normal"`) — see `CellFormat::named_style`'s doc comment.
2748        let xf_id = format
2749            .named_style
2750            .as_ref()
2751            .and_then(|name| {
2752                named_cell_styles
2753                    .iter()
2754                    .position(|style| &style.name == name)
2755            })
2756            .map(|index| index + 1)
2757            .unwrap_or(0);
2758
2759        cell_xfs.push(XfEntry {
2760            num_fmt_id,
2761            font_id,
2762            fill_id,
2763            border_id,
2764            xf_id,
2765            horizontal_alignment: format.horizontal_alignment,
2766            vertical_alignment: format.vertical_alignment,
2767            wrap_text: format.wrap_text,
2768            indent: format.indent,
2769            text_rotation: format.text_rotation,
2770            shrink_to_fit: format.shrink_to_fit,
2771            locked: format.locked,
2772            formula_hidden: format.formula_hidden,
2773        });
2774    }
2775
2776    // `cellStyleXfs` entries — built from `named_cell_styles`' own formats, sharing the same
2777    // `fonts`/`fills`/`borders`/`num_fmts` collections as `cellXfs` above (so a font/fill/. reused
2778    // between an ordinary cell format and a named style is deduplicated too). Each entry's own
2779    // `xfId` is always left at `0` (this crate doesn't model styles chaining to a *different*
2780    // parent style, only Excel's flat "one level" gallery).
2781    let mut cell_style_xfs: Vec<XfEntry> = Vec::new();
2782    for style in named_cell_styles {
2783        let (num_fmt_id, font_id, fill_id, border_id) = resolve_format_ids(
2784            &style.format,
2785            &mut num_fmts,
2786            &mut next_num_fmt_id,
2787            &mut fonts,
2788            &mut fills,
2789            &mut borders,
2790        );
2791        cell_style_xfs.push(XfEntry {
2792            num_fmt_id,
2793            font_id,
2794            fill_id,
2795            border_id,
2796            xf_id: 0,
2797            horizontal_alignment: style.format.horizontal_alignment,
2798            vertical_alignment: style.format.vertical_alignment,
2799            wrap_text: style.format.wrap_text,
2800            indent: style.format.indent,
2801            text_rotation: style.format.text_rotation,
2802            shrink_to_fit: style.format.shrink_to_fit,
2803            locked: style.format.locked,
2804            formula_hidden: style.format.formula_hidden,
2805        });
2806    }
2807
2808    // Dxfs share the same `numFmtId` counter (to avoid any possible id collision with cellXfs's
2809    // custom formats across the whole `styleSheet`) but are never pushed into the shared `num_fmts`
2810    // Vec — a dxf's `<numFmt>` is inline/self-contained, not an index-reference (see `DxfEntry`'s
2811    // doc comment).
2812    let mut dxfs: Vec<DxfEntry> = Vec::new();
2813    for format in dxf_formats {
2814        let font = if format.bold || format.italic || format.font_color.is_some() {
2815            Some(DxfFontEntry {
2816                bold: format.bold,
2817                italic: format.italic,
2818                color: format.font_color.clone(),
2819            })
2820        } else {
2821            None
2822        };
2823
2824        let num_fmt = format.number_format.as_ref().map(|code| {
2825            let id = match num_fmts.iter().find(|(_, existing)| existing == code) {
2826                Some((id, _)) => *id,
2827                None => {
2828                    let id = next_num_fmt_id;
2829                    next_num_fmt_id += 1;
2830                    id
2831                }
2832            };
2833            (id, code.clone())
2834        });
2835
2836        dxfs.push(DxfEntry {
2837            font,
2838            num_fmt,
2839            fill_color: format.fill_color.clone(),
2840            horizontal_alignment: format.horizontal_alignment,
2841            vertical_alignment: format.vertical_alignment,
2842        });
2843    }
2844
2845    StylesBuild {
2846        num_fmts,
2847        fonts,
2848        fills,
2849        borders,
2850        cell_xfs,
2851        cell_style_xfs,
2852        dxfs,
2853    }
2854}
2855
2856fn border_style_str(style: BorderStyle) -> &'static str {
2857    match style {
2858        BorderStyle::Thin => "thin",
2859        BorderStyle::Medium => "medium",
2860        BorderStyle::Thick => "thick",
2861        BorderStyle::Dashed => "dashed",
2862        BorderStyle::Dotted => "dotted",
2863        BorderStyle::Double => "double",
2864        BorderStyle::Hair => "hair",
2865    }
2866}
2867
2868/// Writes one `<border>` edge (`<left>`/`<right>`/`<top>`/`<bottom>`/ `<diagonal>`, `CT_BorderPr`)
2869/// — a self-closing empty element when `edge` is `None` (no line on this side), or `<name
2870/// style=".."><color rgb=".."/></name>` otherwise.
2871fn write_border_edge<W: Write>(
2872    writer: &mut Writer<W>,
2873    name: &str,
2874    edge: &Option<(BorderStyle, String)>,
2875) -> Result<()> {
2876    match edge {
2877        None => {
2878            writer.write_event(Event::Empty(BytesStart::new(name)))?;
2879        }
2880        Some((style, color)) => {
2881            let mut start = BytesStart::new(name);
2882            start.push_attribute(("style", border_style_str(*style)));
2883            writer.write_event(Event::Start(start))?;
2884            let mut color_start = BytesStart::new("color");
2885            color_start.push_attribute(("rgb", color.as_str()));
2886            writer.write_event(Event::Empty(color_start))?;
2887            writer.write_event(Event::End(BytesEnd::new(name)))?;
2888        }
2889    }
2890    Ok(())
2891}
2892
2893/// Maps a [`PatternType`] to its `ST_PatternType` token.
2894fn pattern_type_str(pattern_type: PatternType) -> &'static str {
2895    match pattern_type {
2896        PatternType::Gray125 => "gray125",
2897        PatternType::Gray0625 => "gray0625",
2898        PatternType::DarkGray => "darkGray",
2899        PatternType::MediumGray => "mediumGray",
2900        PatternType::LightGray => "lightGray",
2901        PatternType::DarkHorizontal => "darkHorizontal",
2902        PatternType::DarkVertical => "darkVertical",
2903        PatternType::DarkDown => "darkDown",
2904        PatternType::DarkUp => "darkUp",
2905        PatternType::DarkGrid => "darkGrid",
2906        PatternType::DarkTrellis => "darkTrellis",
2907        PatternType::LightHorizontal => "lightHorizontal",
2908        PatternType::LightVertical => "lightVertical",
2909        PatternType::LightDown => "lightDown",
2910        PatternType::LightUp => "lightUp",
2911        PatternType::LightGrid => "lightGrid",
2912        PatternType::LightTrellis => "lightTrellis",
2913    }
2914}
2915
2916fn horizontal_alignment_str(alignment: HorizontalAlignment) -> &'static str {
2917    match alignment {
2918        HorizontalAlignment::Left => "left",
2919        HorizontalAlignment::Center => "center",
2920        HorizontalAlignment::Right => "right",
2921    }
2922}
2923
2924fn vertical_alignment_str(alignment: VerticalAlignment) -> &'static str {
2925    match alignment {
2926        VerticalAlignment::Top => "top",
2927        VerticalAlignment::Center => "center",
2928        VerticalAlignment::Bottom => "bottom",
2929    }
2930}
2931
2932/// Writes one `<xf>` element (`CT_Xf`) — shared between `cellXfs` and `cellStyleXfs` entries (point
2933/// 58 made `cellStyleXfs` a dynamic, possibly-multi-entry collection too, no longer just the one
2934/// hardcoded `"Normal"` line). Always writes `xfId` (unlike `numFmtId`/`fontId`/.., which real
2935/// Excel also always writes even at `0`, `xfId` is technically optional per `sml.xsd` — this crate
2936/// writes it unconditionally anyway, matching this function's one previous inline call site's own
2937/// behavior before this refactor).
2938fn write_xf<W: Write>(writer: &mut Writer<W>, xf: &XfEntry) -> Result<()> {
2939    let num_fmt_id_text = xf.num_fmt_id.to_string();
2940    let font_id_text = xf.font_id.to_string();
2941    let fill_id_text = xf.fill_id.to_string();
2942    let border_id_text = xf.border_id.to_string();
2943    let xf_id_text = xf.xf_id.to_string();
2944
2945    let mut start = BytesStart::new("xf");
2946    start.push_attribute(("numFmtId", num_fmt_id_text.as_str()));
2947    start.push_attribute(("fontId", font_id_text.as_str()));
2948    start.push_attribute(("fillId", fill_id_text.as_str()));
2949    start.push_attribute(("borderId", border_id_text.as_str()));
2950    start.push_attribute(("xfId", xf_id_text.as_str()));
2951    if xf.num_fmt_id != 0 {
2952        start.push_attribute(("applyNumberFormat", "1"));
2953    }
2954    if xf.border_id != 0 {
2955        start.push_attribute(("applyBorder", "1"));
2956    }
2957    if xf.font_id != 0 {
2958        start.push_attribute(("applyFont", "1"));
2959    }
2960    if xf.fill_id != 0 {
2961        start.push_attribute(("applyFill", "1"));
2962    }
2963
2964    // `CT_Xf`'s sequence: `alignment?`, `protection?`.
2965    // `wrap_text`/`indent`/`text_rotation`/`shrink_to_fit` and `locked`/`formula_hidden` are.
2966    let needs_alignment = xf.horizontal_alignment.is_some()
2967        || xf.vertical_alignment.is_some()
2968        || xf.wrap_text
2969        || xf.indent.is_some()
2970        || xf.text_rotation.is_some()
2971        || xf.shrink_to_fit;
2972    let needs_protection = xf.locked.is_some() || xf.formula_hidden.is_some();
2973
2974    if needs_alignment {
2975        start.push_attribute(("applyAlignment", "1"));
2976    }
2977    if needs_protection {
2978        start.push_attribute(("applyProtection", "1"));
2979    }
2980
2981    if !needs_alignment && !needs_protection {
2982        writer.write_event(Event::Empty(start))?;
2983        return Ok(());
2984    }
2985    writer.write_event(Event::Start(start))?;
2986
2987    if needs_alignment {
2988        let indent_text = xf.indent.map(|value| value.to_string());
2989        let text_rotation_text = xf.text_rotation.map(|value| value.to_string());
2990        let mut alignment = BytesStart::new("alignment");
2991        if let Some(horizontal) = xf.horizontal_alignment {
2992            alignment.push_attribute(("horizontal", horizontal_alignment_str(horizontal)));
2993        }
2994        if let Some(vertical) = xf.vertical_alignment {
2995            alignment.push_attribute(("vertical", vertical_alignment_str(vertical)));
2996        }
2997        if xf.wrap_text {
2998            alignment.push_attribute(("wrapText", "1"));
2999        }
3000        if let Some(indent_text) = &indent_text {
3001            alignment.push_attribute(("indent", indent_text.as_str()));
3002        }
3003        if let Some(text_rotation_text) = &text_rotation_text {
3004            alignment.push_attribute(("textRotation", text_rotation_text.as_str()));
3005        }
3006        if xf.shrink_to_fit {
3007            alignment.push_attribute(("shrinkToFit", "1"));
3008        }
3009        writer.write_event(Event::Empty(alignment))?;
3010    }
3011
3012    if needs_protection {
3013        let mut protection = BytesStart::new("protection");
3014        if let Some(locked) = xf.locked {
3015            protection.push_attribute(("locked", if locked { "1" } else { "0" }));
3016        }
3017        if let Some(formula_hidden) = xf.formula_hidden {
3018            protection.push_attribute(("hidden", if formula_hidden { "1" } else { "0" }));
3019        }
3020        writer.write_event(Event::Empty(protection))?;
3021    }
3022
3023    writer.write_event(Event::End(BytesEnd::new("xf")))?;
3024    Ok(())
3025}
3026
3027/// Serializes `xl/styles.xml` (`CT_Stylesheet`) from the workbook's distinct cell formats — always
3028/// carries at least the minimum real Excel expects to open a file without a repair prompt (see this
3029/// module's `STYLES_CONTENT_TYPE` doc comment: one font, two fills in `none`/ `gray125` order, one
3030/// empty border, one `cellStyleXfs`/`cellXfs` entry), then appends one more `cellXfs` entry (and
3031/// any `numFmts`/`fonts`/ `fills` entries it needs) per distinct [`CellFormat`] actually used — see
3032/// `build_styles`. The one font this crate ever writes by default (and reuses for every format that
3033/// doesn't override any font attribute) is plain black Calibri, referenced by literal RGB rather
3034/// than a theme color index, since this crate doesn't write `xl/theme/theme1.xml` yet. Also writes
3035/// `<tableStyles>` from `table_styles`, if any.
3036fn to_styles_xml(
3037    cell_formats: &[CellFormat],
3038    dxf_formats: &[CellFormat],
3039    table_styles: &[TableStyle],
3040    named_cell_styles: &[NamedCellStyle],
3041) -> Result<Vec<u8>> {
3042    let built = build_styles(cell_formats, dxf_formats, named_cell_styles);
3043    let mut writer = Writer::new(Vec::new());
3044
3045    writer.write_event(Event::Decl(BytesDecl::new(
3046        "1.0",
3047        Some("UTF-8"),
3048        Some("yes"),
3049    )))?;
3050
3051    let mut root = BytesStart::new("styleSheet");
3052    root.push_attribute(("xmlns", SPREADSHEETML_NAMESPACE));
3053    writer.write_event(Event::Start(root))?;
3054
3055    if !built.num_fmts.is_empty() {
3056        let count = built.num_fmts.len().to_string();
3057        let mut start = BytesStart::new("numFmts");
3058        start.push_attribute(("count", count.as_str()));
3059        writer.write_event(Event::Start(start))?;
3060        for (id, code) in &built.num_fmts {
3061            let id_text = id.to_string();
3062            let mut num_fmt = BytesStart::new("numFmt");
3063            num_fmt.push_attribute(("numFmtId", id_text.as_str()));
3064            num_fmt.push_attribute(("formatCode", code.as_str()));
3065            writer.write_event(Event::Empty(num_fmt))?;
3066        }
3067        writer.write_event(Event::End(BytesEnd::new("numFmts")))?;
3068    }
3069
3070    let fonts_count = built.fonts.len().to_string();
3071    let mut fonts_start = BytesStart::new("fonts");
3072    fonts_start.push_attribute(("count", fonts_count.as_str()));
3073    writer.write_event(Event::Start(fonts_start))?;
3074    for font in &built.fonts {
3075        writer.write_event(Event::Start(BytesStart::new("font")))?;
3076        if font.bold {
3077            writer.write_event(Event::Empty(BytesStart::new("b")))?;
3078        }
3079        if font.italic {
3080            writer.write_event(Event::Empty(BytesStart::new("i")))?;
3081        }
3082        // `strike`/`u`. `u` is written with no `val` attribute (`CT_UnderlineProperty` defaults to
3083        // `"single"` when omitted, this crate only ever models a plain single underline, see
3084        // `CellFormat::underline`'s doc comment).
3085        if font.strike {
3086            writer.write_event(Event::Empty(BytesStart::new("strike")))?;
3087        }
3088        let size_text = font.size.to_string();
3089        let mut size = BytesStart::new("sz");
3090        size.push_attribute(("val", size_text.as_str()));
3091        writer.write_event(Event::Empty(size))?;
3092        let mut color = BytesStart::new("color");
3093        color.push_attribute(("rgb", font.color.as_str()));
3094        writer.write_event(Event::Empty(color))?;
3095        if font.underline {
3096            writer.write_event(Event::Empty(BytesStart::new("u")))?;
3097        }
3098        let mut name = BytesStart::new("name");
3099        name.push_attribute(("val", font.name.as_str()));
3100        writer.write_event(Event::Empty(name))?;
3101        let mut family = BytesStart::new("family");
3102        family.push_attribute(("val", "2"));
3103        writer.write_event(Event::Empty(family))?;
3104        writer.write_event(Event::End(BytesEnd::new("font")))?;
3105    }
3106    writer.write_event(Event::End(BytesEnd::new("fonts")))?;
3107
3108    let fills_count = built.fills.len().to_string();
3109    let mut fills_start = BytesStart::new("fills");
3110    fills_start.push_attribute(("count", fills_count.as_str()));
3111    writer.write_event(Event::Start(fills_start))?;
3112    for fill in &built.fills {
3113        writer.write_event(Event::Start(BytesStart::new("fill")))?;
3114        match fill {
3115            FillEntry::None => {
3116                let mut pattern = BytesStart::new("patternFill");
3117                pattern.push_attribute(("patternType", "none"));
3118                writer.write_event(Event::Empty(pattern))?;
3119            }
3120            FillEntry::Gray125 => {
3121                let mut pattern = BytesStart::new("patternFill");
3122                pattern.push_attribute(("patternType", "gray125"));
3123                writer.write_event(Event::Empty(pattern))?;
3124            }
3125            FillEntry::Solid(color) => {
3126                let mut pattern = BytesStart::new("patternFill");
3127                pattern.push_attribute(("patternType", "solid"));
3128                writer.write_event(Event::Start(pattern))?;
3129                let mut fg_color = BytesStart::new("fgColor");
3130                fg_color.push_attribute(("rgb", color.as_str()));
3131                writer.write_event(Event::Empty(fg_color))?;
3132                writer.write_event(Event::End(BytesEnd::new("patternFill")))?;
3133            }
3134            // A non-solid pattern fill.
3135            FillEntry::Pattern(pattern_fill) => {
3136                let mut pattern = BytesStart::new("patternFill");
3137                pattern
3138                    .push_attribute(("patternType", pattern_type_str(pattern_fill.pattern_type)));
3139                writer.write_event(Event::Start(pattern))?;
3140                if let Some(fg_color) = &pattern_fill.fg_color {
3141                    let mut fg = BytesStart::new("fgColor");
3142                    fg.push_attribute(("rgb", fg_color.as_str()));
3143                    writer.write_event(Event::Empty(fg))?;
3144                }
3145                if let Some(bg_color) = &pattern_fill.bg_color {
3146                    let mut bg = BytesStart::new("bgColor");
3147                    bg.push_attribute(("rgb", bg_color.as_str()));
3148                    writer.write_event(Event::Empty(bg))?;
3149                }
3150                writer.write_event(Event::End(BytesEnd::new("patternFill")))?;
3151            }
3152            // A linear gradient fill. `degree` is `CT_GradientFill`'s angle attribute (no `type`
3153            // attribute written, defaulting to `"linear"`, see `GradientFill`'s doc comment).
3154            FillEntry::Gradient(gradient) => {
3155                let degree_text = gradient.angle.to_string();
3156                let mut gradient_start = BytesStart::new("gradientFill");
3157                gradient_start.push_attribute(("degree", degree_text.as_str()));
3158                writer.write_event(Event::Start(gradient_start))?;
3159                for stop in &gradient.stops {
3160                    let position_text = stop.position.to_string();
3161                    let mut gs = BytesStart::new("stop");
3162                    gs.push_attribute(("position", position_text.as_str()));
3163                    writer.write_event(Event::Start(gs))?;
3164                    let mut color = BytesStart::new("color");
3165                    color.push_attribute(("rgb", stop.color.as_str()));
3166                    writer.write_event(Event::Empty(color))?;
3167                    writer.write_event(Event::End(BytesEnd::new("stop")))?;
3168                }
3169                writer.write_event(Event::End(BytesEnd::new("gradientFill")))?;
3170            }
3171        }
3172        writer.write_event(Event::End(BytesEnd::new("fill")))?;
3173    }
3174    writer.write_event(Event::End(BytesEnd::new("fills")))?;
3175
3176    // Each `<border>` entry's 5 possible children (`left`, `right`, `top`, `bottom`, `diagonal`, in
3177    // that `CT_Border` sequence order) are each written as `<left style=".."><color
3178    // rgb=".."/></left>` when set, or a self-closing `<left/>` when not — real Excel always writes
3179    // all 5, even when empty, so this crate does too (matches the previous hardcoded single empty
3180    // border this replaces).
3181    let borders_count = built.borders.len().to_string();
3182    let mut borders_start = BytesStart::new("borders");
3183    borders_start.push_attribute(("count", borders_count.as_str()));
3184    writer.write_event(Event::Start(borders_start))?;
3185    for border in &built.borders {
3186        let mut border_start = BytesStart::new("border");
3187        if border.diagonal_up {
3188            border_start.push_attribute(("diagonalUp", "1"));
3189        }
3190        if border.diagonal_down {
3191            border_start.push_attribute(("diagonalDown", "1"));
3192        }
3193        writer.write_event(Event::Start(border_start))?;
3194        write_border_edge(&mut writer, "left", &border.left)?;
3195        write_border_edge(&mut writer, "right", &border.right)?;
3196        write_border_edge(&mut writer, "top", &border.top)?;
3197        write_border_edge(&mut writer, "bottom", &border.bottom)?;
3198        write_border_edge(&mut writer, "diagonal", &border.diagonal)?;
3199        writer.write_event(Event::End(BytesEnd::new("border")))?;
3200    }
3201    writer.write_event(Event::End(BytesEnd::new("borders")))?;
3202
3203    // `cellStyleXfs` — index `0` is always the implicit `"Normal"` entry this crate hardcodes
3204    // (matching the fixed single-entry output before point 58 existed); one more entry per
3205    // [`NamedCellStyle`] in `named_cell_styles`/`built.cell_style_xfs`, same order.
3206    let cell_style_xfs_count = (1 + built.cell_style_xfs.len()).to_string();
3207    let mut cell_style_xfs_start = BytesStart::new("cellStyleXfs");
3208    cell_style_xfs_start.push_attribute(("count", cell_style_xfs_count.as_str()));
3209    writer.write_event(Event::Start(cell_style_xfs_start))?;
3210    writer.write_raw(r#"<xf numFmtId="0" fontId="0" fillId="0" borderId="0"/>"#)?;
3211    for xf in &built.cell_style_xfs {
3212        write_xf(&mut writer, xf)?;
3213    }
3214    writer.write_event(Event::End(BytesEnd::new("cellStyleXfs")))?;
3215
3216    let cell_xfs_count = built.cell_xfs.len().to_string();
3217    let mut cell_xfs_start = BytesStart::new("cellXfs");
3218    cell_xfs_start.push_attribute(("count", cell_xfs_count.as_str()));
3219    writer.write_event(Event::Start(cell_xfs_start))?;
3220    for xf in &built.cell_xfs {
3221        write_xf(&mut writer, xf)?;
3222    }
3223    writer.write_event(Event::End(BytesEnd::new("cellXfs")))?;
3224
3225    // `cellStyles` — same index correspondence as `cellStyleXfs` above: index `0` is the hardcoded
3226    // `"Normal"` entry, index `i + 1` is `named_cell_styles[i]`.
3227    let cell_styles_count = (1 + named_cell_styles.len()).to_string();
3228    let mut cell_styles_start = BytesStart::new("cellStyles");
3229    cell_styles_start.push_attribute(("count", cell_styles_count.as_str()));
3230    writer.write_event(Event::Start(cell_styles_start))?;
3231    writer.write_raw(r#"<cellStyle name="Normal" xfId="0" builtinId="0"/>"#)?;
3232    for (index, style) in named_cell_styles.iter().enumerate() {
3233        let xf_id_text = (index + 1).to_string();
3234        let mut cell_style = BytesStart::new("cellStyle");
3235        cell_style.push_attribute(("name", style.name.as_str()));
3236        cell_style.push_attribute(("xfId", xf_id_text.as_str()));
3237        if let Some(builtin_id) = style.builtin_id {
3238            let builtin_id_text = builtin_id.to_string();
3239            cell_style.push_attribute(("builtinId", builtin_id_text.as_str()));
3240        }
3241        writer.write_event(Event::Empty(cell_style))?;
3242    }
3243    writer.write_event(Event::End(BytesEnd::new("cellStyles")))?;
3244
3245    if !built.dxfs.is_empty() {
3246        let dxfs_count = built.dxfs.len().to_string();
3247        let mut dxfs_start = BytesStart::new("dxfs");
3248        dxfs_start.push_attribute(("count", dxfs_count.as_str()));
3249        writer.write_event(Event::Start(dxfs_start))?;
3250        for dxf in &built.dxfs {
3251            writer.write_event(Event::Start(BytesStart::new("dxf")))?;
3252
3253            // `CT_Dxf`'s sequence: font, numFmt, fill, alignment (then border/protection/extLst,
3254            // none of which this crate writes).
3255            if let Some(font) = &dxf.font {
3256                writer.write_event(Event::Start(BytesStart::new("font")))?;
3257                if font.bold {
3258                    writer.write_event(Event::Empty(BytesStart::new("b")))?;
3259                }
3260                if font.italic {
3261                    writer.write_event(Event::Empty(BytesStart::new("i")))?;
3262                }
3263                if let Some(color) = &font.color {
3264                    let mut color_start = BytesStart::new("color");
3265                    color_start.push_attribute(("rgb", color.as_str()));
3266                    writer.write_event(Event::Empty(color_start))?;
3267                }
3268                writer.write_event(Event::End(BytesEnd::new("font")))?;
3269            }
3270
3271            if let Some((id, code)) = &dxf.num_fmt {
3272                let id_text = id.to_string();
3273                let mut num_fmt = BytesStart::new("numFmt");
3274                num_fmt.push_attribute(("numFmtId", id_text.as_str()));
3275                num_fmt.push_attribute(("formatCode", code.as_str()));
3276                writer.write_event(Event::Empty(num_fmt))?;
3277            }
3278
3279            if let Some(color) = &dxf.fill_color {
3280                writer.write_event(Event::Start(BytesStart::new("fill")))?;
3281                let mut pattern = BytesStart::new("patternFill");
3282                pattern.push_attribute(("patternType", "solid"));
3283                writer.write_event(Event::Start(pattern))?;
3284                // A dxf's solid fill color is `<bgColor>`, not `<fgColor>` as a normal
3285                // cellXfs-referenced fill is — a real, easy-to-miss Excel/OOXML quirk.
3286                let mut bg_color = BytesStart::new("bgColor");
3287                bg_color.push_attribute(("rgb", color.as_str()));
3288                writer.write_event(Event::Empty(bg_color))?;
3289                writer.write_event(Event::End(BytesEnd::new("patternFill")))?;
3290                writer.write_event(Event::End(BytesEnd::new("fill")))?;
3291            }
3292
3293            if dxf.horizontal_alignment.is_some() || dxf.vertical_alignment.is_some() {
3294                let mut alignment = BytesStart::new("alignment");
3295                if let Some(horizontal) = dxf.horizontal_alignment {
3296                    alignment.push_attribute(("horizontal", horizontal_alignment_str(horizontal)));
3297                }
3298                if let Some(vertical) = dxf.vertical_alignment {
3299                    alignment.push_attribute(("vertical", vertical_alignment_str(vertical)));
3300                }
3301                writer.write_event(Event::Empty(alignment))?;
3302            }
3303
3304            writer.write_event(Event::End(BytesEnd::new("dxf")))?;
3305        }
3306        writer.write_event(Event::End(BytesEnd::new("dxfs")))?;
3307    }
3308
3309    // `<tableStyles>`/`<tableStyle>`/`<tableStyleElement>`. `CT_Stylesheet`'s own sequence puts
3310    // `tableStyles` right after `dxfs`, before `colors` (not written by this crate, confirmed via
3311    // `sml.xsd`). Each element's `dxfId` resolves against `dxf_formats` — the very same shared,
3312    // deduplicated collection a conditional formatting rule's own `dxfId` uses (see
3313    // `collect_dxfs`).
3314    if !table_styles.is_empty() {
3315        let count_text = table_styles.len().to_string();
3316        let mut table_styles_start = BytesStart::new("tableStyles");
3317        table_styles_start.push_attribute(("count", count_text.as_str()));
3318        writer.write_event(Event::Start(table_styles_start))?;
3319        for style in table_styles {
3320            let element_count_text = style.elements.len().to_string();
3321            let mut table_style_start = BytesStart::new("tableStyle");
3322            table_style_start.push_attribute(("name", style.name.as_str()));
3323            table_style_start.push_attribute(("pivot", "0"));
3324            table_style_start.push_attribute(("count", element_count_text.as_str()));
3325            writer.write_event(Event::Start(table_style_start))?;
3326            for element in &style.elements {
3327                let dxf_id = dxf_formats
3328                    .iter()
3329                    .position(|format| format == &element.format)
3330                    .unwrap_or(0);
3331                let dxf_id_text = dxf_id.to_string();
3332                let mut element_start = BytesStart::new("tableStyleElement");
3333                element_start
3334                    .push_attribute(("type", table_style_element_type_str(element.element_type)));
3335                element_start.push_attribute(("dxfId", dxf_id_text.as_str()));
3336                writer.write_event(Event::Empty(element_start))?;
3337            }
3338            writer.write_event(Event::End(BytesEnd::new("tableStyle")))?;
3339        }
3340        writer.write_event(Event::End(BytesEnd::new("tableStyles")))?;
3341    }
3342
3343    writer.write_event(Event::End(BytesEnd::new("styleSheet")))?;
3344
3345    Ok(writer.into_inner())
3346}
3347
3348/// Maps a [`TableStyleElementType`] to its `ST_TableStyleType` token — see that enum's own doc
3349/// comment for which values are (and aren't) modeled.
3350fn table_style_element_type_str(element_type: TableStyleElementType) -> &'static str {
3351    match element_type {
3352        TableStyleElementType::WholeTable => "wholeTable",
3353        TableStyleElementType::HeaderRow => "headerRow",
3354        TableStyleElementType::TotalRow => "totalRow",
3355        TableStyleElementType::FirstRowStripe => "firstRowStripe",
3356        TableStyleElementType::SecondRowStripe => "secondRowStripe",
3357        TableStyleElementType::FirstColumnStripe => "firstColumnStripe",
3358        TableStyleElementType::SecondColumnStripe => "secondColumnStripe",
3359        TableStyleElementType::FirstColumn => "firstColumn",
3360        TableStyleElementType::LastColumn => "lastColumn",
3361    }
3362}
3363
3364/// Serializes `xl/sharedStrings.xml` (`CT_Sst`) from a [`SharedStrings`] collection.
3365fn to_shared_strings_xml(shared_strings: &SharedStrings) -> Result<Vec<u8>> {
3366    let mut writer = Writer::new(Vec::new());
3367
3368    writer.write_event(Event::Decl(BytesDecl::new(
3369        "1.0",
3370        Some("UTF-8"),
3371        Some("yes"),
3372    )))?;
3373
3374    let mut root = BytesStart::new("sst");
3375    root.push_attribute(("xmlns", SPREADSHEETML_NAMESPACE));
3376    let count = shared_strings.total_count.to_string();
3377    let unique_count = shared_strings.entries.len().to_string();
3378    root.push_attribute(("count", count.as_str()));
3379    root.push_attribute(("uniqueCount", unique_count.as_str()));
3380    writer.write_event(Event::Start(root))?;
3381
3382    for entry in &shared_strings.entries {
3383        match entry {
3384            SharedStringEntry::Plain(text) => {
3385                writer.write_event(Event::Start(BytesStart::new("si")))?;
3386                writer.write_event(Event::Start(BytesStart::new("t")))?;
3387                writer.write_event(Event::Text(BytesText::new(text)))?;
3388                writer.write_event(Event::End(BytesEnd::new("t")))?;
3389                writer.write_event(Event::End(BytesEnd::new("si")))?;
3390            }
3391            // A rich-text `<si>` holds one `<r>` per run instead of a single `<t>` — extended for
3392            // `rFont`/`strike`/`u`. `CT_RPrElt`'s sequence: `rFont?`, `charset?`, `family?`, `b?`,
3393            // `i?`, `strike?`., `color?`, `sz?`, `u?`. — note `rFont` uses that element name, not
3394            // `name` as `CT_Font` (`xl/styles.xml`'s own font entries) does.
3395            SharedStringEntry::Rich(runs) => {
3396                writer.write_event(Event::Start(BytesStart::new("si")))?;
3397                for run in runs {
3398                    write_rich_text_run(&mut writer, run)?;
3399                }
3400                writer.write_event(Event::End(BytesEnd::new("si")))?;
3401            }
3402        }
3403    }
3404
3405    writer.write_event(Event::End(BytesEnd::new("sst")))?;
3406
3407    Ok(writer.into_inner())
3408}
3409
3410/// Serializes one `<r>..</r>` run (`CT_RElt`) from a [`RichTextRun`] — shared between
3411/// `xl/sharedStrings.xml`'s rich-text `<si>` entries and `xl/commentsN.xml`'s rich-text comment
3412/// bodies — both reuse the exact same `CT_RElt`/`CT_RPrElt` shape. See `to_shared_strings_xml`'s
3413/// own doc comment on the `Rich` arm for `CT_RPrElt`'s child sequence and the `rFont`-vs-`name`
3414/// element-name quirk.
3415fn write_rich_text_run<W: Write>(writer: &mut Writer<W>, run: &RichTextRun) -> Result<()> {
3416    writer.write_event(Event::Start(BytesStart::new("r")))?;
3417    let has_run_properties = run.bold
3418        || run.italic
3419        || run.font_size.is_some()
3420        || run.font_color.is_some()
3421        || run.font_name.is_some()
3422        || run.underline
3423        || run.strike;
3424    if has_run_properties {
3425        writer.write_event(Event::Start(BytesStart::new("rPr")))?;
3426        if let Some(font_name) = &run.font_name {
3427            let mut r_font = BytesStart::new("rFont");
3428            r_font.push_attribute(("val", font_name.as_str()));
3429            writer.write_event(Event::Empty(r_font))?;
3430        }
3431        if run.bold {
3432            writer.write_event(Event::Empty(BytesStart::new("b")))?;
3433        }
3434        if run.italic {
3435            writer.write_event(Event::Empty(BytesStart::new("i")))?;
3436        }
3437        if run.strike {
3438            writer.write_event(Event::Empty(BytesStart::new("strike")))?;
3439        }
3440        if let Some(size) = run.font_size {
3441            let size_text = size.to_string();
3442            let mut sz = BytesStart::new("sz");
3443            sz.push_attribute(("val", size_text.as_str()));
3444            writer.write_event(Event::Empty(sz))?;
3445        }
3446        if let Some(color) = &run.font_color {
3447            let mut color_start = BytesStart::new("color");
3448            color_start.push_attribute(("rgb", color.as_str()));
3449            writer.write_event(Event::Empty(color_start))?;
3450        }
3451        if run.underline {
3452            writer.write_event(Event::Empty(BytesStart::new("u")))?;
3453        }
3454        writer.write_event(Event::End(BytesEnd::new("rPr")))?;
3455    }
3456    writer.write_event(Event::Start(BytesStart::new("t")))?;
3457    writer.write_event(Event::Text(BytesText::new(&run.text)))?;
3458    writer.write_event(Event::End(BytesEnd::new("t")))?;
3459    writer.write_event(Event::End(BytesEnd::new("r")))?;
3460    Ok(())
3461}
3462
3463/// Serializes `docProps/core.xml` from a workbook's [`DocumentProperties`] — (before this, always
3464/// empty boilerplate, matching `word-ooxml`'s own before `DocumentProperties` existed there). Every
3465/// child of `CT_CoreProperties` has `minOccurs="0"`, so an empty `<cp:coreProperties>` root remains
3466/// schema-valid when every field is `None`.
3467fn to_core_properties_xml(properties: &DocumentProperties) -> Result<Vec<u8>> {
3468    let mut writer = Writer::new(Vec::new());
3469
3470    writer.write_event(Event::Decl(BytesDecl::new(
3471        "1.0",
3472        Some("UTF-8"),
3473        Some("yes"),
3474    )))?;
3475
3476    let mut root = BytesStart::new("cp:coreProperties");
3477    root.push_attribute(("xmlns:cp", CORE_PROPERTIES_NAMESPACE));
3478    root.push_attribute(("xmlns:dc", DC_NAMESPACE));
3479    root.push_attribute(("xmlns:dcterms", DCTERMS_NAMESPACE));
3480    root.push_attribute(("xmlns:xsi", XSI_NAMESPACE));
3481
3482    let has_any = properties.title.is_some()
3483        || properties.author.is_some()
3484        || properties.subject.is_some()
3485        || properties.keywords.is_some();
3486    if !has_any {
3487        writer.write_event(Event::Empty(root))?;
3488        return Ok(writer.into_inner());
3489    }
3490    writer.write_event(Event::Start(root))?;
3491
3492    if let Some(title) = &properties.title {
3493        write_element_text(&mut writer, "dc:title", title)?;
3494    }
3495    if let Some(author) = &properties.author {
3496        write_element_text(&mut writer, "dc:creator", author)?;
3497    }
3498    if let Some(subject) = &properties.subject {
3499        write_element_text(&mut writer, "dc:subject", subject)?;
3500    }
3501    if let Some(keywords) = &properties.keywords {
3502        write_element_text(&mut writer, "cp:keywords", keywords)?;
3503    }
3504
3505    writer.write_event(Event::End(BytesEnd::new("cp:coreProperties")))?;
3506
3507    Ok(writer.into_inner())
3508}
3509
3510/// Serializes `docProps/app.xml` from a workbook's [`DocumentProperties`] — extended by
3511/// (`Manager`/`HyperlinkBase`). `Application` is always set, to identify this library as the
3512/// workbook's producer (mirroring `word-ooxml`'s own `to_extended_properties_xml`);
3513/// `Company`/`Manager`/`HyperlinkBase` are each set only when the matching `DocumentProperties`
3514/// field is.
3515fn to_extended_properties_xml(properties: &DocumentProperties) -> Result<Vec<u8>> {
3516    let mut writer = Writer::new(Vec::new());
3517
3518    writer.write_event(Event::Decl(BytesDecl::new(
3519        "1.0",
3520        Some("UTF-8"),
3521        Some("yes"),
3522    )))?;
3523
3524    let mut root = BytesStart::new("Properties");
3525    root.push_attribute(("xmlns", EXTENDED_PROPERTIES_NAMESPACE));
3526    writer.write_event(Event::Start(root))?;
3527
3528    writer.write_event(Event::Start(BytesStart::new("Application")))?;
3529    writer.write_event(Event::Text(BytesText::new("office-toolkit")))?;
3530    writer.write_event(Event::End(BytesEnd::new("Application")))?;
3531
3532    if let Some(company) = &properties.company {
3533        write_element_text(&mut writer, "Company", company)?;
3534    }
3535    if let Some(manager) = &properties.manager {
3536        write_element_text(&mut writer, "Manager", manager)?;
3537    }
3538    if let Some(hyperlink_base) = &properties.hyperlink_base {
3539        write_element_text(&mut writer, "HyperlinkBase", hyperlink_base)?;
3540    }
3541
3542    writer.write_event(Event::End(BytesEnd::new("Properties")))?;
3543
3544    Ok(writer.into_inner())
3545}
3546
3547/// Serializes `docProps/custom.xml` (OPC custom properties) from a workbook's
3548/// [`DocumentProperties::custom_properties`] — point 56, only called when non-empty. Each entry
3549/// becomes a `<property>` element (`CT_Property`) with the fixed custom-properties `fmtid`, a `pid`
3550/// assigned sequentially starting at `FIRST_CUSTOM_PROPERTY_PID` in the caller's own order (not
3551/// re-sorted), the entry's name, and a single variant-type child chosen from
3552/// [`CustomPropertyValue`]'s variant (`vt:lpwstr`/`vt:bool`/`vt:i4`/ `vt:r8`) — an exact mirror of
3553/// `word-ooxml`'s own `to_custom_properties_xml`.
3554fn to_custom_properties_xml(
3555    custom_properties: &[(String, CustomPropertyValue)],
3556) -> Result<Vec<u8>> {
3557    let mut writer = Writer::new(Vec::new());
3558
3559    writer.write_event(Event::Decl(BytesDecl::new(
3560        "1.0",
3561        Some("UTF-8"),
3562        Some("yes"),
3563    )))?;
3564
3565    let mut root = BytesStart::new("Properties");
3566    root.push_attribute(("xmlns", CUSTOM_PROPERTIES_NAMESPACE));
3567    root.push_attribute(("xmlns:vt", CUSTOM_PROPERTIES_VT_NAMESPACE));
3568    writer.write_event(Event::Start(root))?;
3569
3570    for (index, (name, value)) in custom_properties.iter().enumerate() {
3571        let pid = FIRST_CUSTOM_PROPERTY_PID + index as i32;
3572
3573        let mut property = BytesStart::new("property");
3574        property.push_attribute(("fmtid", CUSTOM_PROPERTY_FMTID));
3575        property.push_attribute(("pid", pid.to_string().as_str()));
3576        property.push_attribute(("name", name.as_str()));
3577        writer.write_event(Event::Start(property))?;
3578
3579        let (variant_element, text) = match value {
3580            CustomPropertyValue::Text(text) => ("vt:lpwstr", text.clone()),
3581            CustomPropertyValue::Bool(value) => (
3582                "vt:bool",
3583                if *value {
3584                    "true".to_string()
3585                } else {
3586                    "false".to_string()
3587                },
3588            ),
3589            CustomPropertyValue::Int(value) => ("vt:i4", value.to_string()),
3590            CustomPropertyValue::Number(value) => ("vt:r8", value.to_string()),
3591        };
3592        writer.write_event(Event::Start(BytesStart::new(variant_element)))?;
3593        writer.write_event(Event::Text(BytesText::new(&text)))?;
3594        writer.write_event(Event::End(BytesEnd::new(variant_element)))?;
3595
3596        writer.write_event(Event::End(BytesEnd::new("property")))?;
3597    }
3598
3599    writer.write_event(Event::End(BytesEnd::new("Properties")))?;
3600
3601    Ok(writer.into_inner())
3602}
3603
3604/// Serializes `xl/theme/theme1.xml` (`<a:theme>`, `CT_OfficeStyleSheet`). Always the fixed default
3605/// Office color/font scheme (the same well-known values every new Office document uses out of the
3606/// box: `dk1`=black, `lt1`=white, `dk2`=`44546A`, `lt2`=`E7E6E6`, `accent1`-`accent6`,
3607/// `hlink`=`0563C1`, `folHlink`=`954F72`, major/minor Latin font `"Calibri Light"`/`"Calibri"`) —
3608/// this crate has no notion of a customizable theme model for Excel, unlike `word-ooxml`'s
3609/// `Theme`/`ColorScheme`/`FontScheme` (out of scope: no cell in this crate's model references a
3610/// theme color by index, only literal RGB, so the theme's *sole* purpose here is to exist as a
3611/// schema-complete, real-Excel-shaped part). `fmtScheme` is the same fixed Office "style matrix"
3612/// boilerplate `word-ooxml`'s own `to_theme_xml` writes verbatim (see that crate's
3613/// `FIXED_FORMAT_SCHEME_XML` doc comment for why).
3614fn to_theme_xml() -> Vec<u8> {
3615    const THEME_XML: &str = concat!(
3616        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
3617        r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office">"#,
3618        "<a:themeElements>",
3619        r#"<a:clrScheme name="Office">"#,
3620        r#"<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>"#,
3621        r#"<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>"#,
3622        r#"<a:dk2><a:srgbClr val="44546A"/></a:dk2>"#,
3623        r#"<a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>"#,
3624        r#"<a:accent1><a:srgbClr val="4472C4"/></a:accent1>"#,
3625        r#"<a:accent2><a:srgbClr val="ED7D31"/></a:accent2>"#,
3626        r#"<a:accent3><a:srgbClr val="A5A5A5"/></a:accent3>"#,
3627        r#"<a:accent4><a:srgbClr val="FFC000"/></a:accent4>"#,
3628        r#"<a:accent5><a:srgbClr val="5B9BD5"/></a:accent5>"#,
3629        r#"<a:accent6><a:srgbClr val="70AD47"/></a:accent6>"#,
3630        r#"<a:hlink><a:srgbClr val="0563C1"/></a:hlink>"#,
3631        r#"<a:folHlink><a:srgbClr val="954F72"/></a:folHlink>"#,
3632        "</a:clrScheme>",
3633        r#"<a:fontScheme name="Office">"#,
3634        r#"<a:majorFont><a:latin typeface="Calibri Light"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>"#,
3635        r#"<a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>"#,
3636        "</a:fontScheme>",
3637        r#"<a:fmtScheme name="Office">"#,
3638        "<a:fillStyleLst>",
3639        r#"<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>"#,
3640        r#"<a:gradFill rotWithShape="1"><a:gsLst>"#,
3641        r#"<a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="50000"/><a:satMod val="300000"/></a:schemeClr></a:gs>"#,
3642        r#"<a:gs pos="35000"><a:schemeClr val="phClr"><a:tint val="37000"/><a:satMod val="300000"/></a:schemeClr></a:gs>"#,
3643        r#"<a:gs pos="100000"><a:schemeClr val="phClr"><a:tint val="15000"/><a:satMod val="350000"/></a:schemeClr></a:gs>"#,
3644        r#"</a:gsLst><a:lin ang="16200000" scaled="1"/></a:gradFill>"#,
3645        r#"<a:gradFill rotWithShape="1"><a:gsLst>"#,
3646        r#"<a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="100000"/><a:shade val="100000"/><a:satMod val="130000"/></a:schemeClr></a:gs>"#,
3647        r#"<a:gs pos="100000"><a:schemeClr val="phClr"><a:tint val="50000"/><a:shade val="100000"/><a:satMod val="350000"/></a:schemeClr></a:gs>"#,
3648        r#"</a:gsLst><a:lin ang="16200000" scaled="0"/></a:gradFill>"#,
3649        "</a:fillStyleLst>",
3650        "<a:lnStyleLst>",
3651        r#"<a:ln w="9525" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"><a:shade val="95000"/><a:satMod val="105000"/></a:schemeClr></a:solidFill><a:prstDash val="solid"/></a:ln>"#,
3652        r#"<a:ln w="25400" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln>"#,
3653        r#"<a:ln w="38100" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln>"#,
3654        "</a:lnStyleLst>",
3655        "<a:effectStyleLst>",
3656        r#"<a:effectStyle><a:effectLst><a:outerShdw blurRad="40000" dist="20000" dir="5400000" rotWithShape="0"><a:srgbClr val="000000"><a:alpha val="38000"/></a:srgbClr></a:outerShdw></a:effectLst></a:effectStyle>"#,
3657        r#"<a:effectStyle><a:effectLst><a:outerShdw blurRad="40000" dist="23000" dir="5400000" rotWithShape="0"><a:srgbClr val="000000"><a:alpha val="35000"/></a:srgbClr></a:outerShdw></a:effectLst></a:effectStyle>"#,
3658        "<a:effectStyle>",
3659        r#"<a:effectLst><a:outerShdw blurRad="40000" dist="23000" dir="5400000" rotWithShape="0"><a:srgbClr val="000000"><a:alpha val="35000"/></a:srgbClr></a:outerShdw></a:effectLst>"#,
3660        r#"<a:scene3d><a:camera prst="orthographicFront"><a:rot lat="0" lon="0" rev="0"/></a:camera><a:lightRig rig="threePt" dir="t"><a:rot lat="0" lon="0" rev="1200000"/></a:lightRig></a:scene3d>"#,
3661        r#"<a:sp3d><a:bevelT w="63500" h="25400"/></a:sp3d>"#,
3662        "</a:effectStyle>",
3663        "</a:effectStyleLst>",
3664        "<a:bgFillStyleLst>",
3665        r#"<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>"#,
3666        r#"<a:gradFill rotWithShape="1"><a:gsLst>"#,
3667        r#"<a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="40000"/><a:satMod val="350000"/></a:schemeClr></a:gs>"#,
3668        r#"<a:gs pos="40000"><a:schemeClr val="phClr"><a:tint val="45000"/><a:shade val="99000"/><a:satMod val="350000"/></a:schemeClr></a:gs>"#,
3669        r#"<a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="20000"/><a:satMod val="255000"/></a:schemeClr></a:gs>"#,
3670        r#"</a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="-80000" r="50000" b="180000"/></a:path></a:gradFill>"#,
3671        r#"<a:gradFill rotWithShape="1"><a:gsLst>"#,
3672        r#"<a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="80000"/><a:satMod val="300000"/></a:schemeClr></a:gs>"#,
3673        r#"<a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="30000"/><a:satMod val="200000"/></a:schemeClr></a:gs>"#,
3674        r#"</a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path></a:gradFill>"#,
3675        "</a:bgFillStyleLst>",
3676        "</a:fmtScheme>",
3677        "</a:themeElements>",
3678        "</a:theme>",
3679    );
3680    THEME_XML.as_bytes().to_vec()
3681}
3682
3683/// Serializes `xl/externalLinks/externalLinkN.xml` (`CT_ExternalLink`) from an
3684/// [`ExternalWorkbookLink`]. `r:id` (on `<externalBook>`) resolves, via this part's own `.rels`
3685/// (built alongside by `Workbook::write_to`), to the actual external target — always `"rId1"`, this
3686/// crate only ever writes the one relationship. Storage only: `<sheetDataSet>` (cached cell values
3687/// from the other workbook) is never written, see `ExternalWorkbookLink`'s doc comment.
3688fn to_external_link_xml(link: &ExternalWorkbookLink) -> Result<Vec<u8>> {
3689    let mut writer = Writer::new(Vec::new());
3690
3691    writer.write_event(Event::Decl(BytesDecl::new(
3692        "1.0",
3693        Some("UTF-8"),
3694        Some("yes"),
3695    )))?;
3696
3697    let mut root = BytesStart::new("externalLink");
3698    root.push_attribute(("xmlns", SPREADSHEETML_NAMESPACE));
3699    writer.write_event(Event::Start(root))?;
3700
3701    let mut external_book = BytesStart::new("externalBook");
3702    external_book.push_attribute(("xmlns:r", RELATIONSHIPS_NAMESPACE));
3703    external_book.push_attribute(("r:id", "rId1"));
3704    writer.write_event(Event::Start(external_book))?;
3705
3706    if !link.sheet_names.is_empty() {
3707        writer.write_event(Event::Start(BytesStart::new("sheetNames")))?;
3708        for name in &link.sheet_names {
3709            let mut sheet_name = BytesStart::new("sheetName");
3710            sheet_name.push_attribute(("val", name.as_str()));
3711            writer.write_event(Event::Empty(sheet_name))?;
3712        }
3713        writer.write_event(Event::End(BytesEnd::new("sheetNames")))?;
3714    }
3715
3716    if !link.defined_names.is_empty() {
3717        writer.write_event(Event::Start(BytesStart::new("definedNames")))?;
3718        for (name, refers_to) in &link.defined_names {
3719            let mut defined_name = BytesStart::new("definedName");
3720            defined_name.push_attribute(("name", name.as_str()));
3721            defined_name.push_attribute(("refersTo", refers_to.as_str()));
3722            writer.write_event(Event::Empty(defined_name))?;
3723        }
3724        writer.write_event(Event::End(BytesEnd::new("definedNames")))?;
3725    }
3726
3727    writer.write_event(Event::End(BytesEnd::new("externalBook")))?;
3728    writer.write_event(Event::End(BytesEnd::new("externalLink")))?;
3729
3730    Ok(writer.into_inner())
3731}
3732
3733/// Serializes `xl/tables/tableN.xml` (`CT_Table`) from a sheet's [`ExcelTable`]. `table_id` is this
3734/// table's 1-based, package-wide-unique id (this crate simply reuses the owning sheet's own 1-based
3735/// position, safe since at most one table per sheet is modeled, see `Sheet.table`'s doc comment).
3736/// Applies `table.table_style_name`, falling back to the fixed `TableStyleMedium2` built-in style
3737/// when `None` (this crate's own default since point 15, unchanged by point 50 — see
3738/// `ExcelTable::table_style_name`'s doc comment).
3739fn to_table_xml(table: &ExcelTable, table_id: usize) -> Result<Vec<u8>> {
3740    let mut writer = Writer::new(Vec::new());
3741
3742    writer.write_event(Event::Decl(BytesDecl::new(
3743        "1.0",
3744        Some("UTF-8"),
3745        Some("yes"),
3746    )))?;
3747
3748    let table_id_text = table_id.to_string();
3749    let mut root = BytesStart::new("table");
3750    root.push_attribute(("xmlns", SPREADSHEETML_NAMESPACE));
3751    root.push_attribute(("id", table_id_text.as_str()));
3752    root.push_attribute(("name", table.name.as_str()));
3753    root.push_attribute(("displayName", table.name.as_str()));
3754    root.push_attribute(("ref", table.range.as_str()));
3755    // `totalsRowCount` (not `totalsRowShown` alone) is what actually reserves `table.range`'s last
3756    // row as the totals row in `CT_Table`'s own schema (`totalsRowShown` only controls whether it's
3757    // displayed, default `"0"` count means "no totals row at all" regardless of `totalsRowShown`) —
3758    // Without this attribute, real Excel finds `totalsRowShown="1"` claiming a totals row exists
3759    // while nothing actually reserves it, and repairs the file by dropping the whole table.
3760    if table.show_totals_row {
3761        root.push_attribute(("totalsRowShown", "1"));
3762        root.push_attribute(("totalsRowCount", "1"));
3763    } else {
3764        root.push_attribute(("totalsRowShown", "0"));
3765    }
3766    writer.write_event(Event::Start(root))?;
3767
3768    // The autofilter's own range excludes the totals row (matching real Excel: the dropdown arrows
3769    // never appear on a totals row).
3770    let auto_filter_ref = if table.show_totals_row {
3771        shrink_range_by_one_row(&table.range)
3772    } else {
3773        table.range.clone()
3774    };
3775    let mut auto_filter = BytesStart::new("autoFilter");
3776    auto_filter.push_attribute(("ref", auto_filter_ref.as_str()));
3777    writer.write_event(Event::Empty(auto_filter))?;
3778
3779    // `CT_Table`'s own sequence puts `sortState` right after `autoFilter`, as its *sibling* (not
3780    // nested inside it, unlike the worksheet-level case — see `ExcelTable.sort_state`'s doc comment
3781    // and `write_sort_state`'s own doc comment).
3782    if let Some(sort_state) = &table.sort_state {
3783        write_sort_state(&mut writer, sort_state)?;
3784    }
3785
3786    let columns_count = table.columns.len().to_string();
3787    let mut table_columns_start = BytesStart::new("tableColumns");
3788    table_columns_start.push_attribute(("count", columns_count.as_str()));
3789    writer.write_event(Event::Start(table_columns_start))?;
3790    for (index, column) in table.columns.iter().enumerate() {
3791        write_table_column(&mut writer, index, column)?;
3792    }
3793    writer.write_event(Event::End(BytesEnd::new("tableColumns")))?;
3794
3795    let table_style_name = table
3796        .table_style_name
3797        .as_deref()
3798        .unwrap_or("TableStyleMedium2");
3799    let mut table_style_info = BytesStart::new("tableStyleInfo");
3800    table_style_info.push_attribute(("name", table_style_name));
3801    table_style_info.push_attribute(("showFirstColumn", "0"));
3802    table_style_info.push_attribute(("showLastColumn", "0"));
3803    table_style_info.push_attribute(("showRowStripes", "1"));
3804    table_style_info.push_attribute(("showColumnStripes", "0"));
3805    writer.write_event(Event::Empty(table_style_info))?;
3806
3807    writer.write_event(Event::End(BytesEnd::new("table")))?;
3808
3809    Ok(writer.into_inner())
3810}
3811
3812/// Serializes one `<tableColumn>` (`CT_TableColumn`) from a [`TableColumn`] — (plain `name` only),
3813/// extended for `calculatedColumnFormula`/ `totalsRowFunction`/`totalsRowLabel`/`totalsRowFormula`.
3814/// `CT_TableColumn`'s child sequence is `calculatedColumnFormula?`, `totalsRowFormula?`.
3815/// (everything else not modeled).
3816///
3817/// A built-in `totals_row_function` (anything but `Custom`) writes only the
3818/// `totalsRowFunction=".."` attribute, no `<totalsRowFormula>` child at all — real Excel itself
3819/// never writes one for a built-in function (the function name alone fully determines the
3820/// `SUBTOTAL(..)` it computes internally). Writing a redundant, auto-synthesized
3821/// `<totalsRowFormula>` here for every built-in function causes a real Excel installation to reject
3822/// and strip the whole `<table>` on open. Only `Custom` still writes `<totalsRowFormula>`, since
3823/// that's the only variant where the formula text isn't implied by the function name.
3824fn write_table_column<W: Write>(
3825    writer: &mut Writer<W>,
3826    index: usize,
3827    column: &TableColumn,
3828) -> Result<()> {
3829    let column_id_text = (index + 1).to_string();
3830    let mut table_column = BytesStart::new("tableColumn");
3831    table_column.push_attribute(("id", column_id_text.as_str()));
3832    table_column.push_attribute(("name", column.name.as_str()));
3833
3834    // `totalsRowLabel` and `totalsRowFunction` are mutually exclusive on the same column in real
3835    // Excel's own UI (a totals-row cell either shows a plain text label — e.g. "Total" on the first
3836    // column — or a computed function, never both at once. A label set alongside a function on the
3837    // same [`TableColumn`] wins: the function (and any `totals_row_formula`) is simply not written.
3838    let (totals_row_function, totals_row_formula) = if column.totals_row_label.is_some() {
3839        (None, None)
3840    } else {
3841        let formula = match column.totals_row_function {
3842            Some(TotalsRowFunction::Custom) => column.totals_row_formula.clone(),
3843            _ => None,
3844        };
3845        (column.totals_row_function, formula)
3846    };
3847    if let Some(function) = totals_row_function {
3848        table_column.push_attribute(("totalsRowFunction", totals_row_function_str(function)));
3849    }
3850    if let Some(label) = &column.totals_row_label {
3851        table_column.push_attribute(("totalsRowLabel", label.as_str()));
3852    }
3853
3854    if column.calculated_formula.is_none() && totals_row_formula.is_none() {
3855        writer.write_event(Event::Empty(table_column))?;
3856        return Ok(());
3857    }
3858
3859    writer.write_event(Event::Start(table_column))?;
3860    if let Some(formula) = &column.calculated_formula {
3861        write_element_text(writer, "calculatedColumnFormula", formula)?;
3862    }
3863    if let Some(formula) = &totals_row_formula {
3864        write_element_text(writer, "totalsRowFormula", formula)?;
3865    }
3866    writer.write_event(Event::End(BytesEnd::new("tableColumn")))?;
3867    Ok(())
3868}
3869
3870fn totals_row_function_str(function: TotalsRowFunction) -> &'static str {
3871    match function {
3872        TotalsRowFunction::Sum => "sum",
3873        TotalsRowFunction::Average => "average",
3874        TotalsRowFunction::Count => "count",
3875        TotalsRowFunction::CountNums => "countNums",
3876        TotalsRowFunction::Max => "max",
3877        TotalsRowFunction::Min => "min",
3878        TotalsRowFunction::StdDev => "stdDev",
3879        TotalsRowFunction::Var => "var",
3880        TotalsRowFunction::Custom => "custom",
3881    }
3882}
3883
3884/// Decrements a range reference's ending row by one (e.g. `"A1:D6"` → `"A1:D5"`) — used to exclude
3885/// a table's totals row from its `<autoFilter>` range. Falls back to returning `range` unchanged if
3886/// it isn't in the expected `"start:end"` shape or the end row is already `1` (nothing to shrink).
3887fn shrink_range_by_one_row(range: &str) -> String {
3888    let Some((start, end)) = range.split_once(':') else {
3889        return range.to_string();
3890    };
3891    let Some(split_at) = end.find(|character: char| character.is_ascii_digit()) else {
3892        return range.to_string();
3893    };
3894    let (letters, digits) = end.split_at(split_at);
3895    match digits.parse::<usize>() {
3896        Ok(row) if row > 1 => format!("{start}:{letters}{}", row - 1),
3897        _ => range.to_string(),
3898    }
3899}
3900
3901/// Serializes `xl/commentsN.xml` (`CT_Comments`) from a sheet's [`CellComment`]s. Authors are
3902/// deduplicated into `<authors>` in first-appearance order (same principle as this crate's own
3903/// shared-strings/cell-format deduplication elsewhere), each comment referencing its author by
3904/// index (`authorId`).
3905fn to_comments_xml(comments: &[CellComment]) -> Result<Vec<u8>> {
3906    let mut writer = Writer::new(Vec::new());
3907
3908    writer.write_event(Event::Decl(BytesDecl::new(
3909        "1.0",
3910        Some("UTF-8"),
3911        Some("yes"),
3912    )))?;
3913
3914    let mut root = BytesStart::new("comments");
3915    root.push_attribute(("xmlns", SPREADSHEETML_NAMESPACE));
3916    writer.write_event(Event::Start(root))?;
3917
3918    let mut authors: Vec<String> = Vec::new();
3919    for comment in comments {
3920        if !authors.contains(&comment.author) {
3921            authors.push(comment.author.clone());
3922        }
3923    }
3924
3925    writer.write_event(Event::Start(BytesStart::new("authors")))?;
3926    for author in &authors {
3927        write_element_text(&mut writer, "author", author)?;
3928    }
3929    writer.write_event(Event::End(BytesEnd::new("authors")))?;
3930
3931    writer.write_event(Event::Start(BytesStart::new("commentList")))?;
3932    for comment in comments {
3933        let author_id = authors
3934            .iter()
3935            .position(|author| author == &comment.author)
3936            .unwrap_or(0);
3937        let author_id_text = author_id.to_string();
3938        let mut comment_start = BytesStart::new("comment");
3939        comment_start.push_attribute(("ref", comment.cell.as_str()));
3940        comment_start.push_attribute(("authorId", author_id_text.as_str()));
3941        writer.write_event(Event::Start(comment_start))?;
3942        writer.write_event(Event::Start(BytesStart::new("text")))?;
3943        // Rich per-run formatting — — falls back to the plain single-run form when `rich_text` is
3944        // `None`, reusing `write_rich_text_run` (shared with `xl/sharedStrings.xml`'s own rich-text
3945        // runs).
3946        match &comment.rich_text {
3947            Some(runs) => {
3948                for run in runs {
3949                    write_rich_text_run(&mut writer, run)?;
3950                }
3951            }
3952            None => {
3953                writer.write_event(Event::Start(BytesStart::new("r")))?;
3954                writer.write_event(Event::Start(BytesStart::new("t")))?;
3955                writer.write_event(Event::Text(BytesText::new(&comment.text)))?;
3956                writer.write_event(Event::End(BytesEnd::new("t")))?;
3957                writer.write_event(Event::End(BytesEnd::new("r")))?;
3958            }
3959        }
3960        writer.write_event(Event::End(BytesEnd::new("text")))?;
3961        writer.write_event(Event::End(BytesEnd::new("comment")))?;
3962    }
3963    writer.write_event(Event::End(BytesEnd::new("commentList")))?;
3964
3965    writer.write_event(Event::End(BytesEnd::new("comments")))?;
3966
3967    Ok(writer.into_inner())
3968}
3969
3970/// Serializes the legacy VML drawing (`xl/drawings/vmlDrawingN.vml`) real Excel still expects
3971/// alongside `xl/commentsN.xml` for a comment's indicator triangle/popup box to render correctly
3972/// (see `VML_DRAWING_CONTENT_TYPE`'s doc comment) — one `<v:shape>` per comment, all referencing a
3973/// single shared `<v:shapetype>`, following the same boilerplate structure real Excel itself
3974/// generates for this legacy format. Raw string concatenation rather than `xml_core::Writer` — VML
3975/// is not SpreadsheetML and doesn't need namespace-aware escaping beyond what's already guaranteed
3976/// by `CellComment.cell`/`author`/`text` not being attacker-controlled in this crate's own
3977/// tests/examples; a caller passing genuinely untrusted text here should be aware this function
3978/// does not escape `<`/`>`/`&` inside the comment's row/column-derived shape id, which is always
3979/// numeric anyway.
3980fn to_comments_vml(comments: &[CellComment]) -> Vec<u8> {
3981    let mut vml = String::new();
3982    vml.push_str(
3983        r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">
3984<o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="1"/></o:shapelayout>
3985<v:shapetype id="_x0000_t202" coordsize="21600,21600" o:spt="202" path="m,l,21600r21600,l21600,xe">
3986<v:stroke joinstyle="miter"/>
3987<v:path gradientshapeok="t" o:connecttype="rect"/>
3988</v:shapetype>
3989"#,
3990    );
3991
3992    for (index, comment) in comments.iter().enumerate() {
3993        let shape_id = 1025 + index;
3994        let (column_index, row_number) = cell_column_and_row(&comment.cell);
3995        let row_index = row_number.saturating_sub(1);
3996        vml.push_str(&format!(
3997            r##"<v:shape id="_x0000_s{shape_id}" type="#_x0000_t202" style='position:absolute;margin-left:59.25pt;margin-top:1.5pt;width:108pt;height:59.25pt;z-index:{index_plus_one};visibility:hidden' fillcolor="#ffffe1" o:insetmode="auto">
3998<v:fill color2="#ffffe1"/>
3999<v:shadow on="t" color="black" obscured="t"/>
4000<v:path o:connecttype="none"/>
4001<x:ClientData ObjectType="Note">
4002<x:MoveWithCells/>
4003<x:SizeWithCells/>
4004<x:Anchor>{col1}, 15, {row1}, 2, {col2}, 31, {row2}, 4</x:Anchor>
4005<x:AutoFill>False</x:AutoFill>
4006<x:Row>{row_index}</x:Row>
4007<x:Column>{column_index}</x:Column>
4008</x:ClientData>
4009</v:shape>
4010"##,
4011            shape_id = shape_id,
4012            index_plus_one = index + 1,
4013            col1 = column_index,
4014            row1 = row_index,
4015            col2 = column_index + 2,
4016            row2 = row_index + 4,
4017            row_index = row_index,
4018            column_index = column_index,
4019        ));
4020    }
4021
4022    vml.push_str("</xml>");
4023    vml.into_bytes()
4024}
4025
4026// ============================================================================,: sheet drawing
4027// (embedded pictures/charts).
4028// ============================================================================
4029
4030/// Serializes one sheet's `xl/drawings/drawingN.xml` (`CT_Drawing`, root `<xdr:wsDr>`) — a
4031/// `<xdr:twoCellAnchor>` per [`crate::model::DrawingAnchor`], each wrapping either an `<xdr:pic>`
4032/// or an `<xdr:graphicFrame>` (chart). Every picture/chart referenced from an anchor is also added
4033/// to `package` here (as `xl/media/imageN.<ext>` / `xl/charts/chartN.xml`), with a matching
4034/// relationship recorded on `relationships` — the drawing part's own `.rels`, later attached to the
4035/// `Part` by the caller (see `write_to`'s per-sheet loop). `next_media_index`/`next_chart_index`
4036/// are threaded through (and bumped) across the whole workbook, not reset per sheet — see their own
4037/// doc comments in `write_to`.
4038fn to_drawing_xml(
4039    drawing: &SheetDrawing,
4040    relationships: &mut Relationships,
4041    next_media_index: &mut usize,
4042    next_chart_index: &mut usize,
4043    package: &mut Package,
4044) -> Result<Vec<u8>> {
4045    let mut writer = Writer::new(Vec::new());
4046
4047    writer.write_event(Event::Decl(BytesDecl::new(
4048        "1.0",
4049        Some("UTF-8"),
4050        Some("yes"),
4051    )))?;
4052
4053    let mut root = BytesStart::new("xdr:wsDr");
4054    root.push_attribute(("xmlns:xdr", SPREADSHEET_DRAWING_NAMESPACE));
4055    root.push_attribute(("xmlns:a", DRAWINGML_NAMESPACE));
4056    root.push_attribute(("xmlns:r", RELATIONSHIPS_NAMESPACE));
4057    writer.write_event(Event::Start(root))?;
4058
4059    let mut next_relationship_id = 1usize;
4060
4061    for (anchor_index, anchor) in drawing.anchors.iter().enumerate() {
4062        writer.write_event(Event::Start(BytesStart::new("xdr:twoCellAnchor")))?;
4063        write_cell_anchor_point(&mut writer, "xdr:from", &anchor.from)?;
4064        write_cell_anchor_point(&mut writer, "xdr:to", &anchor.to)?;
4065
4066        // The non-visual id (`cNvPr id=".."`) only has to be unique *within this drawing part* — a
4067        // 1-based counter over this drawing's own anchors is enough.
4068        let shape_id = (anchor_index + 1) as u32;
4069
4070        match &anchor.object {
4071            DrawingObject::Picture(picture) => {
4072                let relationship_id = format!("rId{next_relationship_id}");
4073                next_relationship_id += 1;
4074                let media_index = *next_media_index;
4075                *next_media_index += 1;
4076                let entry_name = format!("image{}.{}", media_index, picture.format.extension());
4077                relationships.add(Relationship {
4078                    id: relationship_id.clone(),
4079                    rel_type: IMAGE_RELATIONSHIP_TYPE.to_string(),
4080                    target: format!("../media/{entry_name}"),
4081                    target_mode: TargetMode::Internal,
4082                });
4083                package.add_part(Part::new(
4084                    format!("/xl/media/{entry_name}"),
4085                    picture.format.content_type(),
4086                    picture.data.clone(),
4087                ));
4088                write_sheet_picture(&mut writer, picture, &relationship_id, shape_id)?;
4089            }
4090            DrawingObject::Chart(sheet_chart) => {
4091                let relationship_id = format!("rId{next_relationship_id}");
4092                next_relationship_id += 1;
4093                let chart_index = *next_chart_index;
4094                *next_chart_index += 1;
4095                relationships.add(Relationship {
4096                    id: relationship_id.clone(),
4097                    rel_type: CHART_RELATIONSHIP_TYPE.to_string(),
4098                    target: format!("../charts/chart{chart_index}.xml"),
4099                    target_mode: TargetMode::Internal,
4100                });
4101                let mut chart_writer = Writer::new(Vec::new());
4102                chart::write_chart_space(&mut chart_writer, &sheet_chart.chart_space)?;
4103                package.add_part(Part::new(
4104                    format!("/xl/charts/chart{chart_index}.xml"),
4105                    CHART_CONTENT_TYPE,
4106                    chart_writer.into_inner(),
4107                ));
4108                write_sheet_chart(&mut writer, sheet_chart, &relationship_id, shape_id)?;
4109            }
4110        }
4111
4112        // `CT_TwoCellAnchor`'s `clientData` (`CT_AnchorClientData`) is required but every attribute
4113        // on it is optional with a schema default (`fLocksWithSheet`/`fPrintsWithSheet` both
4114        // default `true`) — this crate doesn't model either, so a self-closing element with no
4115        // attributes is schema-valid and matches Excel's own defaults.
4116        writer.write_event(Event::Empty(BytesStart::new("xdr:clientData")))?;
4117
4118        writer.write_event(Event::End(BytesEnd::new("xdr:twoCellAnchor")))?;
4119    }
4120
4121    writer.write_event(Event::End(BytesEnd::new("xdr:wsDr")))?;
4122
4123    Ok(writer.into_inner())
4124}
4125
4126/// Writes one `<xdr:from>`/`<xdr:to>` marker (`CT_Marker`): `col`/`colOff`/ `row`/`rowOff`, in that
4127/// schema order.
4128fn write_cell_anchor_point<W: Write>(
4129    writer: &mut Writer<W>,
4130    element_name: &str,
4131    point: &CellAnchorPoint,
4132) -> Result<()> {
4133    writer.write_event(Event::Start(BytesStart::new(element_name)))?;
4134
4135    writer.write_event(Event::Start(BytesStart::new("xdr:col")))?;
4136    writer.write_event(Event::Text(BytesText::new(&point.column.to_string())))?;
4137    writer.write_event(Event::End(BytesEnd::new("xdr:col")))?;
4138
4139    writer.write_event(Event::Start(BytesStart::new("xdr:colOff")))?;
4140    writer.write_event(Event::Text(BytesText::new(
4141        &point.column_offset_emu.to_string(),
4142    )))?;
4143    writer.write_event(Event::End(BytesEnd::new("xdr:colOff")))?;
4144
4145    writer.write_event(Event::Start(BytesStart::new("xdr:row")))?;
4146    writer.write_event(Event::Text(BytesText::new(&point.row.to_string())))?;
4147    writer.write_event(Event::End(BytesEnd::new("xdr:row")))?;
4148
4149    writer.write_event(Event::Start(BytesStart::new("xdr:rowOff")))?;
4150    writer.write_event(Event::Text(BytesText::new(
4151        &point.row_offset_emu.to_string(),
4152    )))?;
4153    writer.write_event(Event::End(BytesEnd::new("xdr:rowOff")))?;
4154
4155    writer.write_event(Event::End(BytesEnd::new(element_name)))?;
4156    Ok(())
4157}
4158
4159/// Writes one `<xdr:pic>` (`CT_Picture`, the spreadsheet-drawing flavor) —
4160/// `nvPicPr`/`blipFill`/`spPr`, in that schema order. `blipFill`'s own wrapper is `xdr:blipFill`
4161/// here (unlike `drawing::write_blip_fill`, which hardcodes `a:blipFill` for its own, different,
4162/// use as a shape's `spPr` fill choice — see that function's doc comment), so this crate writes the
4163/// wrapper and the `a:blip`/`a:stretch` children directly rather than reusing that function.
4164/// `spPr`'s own geometry always writes *something* (the picture's own, when set, otherwise a fixed
4165/// default rectangle) — real Excel fails to render a picture at all if `spPr` has no geometry
4166/// element, even with a fill/line present (see this crate's own `write_sheet_picture` for the bug
4167/// this was caught by); fill/line (if any) are then written directly via
4168/// `drawing::write_fill`/`write_line`, not through `drawing::write_shape_properties` as a whole
4169/// (that function alone would skip geometry entirely whenever the caller didn't set one).
4170fn write_sheet_picture<W: Write>(
4171    writer: &mut Writer<W>,
4172    picture: &SheetPicture,
4173    relationship_id: &str,
4174    id: u32,
4175) -> Result<()> {
4176    writer.write_event(Event::Start(BytesStart::new("xdr:pic")))?;
4177
4178    writer.write_event(Event::Start(BytesStart::new("xdr:nvPicPr")))?;
4179    let mut c_nv_pr = BytesStart::new("xdr:cNvPr");
4180    c_nv_pr.push_attribute(("id", id.to_string().as_str()));
4181    c_nv_pr.push_attribute(("name", picture.name.as_str()));
4182    writer.write_event(Event::Empty(c_nv_pr))?;
4183    writer.write_event(Event::Empty(BytesStart::new("xdr:cNvPicPr")))?;
4184    writer.write_event(Event::End(BytesEnd::new("xdr:nvPicPr")))?;
4185
4186    writer.write_event(Event::Start(BytesStart::new("xdr:blipFill")))?;
4187    let mut blip = BytesStart::new("a:blip");
4188    blip.push_attribute(("r:embed", relationship_id));
4189    writer.write_event(Event::Empty(blip))?;
4190    writer.write_event(Event::Start(BytesStart::new("a:stretch")))?;
4191    writer.write_event(Event::Empty(BytesStart::new("a:fillRect")))?;
4192    writer.write_event(Event::End(BytesEnd::new("a:stretch")))?;
4193    writer.write_event(Event::End(BytesEnd::new("xdr:blipFill")))?;
4194
4195    writer.write_event(Event::Start(BytesStart::new("xdr:spPr")))?;
4196    // Every real Excel-authored `xdr:pic` this crate's own DrawingML showcase was cross-checked
4197    // against — genuine Excel output, confirmed by its
4198    // `a14:hiddenFill`/`a14:hiddenLine`/`a14:useLocalDpi` extensions, a Microsoft-only convention —
4199    // always carries an `<a:xfrm>` with real off/ext values, even though a `twoCellAnchor`'s own
4200    // `from`/`to` cell markers are what actually govern the picture's final on-screen
4201    // position/size. This crate never wrote one at all. Since it's still an open question (pending
4202    // a real Excel test) whether that absence is what silently hid step 4-8's pictures in the
4203    // DrawingML showcase once a fill/line was added, or whether a visible custom fill on a picture
4204    // is simply not something Excel's own rendering path supports at all, this is written
4205    // defensively to match real Excel's own convention as closely as this crate can without
4206    // tracking a picture's real EMU size for Excel (unlike `word_ooxml::Image`, `SheetPicture` has
4207    // none — its sizing is fully anchor-driven) — an explicit, zero-valued `a:xfrm` as a
4208    // placeholder before a real size is ever assigned.
4209    writer.write_event(Event::Start(BytesStart::new("a:xfrm")))?;
4210    let mut off = BytesStart::new("a:off");
4211    off.push_attribute(("x", "0"));
4212    off.push_attribute(("y", "0"));
4213    writer.write_event(Event::Empty(off))?;
4214    let mut ext = BytesStart::new("a:ext");
4215    ext.push_attribute(("cx", "0"));
4216    ext.push_attribute(("cy", "0"));
4217    writer.write_event(Event::Empty(ext))?;
4218    writer.write_event(Event::End(BytesEnd::new("a:xfrm")))?;
4219    // A picture's `xdr:spPr` must also always carry *some* geometry for real Excel to actually
4220    // render the picture at all: a `spPr` with only a fill/line and no `a:prstGeom`/`a:custGeom` is
4221    // schema-valid (geometry is optional in `CT_ShapeProperties`) but Excel silently fails to draw
4222    // the picture in that case (no visible image, just the sheet's own cells showing through). This
4223    // delegates to `drawing`'s shared helper to resolve geometry-or-default and fill/line, which
4224    // also covers `shape_properties.effects` (previously never written here, even though it
4225    // round-tripped correctly on read).
4226    let empty_properties = drawing::ShapeProperties::default();
4227    let shape_properties = picture
4228        .shape_properties
4229        .as_ref()
4230        .unwrap_or(&empty_properties);
4231    drawing::write_geometry_fill_line_effects(writer, shape_properties, "rect")?;
4232    writer.write_event(Event::End(BytesEnd::new("xdr:spPr")))?;
4233
4234    writer.write_event(Event::End(BytesEnd::new("xdr:pic")))?;
4235    Ok(())
4236}
4237
4238/// Writes one `<xdr:graphicFrame>` (`CT_GraphicalObjectFrame`) wrapping a `<c:chart r:id=".">` —
4239/// `nvGraphicFramePr`/`xfrm`/`graphic`, in that schema order. `xfrm` is written with fixed
4240/// placeholder position/extent (`0,0`/`0,0`): real positioning/sizing is governed by the enclosing
4241/// `<xdr:twoCellAnchor>`'s `from`/`to` markers, not this element, the same "fixed default for
4242/// schema-mandatory-but-out-of-scope content" convention already used elsewhere in this workspace
4243/// (e.g. `w:sectPr`'s fixed page size). Note the element itself is `xdr:xfrm` here, not `a:xfrm` —
4244/// confirmed via `dml-spreadsheetDrawing.xsd` — so `drawing::write_transform` (which hardcodes
4245/// `a:xfrm`) cannot be reused.
4246fn write_sheet_chart<W: Write>(
4247    writer: &mut Writer<W>,
4248    sheet_chart: &SheetChart,
4249    relationship_id: &str,
4250    id: u32,
4251) -> Result<()> {
4252    writer.write_event(Event::Start(BytesStart::new("xdr:graphicFrame")))?;
4253
4254    writer.write_event(Event::Start(BytesStart::new("xdr:nvGraphicFramePr")))?;
4255    let mut c_nv_pr = BytesStart::new("xdr:cNvPr");
4256    c_nv_pr.push_attribute(("id", id.to_string().as_str()));
4257    c_nv_pr.push_attribute(("name", sheet_chart.name.as_str()));
4258    writer.write_event(Event::Empty(c_nv_pr))?;
4259    writer.write_event(Event::Empty(BytesStart::new("xdr:cNvGraphicFramePr")))?;
4260    writer.write_event(Event::End(BytesEnd::new("xdr:nvGraphicFramePr")))?;
4261
4262    writer.write_event(Event::Start(BytesStart::new("xdr:xfrm")))?;
4263    let mut off = BytesStart::new("a:off");
4264    off.push_attribute(("x", "0"));
4265    off.push_attribute(("y", "0"));
4266    writer.write_event(Event::Empty(off))?;
4267    let mut ext = BytesStart::new("a:ext");
4268    ext.push_attribute(("cx", "0"));
4269    ext.push_attribute(("cy", "0"));
4270    writer.write_event(Event::Empty(ext))?;
4271    writer.write_event(Event::End(BytesEnd::new("xdr:xfrm")))?;
4272
4273    writer.write_event(Event::Start(BytesStart::new("a:graphic")))?;
4274    let mut graphic_data = BytesStart::new("a:graphicData");
4275    graphic_data.push_attribute(("uri", CHART_NAMESPACE));
4276    writer.write_event(Event::Start(graphic_data))?;
4277    let mut c_chart = BytesStart::new("c:chart");
4278    c_chart.push_attribute(("xmlns:c", CHART_NAMESPACE));
4279    c_chart.push_attribute(("r:id", relationship_id));
4280    writer.write_event(Event::Empty(c_chart))?;
4281    writer.write_event(Event::End(BytesEnd::new("a:graphicData")))?;
4282    writer.write_event(Event::End(BytesEnd::new("a:graphic")))?;
4283
4284    writer.write_event(Event::End(BytesEnd::new("xdr:graphicFrame")))?;
4285    Ok(())
4286}
4287
4288/// Extracts a cell reference's (`"C5"`) 0-based column index and 1-based row number — used by
4289/// `to_comments_vml` to position each comment shape. Tolerant of a malformed reference (returns
4290/// `(0, 1)`), same forgiving posture as this crate's reader-side equivalent
4291/// (`reader.rs::column_index_from_reference`).
4292fn cell_column_and_row(reference: &str) -> (usize, usize) {
4293    let letters: String = reference
4294        .chars()
4295        .take_while(|character| character.is_ascii_alphabetic())
4296        .collect();
4297    let digits: String = reference
4298        .chars()
4299        .skip_while(|character| character.is_ascii_alphabetic())
4300        .collect();
4301    let row_number = digits.parse::<usize>().unwrap_or(1);
4302    if letters.is_empty() {
4303        return (0, row_number);
4304    }
4305    let mut index: usize = 0;
4306    for character in letters.chars() {
4307        let digit = (character.to_ascii_uppercase() as u8 - b'A' + 1) as usize;
4308        index = index * 26 + digit;
4309    }
4310    (index - 1, row_number)
4311}
4312
4313#[cfg(test)]
4314mod tests {
4315    use super::*;
4316    use crate::model::Row;
4317
4318    #[test]
4319    fn writes_column_letters_correctly() {
4320        assert_eq!(column_letters(0), "A");
4321        assert_eq!(column_letters(1), "B");
4322        assert_eq!(column_letters(25), "Z");
4323        assert_eq!(column_letters(26), "AA");
4324        assert_eq!(column_letters(27), "AB");
4325        assert_eq!(column_letters(51), "AZ");
4326        assert_eq!(column_letters(52), "BA");
4327        assert_eq!(column_letters(701), "ZZ");
4328        assert_eq!(column_letters(702), "AAA");
4329    }
4330
4331    #[test]
4332    fn writes_a_cell_reference_from_column_and_row() {
4333        assert_eq!(cell_reference(0, 1), "A1");
4334        assert_eq!(cell_reference(2, 5), "C5");
4335        assert_eq!(cell_reference(26, 11), "AA11");
4336    }
4337
4338    #[test]
4339    fn writes_workbook_with_worksheet_styles_and_shared_strings_parts() {
4340        let workbook = Workbook::new().with_sheet(
4341            Sheet::new("Feuil1")
4342                .with_row(Row::new().with_text("Nombre_1").with_text("Nombre_2"))
4343                .with_row(Row::new().with_number(10.0).with_number(1.0)),
4344        );
4345
4346        let buffer = workbook.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4347        let package = opc::Package::read_from(buffer).unwrap();
4348
4349        assert!(package.part(MAIN_WORKBOOK_PART_NAME).is_some());
4350        assert!(package.part("/xl/worksheets/sheet1.xml").is_some());
4351        assert!(package.part(STYLES_PART_NAME).is_some());
4352        assert!(package.part(SHARED_STRINGS_PART_NAME).is_some());
4353        assert!(package.part(CORE_PROPERTIES_PART_NAME).is_some());
4354        assert!(package.part(EXTENDED_PROPERTIES_PART_NAME).is_some());
4355
4356        let workbook_xml =
4357            std::str::from_utf8(&package.part(MAIN_WORKBOOK_PART_NAME).unwrap().data).unwrap();
4358        assert!(
4359            workbook_xml.contains(r#"<sheet name="Feuil1" sheetId="1" r:id="rId1"/>"#),
4360            "{workbook_xml}"
4361        );
4362
4363        let worksheet_xml =
4364            std::str::from_utf8(&package.part("/xl/worksheets/sheet1.xml").unwrap().data).unwrap();
4365        assert!(
4366            worksheet_xml.contains(r#"<c r="A1" t="s"><v>0</v></c>"#),
4367            "{worksheet_xml}"
4368        );
4369        assert!(
4370            worksheet_xml.contains(r#"<c r="B1" t="s"><v>1</v></c>"#),
4371            "{worksheet_xml}"
4372        );
4373        assert!(
4374            worksheet_xml.contains(r#"<c r="A2"><v>10</v></c>"#),
4375            "{worksheet_xml}"
4376        );
4377        assert!(
4378            worksheet_xml.contains(r#"<c r="B2"><v>1</v></c>"#),
4379            "{worksheet_xml}"
4380        );
4381
4382        let shared_strings_xml =
4383            std::str::from_utf8(&package.part(SHARED_STRINGS_PART_NAME).unwrap().data).unwrap();
4384        assert!(
4385            shared_strings_xml.contains(r#"count="2" uniqueCount="2""#),
4386            "{shared_strings_xml}"
4387        );
4388        assert!(
4389            shared_strings_xml.contains("<t>Nombre_1</t>"),
4390            "{shared_strings_xml}"
4391        );
4392        assert!(
4393            shared_strings_xml.contains("<t>Nombre_2</t>"),
4394            "{shared_strings_xml}"
4395        );
4396
4397        let styles_xml =
4398            std::str::from_utf8(&package.part(STYLES_PART_NAME).unwrap().data).unwrap();
4399        assert!(styles_xml.contains(r#"<fills count="2">"#), "{styles_xml}");
4400        assert!(
4401            styles_xml.contains(r#"<patternFill patternType="none"/>"#),
4402            "{styles_xml}"
4403        );
4404        assert!(
4405            styles_xml.contains(r#"<patternFill patternType="gray125"/>"#),
4406            "{styles_xml}"
4407        );
4408        assert!(
4409            styles_xml.contains(r#"<cellXfs count="1">"#),
4410            "{styles_xml}"
4411        );
4412    }
4413
4414    #[test]
4415    fn deduplicates_repeated_text_values_across_the_shared_strings_table() {
4416        let workbook = Workbook::new().with_sheet(
4417            Sheet::new("Feuil1")
4418                .with_row(Row::new().with_text("Répété"))
4419                .with_row(Row::new().with_text("Répété"))
4420                .with_row(Row::new().with_text("Unique")),
4421        );
4422
4423        let buffer = workbook.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4424        let package = opc::Package::read_from(buffer).unwrap();
4425
4426        let shared_strings_xml =
4427            std::str::from_utf8(&package.part(SHARED_STRINGS_PART_NAME).unwrap().data).unwrap();
4428        assert!(
4429            shared_strings_xml.contains(r#"count="3" uniqueCount="2""#),
4430            "{shared_strings_xml}"
4431        );
4432
4433        let worksheet_xml =
4434            std::str::from_utf8(&package.part("/xl/worksheets/sheet1.xml").unwrap().data).unwrap();
4435        assert!(
4436            worksheet_xml.contains(r#"<c r="A1" t="s"><v>0</v></c>"#),
4437            "{worksheet_xml}"
4438        );
4439        assert!(
4440            worksheet_xml.contains(r#"<c r="A2" t="s"><v>0</v></c>"#),
4441            "{worksheet_xml}"
4442        );
4443        assert!(
4444            worksheet_xml.contains(r#"<c r="A3" t="s"><v>1</v></c>"#),
4445            "{worksheet_xml}"
4446        );
4447    }
4448
4449    #[test]
4450    fn omits_a_c_element_entirely_for_an_empty_cell() {
4451        let workbook = Workbook::new().with_sheet(
4452            Sheet::new("Feuil1").with_row(
4453                Row::new()
4454                    .with_text("A")
4455                    .with_cell(Cell::new())
4456                    .with_text("C"),
4457            ),
4458        );
4459
4460        let buffer = workbook.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4461        let package = opc::Package::read_from(buffer).unwrap();
4462
4463        let worksheet_xml =
4464            std::str::from_utf8(&package.part("/xl/worksheets/sheet1.xml").unwrap().data).unwrap();
4465        assert!(
4466            worksheet_xml.contains(r#"<c r="A1" t="s""#),
4467            "{worksheet_xml}"
4468        );
4469        assert!(!worksheet_xml.contains(r#"r="B1""#), "{worksheet_xml}");
4470        assert!(
4471            worksheet_xml.contains(r#"<c r="C1" t="s""#),
4472            "{worksheet_xml}"
4473        );
4474    }
4475
4476    #[test]
4477    fn writes_a_dynamic_style_with_number_format_font_fill_and_alignment() {
4478        let format = CellFormat::new()
4479            .with_number_format("0.00")
4480            .with_bold(true)
4481            .with_fill_color("FFFFFF00")
4482            .with_horizontal_alignment(HorizontalAlignment::Center);
4483
4484        let workbook = Workbook::new().with_sheet(
4485            Sheet::new("Feuil1")
4486                .with_row(Row::new().with_cell(Cell::number(3.5).with_format(format))),
4487        );
4488
4489        let buffer = workbook.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4490        let package = opc::Package::read_from(buffer).unwrap();
4491
4492        let styles_xml =
4493            std::str::from_utf8(&package.part(STYLES_PART_NAME).unwrap().data).unwrap();
4494        assert!(
4495            styles_xml.contains(r#"<numFmt numFmtId="164" formatCode="0.00"/>"#),
4496            "{styles_xml}"
4497        );
4498        assert!(styles_xml.contains("<b/>"), "{styles_xml}");
4499        assert!(
4500            styles_xml.contains(r#"<fgColor rgb="FFFFFF00"/>"#),
4501            "{styles_xml}"
4502        );
4503        assert!(
4504            styles_xml.contains(r#"<alignment horizontal="center"/>"#),
4505            "{styles_xml}"
4506        );
4507        assert!(
4508            styles_xml.contains(r#"<cellXfs count="2">"#),
4509            "{styles_xml}"
4510        );
4511
4512        let worksheet_xml =
4513            std::str::from_utf8(&package.part("/xl/worksheets/sheet1.xml").unwrap().data).unwrap();
4514        assert!(
4515            worksheet_xml.contains(r#"<c r="A1" s="1"><v>3.5</v></c>"#),
4516            "{worksheet_xml}"
4517        );
4518    }
4519
4520    #[test]
4521    fn writes_a_styled_but_empty_cell_as_a_self_closing_element() {
4522        let workbook = Workbook::new().with_sheet(Sheet::new("Feuil1").with_row(
4523            Row::new().with_cell(Cell::new().with_format(CellFormat::new().with_bold(true))),
4524        ));
4525
4526        let buffer = workbook.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4527        let package = opc::Package::read_from(buffer).unwrap();
4528
4529        let worksheet_xml =
4530            std::str::from_utf8(&package.part("/xl/worksheets/sheet1.xml").unwrap().data).unwrap();
4531        assert!(
4532            worksheet_xml.contains(r#"<c r="A1" s="1"/>"#),
4533            "{worksheet_xml}"
4534        );
4535    }
4536
4537    #[test]
4538    fn writes_a_formula_cell_with_text_and_cached_value() {
4539        let workbook = Workbook::new().with_sheet(
4540            Sheet::new("Feuil1")
4541                .with_row(Row::new().with_cell(Cell::formula("A1*2").with_cached_value(20.0))),
4542        );
4543
4544        let buffer = workbook.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4545        let package = opc::Package::read_from(buffer).unwrap();
4546
4547        let worksheet_xml =
4548            std::str::from_utf8(&package.part("/xl/worksheets/sheet1.xml").unwrap().data).unwrap();
4549        assert!(
4550            worksheet_xml.contains(r#"<c r="A1"><f>A1*2</f><v>20</v></c>"#),
4551            "{worksheet_xml}"
4552        );
4553    }
4554
4555    #[test]
4556    fn writes_a_formula_cell_with_no_cached_value_and_no_v_element() {
4557        let workbook = Workbook::new()
4558            .with_sheet(Sheet::new("Feuil1").with_row(Row::new().with_formula("A1+1")));
4559
4560        let buffer = workbook.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4561        let package = opc::Package::read_from(buffer).unwrap();
4562
4563        let worksheet_xml =
4564            std::str::from_utf8(&package.part("/xl/worksheets/sheet1.xml").unwrap().data).unwrap();
4565        assert!(
4566            worksheet_xml.contains(r#"<c r="A1"><f>A1+1</f></c>"#),
4567            "{worksheet_xml}"
4568        );
4569    }
4570
4571    #[test]
4572    fn writes_a_cell_is_conditional_formatting_rule_with_its_dxf() {
4573        use crate::model::ConditionalFormattingRule;
4574
4575        let format = CellFormat::new()
4576            .with_bold(true)
4577            .with_fill_color("FFFFFF00");
4578        let rule = ConditionalFormattingRule::cell_is(
4579            "E3:E9",
4580            ComparisonOperator::GreaterThan,
4581            "0.5",
4582            format,
4583        );
4584
4585        let workbook =
4586            Workbook::new().with_sheet(Sheet::new("Feuil1").with_conditional_formatting_rule(rule));
4587
4588        let buffer = workbook.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4589        let package = opc::Package::read_from(buffer).unwrap();
4590
4591        let worksheet_xml =
4592            std::str::from_utf8(&package.part("/xl/worksheets/sheet1.xml").unwrap().data).unwrap();
4593        assert!(
4594            worksheet_xml.contains(r#"<conditionalFormatting sqref="E3:E9">"#),
4595            "{worksheet_xml}"
4596        );
4597        assert!(
4598            worksheet_xml.contains(
4599                r#"<cfRule type="cellIs" dxfId="0" priority="1" operator="greaterThan">"#
4600            ),
4601            "{worksheet_xml}"
4602        );
4603        assert!(
4604            worksheet_xml.contains("<formula>0.5</formula>"),
4605            "{worksheet_xml}"
4606        );
4607
4608        let styles_xml =
4609            std::str::from_utf8(&package.part(STYLES_PART_NAME).unwrap().data).unwrap();
4610        assert!(styles_xml.contains(r#"<dxfs count="1">"#), "{styles_xml}");
4611        assert!(styles_xml.contains("<b/>"), "{styles_xml}");
4612        assert!(
4613            styles_xml.contains(r#"<bgColor rgb="FFFFFF00"/>"#),
4614            "{styles_xml}"
4615        );
4616    }
4617
4618    #[test]
4619    fn writes_an_expression_conditional_formatting_rule_with_two_formulas_for_between() {
4620        use crate::model::ConditionalFormattingRule;
4621
4622        let format = CellFormat::new().with_fill_color("FFFF0000");
4623        let rule =
4624            ConditionalFormattingRule::cell_is("A1:A10", ComparisonOperator::Between, "1", format)
4625                .with_second_formula("10");
4626
4627        let workbook =
4628            Workbook::new().with_sheet(Sheet::new("Feuil1").with_conditional_formatting_rule(rule));
4629
4630        let buffer = workbook.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4631        let package = opc::Package::read_from(buffer).unwrap();
4632
4633        let worksheet_xml =
4634            std::str::from_utf8(&package.part("/xl/worksheets/sheet1.xml").unwrap().data).unwrap();
4635        assert!(
4636            worksheet_xml.contains(r#"operator="between""#),
4637            "{worksheet_xml}"
4638        );
4639        assert!(
4640            worksheet_xml.contains("<formula>1</formula><formula>10</formula>"),
4641            "{worksheet_xml}"
4642        );
4643    }
4644
4645    #[test]
4646    fn writes_a_data_validation_list_rule() {
4647        use crate::model::DataValidationRule;
4648
4649        let rule = DataValidationRule::list("B2:B20", "\"Oui,Non\"");
4650
4651        let workbook =
4652            Workbook::new().with_sheet(Sheet::new("Feuil1").with_data_validation_rule(rule));
4653
4654        let buffer = workbook.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4655        let package = opc::Package::read_from(buffer).unwrap();
4656
4657        let worksheet_xml =
4658            std::str::from_utf8(&package.part("/xl/worksheets/sheet1.xml").unwrap().data).unwrap();
4659        assert!(
4660            worksheet_xml.contains(r#"<dataValidations count="1">"#),
4661            "{worksheet_xml}"
4662        );
4663        assert!(
4664            worksheet_xml.contains(r#"<dataValidation type="list" allowBlank="1" sqref="B2:B20">"#),
4665            "{worksheet_xml}"
4666        );
4667        // The writer's XML serializer escapes `"` as `&quot;` in text content (valid, if not
4668        // strictly required, for XML text nodes) — this asserts the escaped form actually produced,
4669        // not the raw source string.
4670        assert!(
4671            worksheet_xml.contains("<formula1>&quot;Oui,Non&quot;</formula1>"),
4672            "{worksheet_xml}"
4673        );
4674    }
4675
4676    #[test]
4677    fn writes_a_whole_number_data_validation_rule_with_messages() {
4678        use crate::model::{DataValidationRule, Message};
4679
4680        let rule =
4681            DataValidationRule::whole_number("C2:C20", ComparisonOperator::GreaterThanOrEqual, "0")
4682                .with_input_message(Message::new("Entrez un nombre positif").with_title("Saisie"))
4683                .with_error_message(Message::new("Valeur invalide").with_title("Erreur"));
4684
4685        let workbook =
4686            Workbook::new().with_sheet(Sheet::new("Feuil1").with_data_validation_rule(rule));
4687
4688        let buffer = workbook.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4689        let package = opc::Package::read_from(buffer).unwrap();
4690
4691        let worksheet_xml =
4692            std::str::from_utf8(&package.part("/xl/worksheets/sheet1.xml").unwrap().data).unwrap();
4693        assert!(worksheet_xml.contains(r#"type="whole""#), "{worksheet_xml}");
4694        assert!(
4695            worksheet_xml.contains(r#"operator="greaterThanOrEqual""#),
4696            "{worksheet_xml}"
4697        );
4698        assert!(
4699            worksheet_xml.contains(r#"showInputMessage="1""#),
4700            "{worksheet_xml}"
4701        );
4702        assert!(
4703            worksheet_xml.contains(r#"showErrorMessage="1""#),
4704            "{worksheet_xml}"
4705        );
4706        assert!(
4707            worksheet_xml.contains(r#"promptTitle="Saisie""#),
4708            "{worksheet_xml}"
4709        );
4710        assert!(
4711            worksheet_xml.contains(r#"prompt="Entrez un nombre positif""#),
4712            "{worksheet_xml}"
4713        );
4714        assert!(
4715            worksheet_xml.contains(r#"errorTitle="Erreur""#),
4716            "{worksheet_xml}"
4717        );
4718        assert!(
4719            worksheet_xml.contains(r#"error="Valeur invalide""#),
4720            "{worksheet_xml}"
4721        );
4722        assert!(
4723            worksheet_xml.contains("<formula1>0</formula1>"),
4724            "{worksheet_xml}"
4725        );
4726    }
4727
4728    #[test]
4729    fn writes_a_workbook_scoped_defined_name() {
4730        use crate::model::DefinedName;
4731
4732        let workbook = Workbook::new()
4733            .with_sheet(Sheet::new("Feuil1"))
4734            .with_defined_name(DefinedName::new("Plage_Ventes", "Feuil1!$A$1:$C$12"));
4735
4736        let buffer = workbook.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4737        let package = opc::Package::read_from(buffer).unwrap();
4738
4739        let workbook_xml =
4740            std::str::from_utf8(&package.part(MAIN_WORKBOOK_PART_NAME).unwrap().data).unwrap();
4741        assert!(workbook_xml.contains(r#"<definedNames><definedName name="Plage_Ventes">Feuil1!$A$1:$C$12</definedName></definedNames>"#), "{workbook_xml}");
4742    }
4743
4744    #[test]
4745    fn writes_a_sheet_scoped_hidden_defined_name_with_a_comment() {
4746        use crate::model::DefinedName;
4747
4748        let defined_name = DefinedName::new("Northwind_Database", "Feuil2!$A$1:$T$47")
4749            .with_local_sheet_id(1)
4750            .with_hidden(true)
4751            .with_comment("Source de donnees externe");
4752
4753        let workbook = Workbook::new()
4754            .with_sheet(Sheet::new("Feuil1"))
4755            .with_sheet(Sheet::new("Feuil2"))
4756            .with_defined_name(defined_name);
4757
4758        let buffer = workbook.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4759        let package = opc::Package::read_from(buffer).unwrap();
4760
4761        let workbook_xml =
4762            std::str::from_utf8(&package.part(MAIN_WORKBOOK_PART_NAME).unwrap().data).unwrap();
4763        assert!(
4764            workbook_xml.contains(
4765                r#"<definedName name="Northwind_Database" localSheetId="1" hidden="1" comment="Source de donnees externe">Feuil2!$A$1:$T$47</definedName>"#
4766            ),
4767            "{workbook_xml}"
4768        );
4769    }
4770
4771    #[test]
4772    fn omits_defined_names_entirely_when_none_are_set() {
4773        let workbook = Workbook::new().with_sheet(Sheet::new("Feuil1"));
4774
4775        let buffer = workbook.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4776        let package = opc::Package::read_from(buffer).unwrap();
4777
4778        let workbook_xml =
4779            std::str::from_utf8(&package.part(MAIN_WORKBOOK_PART_NAME).unwrap().data).unwrap();
4780        assert!(!workbook_xml.contains("definedNames"), "{workbook_xml}");
4781    }
4782}