Skip to main content

word_ooxml/
writer.rs

1//! Writing our minimal [`Document`] model out to a valid `.docx` package.
2
3use std::io::{Seek, Write};
4
5use opc::{Package, Part, Relationship, Relationships, TargetMode};
6use xml_core::{BytesDecl, BytesEnd, BytesStart, BytesText, Event, Writer};
7
8use crate::error::Result;
9use crate::model::{
10    Alignment, Block, Bookmark, BreakKind, ColorScheme, Comment, CustomPropertyValue, Document,
11    DocumentProperties, EmbeddedChart, ExtendedProperties, Field, FontScheme, HeaderFooter,
12    Highlight, Hyperlink, Image, Note, NoteKind, NoteReference, NumberingDefinition, Orientation,
13    PageSetup, Paragraph, ProtectionKind, Run, RunContent, StructuredDocumentTag, Style, StyleKind,
14    TabStop, Table, TableCell, TableRow, Theme, UnderlineStyle, VerticalAlign,
15};
16
17const WORDPROCESSINGML_NAMESPACE: &str =
18    "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
19// Namespaces needed for inline images (`<w:drawing>`). Declared unconditionally on the root
20// `<w:document>`/`<w:hdr>`/`<w:ftr>` element, whether or not that part actually contains an image —
21// harmless (real Word files declare dozens of namespaces regardless of what's used) and avoids a
22// two-pass write just to know upfront whether any image is present.
23const RELATIONSHIPS_NAMESPACE: &str =
24    "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
25const WORDPROCESSING_DRAWING_NAMESPACE: &str =
26    "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
27const DRAWINGML_NAMESPACE: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
28const DRAWINGML_PICTURE_NAMESPACE: &str =
29    "http://schemas.openxmlformats.org/drawingml/2006/picture";
30const IMAGE_RELATIONSHIP_TYPE: &str =
31    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
32
33// an embedded chart, inline in a run.
34const DRAWINGML_CHART_NAMESPACE: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart";
35const CHART_CONTENT_TYPE: &str =
36    "application/vnd.openxmlformats-officedocument.drawingml.chart+xml";
37const CHART_RELATIONSHIP_TYPE: &str =
38    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart";
39
40const MAIN_DOCUMENT_CONTENT_TYPE: &str =
41    "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml";
42const MAIN_DOCUMENT_RELATIONSHIP_TYPE: &str =
43    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument";
44const MAIN_DOCUMENT_PART_NAME: &str = "/word/document.xml";
45const MAIN_DOCUMENT_ENTRY_NAME: &str = "word/document.xml";
46
47// Beyond `word/document.xml` itself, real Word packages always carry a minimal set of additional
48// parts: `docProps/core.xml`, `docProps/app.xml` and `word/settings.xml`, plus a `settings`
49// relationship from `word/document.xml`, are written unconditionally on every save regardless of
50// what the caller populated. `styles.xml`, `webSettings.xml`, `fontTable.xml` and the theme are NOT
51// part of that minimum — they are only written on demand, so they are intentionally left out here
52// otherwise.
53//
54// Schema-wise, every element of `CT_CoreProperties` (ECMA-376 Part 2, OPC core properties schema)
55// and of `CT_Properties` (ECMA-376 Part 1, extended properties schema) has `minOccurs="0"`, so
56// minimal/near-empty content is schema-valid; the exact XML shape below was cross-checked against
57// `tests-data/word/minimal.docx`, a real file produced by Word.
58const CORE_PROPERTIES_CONTENT_TYPE: &str =
59    "application/vnd.openxmlformats-package.core-properties+xml";
60const CORE_PROPERTIES_RELATIONSHIP_TYPE: &str =
61    "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties";
62const CORE_PROPERTIES_PART_NAME: &str = "/docProps/core.xml";
63const CORE_PROPERTIES_ENTRY_NAME: &str = "docProps/core.xml";
64const CORE_PROPERTIES_NAMESPACE: &str =
65    "http://schemas.openxmlformats.org/package/2006/metadata/core-properties";
66const DC_NAMESPACE: &str = "http://purl.org/dc/elements/1.1/";
67const DCTERMS_NAMESPACE: &str = "http://purl.org/dc/terms/";
68const XSI_NAMESPACE: &str = "http://www.w3.org/2001/XMLSchema-instance";
69
70const EXTENDED_PROPERTIES_CONTENT_TYPE: &str =
71    "application/vnd.openxmlformats-officedocument.extended-properties+xml";
72const EXTENDED_PROPERTIES_RELATIONSHIP_TYPE: &str =
73    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties";
74const EXTENDED_PROPERTIES_PART_NAME: &str = "/docProps/app.xml";
75const EXTENDED_PROPERTIES_ENTRY_NAME: &str = "docProps/app.xml";
76const EXTENDED_PROPERTIES_NAMESPACE: &str =
77    "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties";
78
79// `docProps/custom.xml` (OPC custom properties) — unlike core/extended properties above, this part
80// is genuinely optional: it is only written (and only gets a package relationship) when
81// `Document.custom_properties` is non-empty, same "optional part" convention as `styles.xml`/
82// `footnotes.xml`/etc., just applied at the package level (its relationship lives in `_rels/.rels`,
83// like core/extended properties, not in `word/_rels/document.xml.rels`).
84const CUSTOM_PROPERTIES_CONTENT_TYPE: &str =
85    "application/vnd.openxmlformats-officedocument.custom-properties+xml";
86const CUSTOM_PROPERTIES_RELATIONSHIP_TYPE: &str =
87    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties";
88const CUSTOM_PROPERTIES_PART_NAME: &str = "/docProps/custom.xml";
89const CUSTOM_PROPERTIES_ENTRY_NAME: &str = "docProps/custom.xml";
90const CUSTOM_PROPERTIES_NAMESPACE: &str =
91    "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties";
92const CUSTOM_PROPERTIES_VT_NAMESPACE: &str =
93    "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes";
94// `CT_Property/@fmtid` is a fixed, format-defined GUID for custom properties (confirmed via
95// ECMA-376's own schema example) — not something callers choose, unlike `@pid`/`@name`.
96const CUSTOM_PROPERTY_FMTID: &str = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}";
97// `@pid` values `0` and `1` are reserved by the format; user-defined properties start at `2`
98// (confirmed via ECMA-376's own schema example, which uses `pid="2"` for the first custom
99// property).
100const FIRST_CUSTOM_PROPERTY_PID: i32 = 2;
101
102const SETTINGS_CONTENT_TYPE: &str =
103    "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml";
104const SETTINGS_RELATIONSHIP_TYPE: &str =
105    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings";
106const SETTINGS_PART_NAME: &str = "/word/settings.xml";
107const SETTINGS_ENTRY_NAME: &str = "settings.xml";
108
109// Content type, relationship type and part-name pattern for header/footer parts. Up to 3 header
110// parts and 3 footer parts can be written (default/first/even — see
111// `Document.header`/`.header_first`/`.header_even`'s doc comments), so each variant gets its own
112// fixed, distinctly-numbered part name rather than a shared counter.
113const HEADER_CONTENT_TYPE: &str =
114    "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml";
115const HEADER_RELATIONSHIP_TYPE: &str =
116    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
117const HEADER_PART_NAME: &str = "/word/header1.xml";
118const HEADER_ENTRY_NAME: &str = "header1.xml";
119const HEADER_FIRST_PART_NAME: &str = "/word/header2.xml";
120const HEADER_FIRST_ENTRY_NAME: &str = "header2.xml";
121const HEADER_EVEN_PART_NAME: &str = "/word/header3.xml";
122const HEADER_EVEN_ENTRY_NAME: &str = "header3.xml";
123
124const FOOTER_CONTENT_TYPE: &str =
125    "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml";
126const FOOTER_RELATIONSHIP_TYPE: &str =
127    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
128const FOOTER_PART_NAME: &str = "/word/footer1.xml";
129const FOOTER_ENTRY_NAME: &str = "footer1.xml";
130const FOOTER_FIRST_PART_NAME: &str = "/word/footer2.xml";
131const FOOTER_FIRST_ENTRY_NAME: &str = "footer2.xml";
132const FOOTER_EVEN_PART_NAME: &str = "/word/footer3.xml";
133const FOOTER_EVEN_ENTRY_NAME: &str = "footer3.xml";
134
135// Unlike header/footer, `word/styles.xml` is never referenced by an explicit `r:id` anywhere in
136// `document.xml`'s content — Word locates it purely by relationship type, exactly like the
137// `settings` relationship above.
138const STYLES_CONTENT_TYPE: &str =
139    "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml";
140const STYLES_RELATIONSHIP_TYPE: &str =
141    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles";
142const STYLES_PART_NAME: &str = "/word/styles.xml";
143const STYLES_ENTRY_NAME: &str = "styles.xml";
144
145// Unlike every other relationship in this file, a hyperlink relationship never corresponds to a
146// package part at all — it is always written with `TargetMode="External"` (see
147// `PartRelationships::add_external`), pointing at an arbitrary URL outside the package, never
148// resolved or validated as one of our own parts.
149const HYPERLINK_RELATIONSHIP_TYPE: &str =
150    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
151
152// Like `word/styles.xml`, `word/numbering.xml` is located purely by relationship type — nothing in
153// `document.xml`'s content carries an explicit `r:id` for it (a paragraph's `w:numPr/w:numId` is a
154// plain integer, not a relationship id at all).
155const NUMBERING_CONTENT_TYPE: &str =
156    "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml";
157const NUMBERING_RELATIONSHIP_TYPE: &str =
158    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering";
159const NUMBERING_PART_NAME: &str = "/word/numbering.xml";
160const NUMBERING_ENTRY_NAME: &str = "numbering.xml";
161
162// Like `word/styles.xml`/`word/numbering.xml`, these are located purely by relationship type — a
163// `<w:footnoteReference>`/`<w:endnoteReference>`'s `w:id` is a plain integer, not a relationship
164// id.
165const FOOTNOTES_CONTENT_TYPE: &str =
166    "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml";
167const FOOTNOTES_RELATIONSHIP_TYPE: &str =
168    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes";
169const FOOTNOTES_PART_NAME: &str = "/word/footnotes.xml";
170const FOOTNOTES_ENTRY_NAME: &str = "footnotes.xml";
171
172const ENDNOTES_CONTENT_TYPE: &str =
173    "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml";
174const ENDNOTES_RELATIONSHIP_TYPE: &str =
175    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes";
176const ENDNOTES_PART_NAME: &str = "/word/endnotes.xml";
177const ENDNOTES_ENTRY_NAME: &str = "endnotes.xml";
178
179// Grounded in ECMA-376 Part 1 §2.13.4 (Comments). Like `word/footnotes.xml`/`word/endnotes.xml`,
180// located purely by relationship type — a `w:commentReference`'s `w:id` is a plain integer, not a
181// relationship id.
182const COMMENTS_CONTENT_TYPE: &str =
183    "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml";
184const COMMENTS_RELATIONSHIP_TYPE: &str =
185    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
186const COMMENTS_PART_NAME: &str = "/word/comments.xml";
187const COMMENTS_ENTRY_NAME: &str = "comments.xml";
188
189// Word's own built-in latent character style Word applies to a `w:commentReference` run (the
190// balloon anchor mark in the body), the same way `FootnoteReference`/`EndnoteReference` apply to
191// their own reference runs — no declaration needed in `Document.styles`.
192const COMMENT_REFERENCE_STYLE_ID: &str = "CommentReference";
193
194// Grounded in ECMA-376 Part 1 §14.2.7 (Theme Part). Like `word/styles.xml`/`word/numbering.xml`,
195// located purely by relationship type — nothing in `document.xml`'s content carries an explicit
196// `r:id` for it.
197const THEME_CONTENT_TYPE: &str = "application/vnd.openxmlformats-officedocument.theme+xml";
198const THEME_RELATIONSHIP_TYPE: &str =
199    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme";
200const THEME_PART_NAME: &str = "/word/theme/theme1.xml";
201const THEME_ENTRY_NAME: &str = "theme/theme1.xml";
202
203impl Document {
204    /// Writes this document out as a valid `.docx` package.
205    ///
206    /// Writes `[Content_Types].xml`, `_rels/.rels`, `word/document.xml`,
207    /// `word/_rels/document.xml.rels`, `word/settings.xml`, `docProps/core.xml`,
208    /// `docProps/app.xml`, and — if set/non-empty — `word/header1.xml`/`word/footer1.xml` (default)
209    /// plus `header2.xml`/`footer2.xml` (first page) and `header3.xml`/ `footer3.xml` (even pages),
210    /// `word/styles.xml`, `word/numbering.xml`, `word/footnotes.xml`, `word/endnotes.xml`,
211    /// `word/comments.xml`, `word/theme/theme1.xml`, `docProps/custom.xml`. `webSettings.xml`/
212    /// `fontTable.xml` are not written yet; they are optional, not required for the file to open
213    /// cleanly. Page setup (size/orientation/margins/ columns) comes from `Document.page_setup`, a
214    /// single document-wide section — see that field's doc comment for why mid-document section
215    /// breaks aren't modeled.
216    pub fn write_to<W: Write + Seek>(&self, writer: W) -> Result<W> {
217        let mut package = Package::new();
218
219        package.add_relationship(Relationship {
220            id: "rId1".to_string(),
221            rel_type: MAIN_DOCUMENT_RELATIONSHIP_TYPE.to_string(),
222            target: MAIN_DOCUMENT_ENTRY_NAME.to_string(),
223            target_mode: TargetMode::Internal,
224        });
225        package.add_relationship(Relationship {
226            id: "rId2".to_string(),
227            rel_type: CORE_PROPERTIES_RELATIONSHIP_TYPE.to_string(),
228            target: CORE_PROPERTIES_ENTRY_NAME.to_string(),
229            target_mode: TargetMode::Internal,
230        });
231        package.add_relationship(Relationship {
232            id: "rId3".to_string(),
233            rel_type: EXTENDED_PROPERTIES_RELATIONSHIP_TYPE.to_string(),
234            target: EXTENDED_PROPERTIES_ENTRY_NAME.to_string(),
235            target_mode: TargetMode::Internal,
236        });
237        // `docProps/custom.xml` is a genuinely optional part (see its constants' doc comment above)
238        // — its package relationship is only added when there's actually something to write, unlike
239        // `rId1`/`rId2`/`rId3` above which are unconditional.
240        if !self.custom_properties.is_empty() {
241            package.add_relationship(Relationship {
242                id: "rId4".to_string(),
243                rel_type: CUSTOM_PROPERTIES_RELATIONSHIP_TYPE.to_string(),
244                target: CUSTOM_PROPERTIES_ENTRY_NAME.to_string(),
245                target_mode: TargetMode::Internal,
246            });
247        }
248
249        // Package-wide state, threaded through every part written below: images referenced anywhere
250        // (body, header, footer), collected as package-wide-unique media parts so `write_to` can
251        // add them once every part has been walked. `document_relationships` accumulates
252        // `word/document.xml`'s own relationships (settings, header/footer, and any images used
253        // directly in the body) — unlike `state`, this one is scoped to this one part, not the
254        // whole package (see [`PartRelationships`]).
255        let mut state = PackageState::new();
256        let mut document_relationships = PartRelationships::new();
257        document_relationships.add(SETTINGS_RELATIONSHIP_TYPE, SETTINGS_ENTRY_NAME); // rId1
258
259        // `word/styles.xml` is only written if the document actually declares styles — like
260        // header/footer, an optional part. Unlike them, nothing in `document.xml`'s content needs
261        // to know its relationship id (Word finds it by relationship type alone), so this can be
262        // added any time before `document_part` is finalized.
263        if !self.styles.is_empty() {
264            document_relationships.add(STYLES_RELATIONSHIP_TYPE, STYLES_ENTRY_NAME);
265            package.add_part(Part::new(
266                STYLES_PART_NAME,
267                STYLES_CONTENT_TYPE,
268                to_styles_xml(&self.styles)?,
269            ));
270        }
271
272        // `word/numbering.xml`, same optional-part treatment as `word/styles.xml` above (located by
273        // relationship type alone, no `r:id` reference needed anywhere in `document.xml`'s content
274        // — a paragraph's `w:numPr/w:numId` is a plain integer, not a relationship id).
275        if !self.numbering_definitions.is_empty() {
276            document_relationships.add(NUMBERING_RELATIONSHIP_TYPE, NUMBERING_ENTRY_NAME);
277            package.add_part(Part::new(
278                NUMBERING_PART_NAME,
279                NUMBERING_CONTENT_TYPE,
280                to_numbering_xml(&self.numbering_definitions)?,
281            ));
282        }
283
284        // `word/footnotes.xml`/`word/endnotes.xml`, same optional-part treatment, same
285        // by-relationship-type-only lookup. Each note gets its own part-scoped relationships (a
286        // footnote could embed its own image), independent of `document.xml.rels`'s numbering —
287        // mirrors `write_header_footer_part` below.
288        if !self.footnotes.is_empty() {
289            document_relationships.add(FOOTNOTES_RELATIONSHIP_TYPE, FOOTNOTES_ENTRY_NAME);
290            let mut footnotes_relationships = PartRelationships::new();
291            let xml = to_notes_xml(
292                "w:footnotes",
293                "w:footnote",
294                &self.footnotes,
295                &mut state,
296                &mut footnotes_relationships,
297            )?;
298            let mut part = Part::new(FOOTNOTES_PART_NAME, FOOTNOTES_CONTENT_TYPE, xml);
299            part.relationships = footnotes_relationships.relationships;
300            package.add_part(part);
301        }
302        if !self.endnotes.is_empty() {
303            document_relationships.add(ENDNOTES_RELATIONSHIP_TYPE, ENDNOTES_ENTRY_NAME);
304            let mut endnotes_relationships = PartRelationships::new();
305            let xml = to_notes_xml(
306                "w:endnotes",
307                "w:endnote",
308                &self.endnotes,
309                &mut state,
310                &mut endnotes_relationships,
311            )?;
312            let mut part = Part::new(ENDNOTES_PART_NAME, ENDNOTES_CONTENT_TYPE, xml);
313            part.relationships = endnotes_relationships.relationships;
314            package.add_part(part);
315        }
316
317        // `word/comments.xml`, same optional-part treatment, same by-relationship-type-only lookup.
318        if !self.comments.is_empty() {
319            document_relationships.add(COMMENTS_RELATIONSHIP_TYPE, COMMENTS_ENTRY_NAME);
320            let mut comments_relationships = PartRelationships::new();
321            let xml = to_comments_xml(&self.comments, &mut state, &mut comments_relationships)?;
322            let mut part = Part::new(COMMENTS_PART_NAME, COMMENTS_CONTENT_TYPE, xml);
323            part.relationships = comments_relationships.relationships;
324            package.add_part(part);
325        }
326
327        // `word/theme/theme1.xml`, same optional-part treatment, same by-relationship-type-only
328        // lookup. No part-scoped relationships of its own are written (a theme part isn't given any
329        // explicit relationships here — see [`Theme`]'s doc comment).
330        if let Some(theme) = &self.theme {
331            document_relationships.add(THEME_RELATIONSHIP_TYPE, THEME_ENTRY_NAME);
332            package.add_part(Part::new(
333                THEME_PART_NAME,
334                THEME_CONTENT_TYPE,
335                to_theme_xml(theme)?,
336            ));
337        }
338
339        // Headers/footers are written before the body: the body's `w:sectPr` needs to reference
340        // their relationship ids (`w:headerReference`/`w:footerReference`), so those ids must be
341        // known upfront. Each gets its own, independently-scoped relationships (a header can embed
342        // its own image, e.g. a logo, with an `r:id` local to `header1.xml.rels`, unrelated to
343        // `document.xml.rels`'s own numbering).
344        let header_relationship_id = match &self.header {
345            Some(header) => Some(write_header_footer_part(
346                &mut package,
347                &mut state,
348                &mut document_relationships,
349                header,
350                "w:hdr",
351                HEADER_PART_NAME,
352                HEADER_CONTENT_TYPE,
353                HEADER_RELATIONSHIP_TYPE,
354                HEADER_ENTRY_NAME,
355            )?),
356            None => None,
357        };
358        let footer_relationship_id = match &self.footer {
359            Some(footer) => Some(write_header_footer_part(
360                &mut package,
361                &mut state,
362                &mut document_relationships,
363                footer,
364                "w:ftr",
365                FOOTER_PART_NAME,
366                FOOTER_CONTENT_TYPE,
367                FOOTER_RELATIONSHIP_TYPE,
368                FOOTER_ENTRY_NAME,
369            )?),
370            None => None,
371        };
372        // First-page and even-page variants, same treatment — each is its own independent
373        // part/relationship, written only if set. Setting either of a pair
374        // (`header_first`/`footer_first`, `header_even`/`footer_even`) turns on the matching
375        // `w:titlePg`/ `w:evenAndOddHeaders` switch below, even if only one of the two (say, just a
376        // first-page footer, no first-page header) is set — ECMA-376 ties the switch to the
377        // section/document as a whole, not to header and footer independently.
378        let header_first_relationship_id = match &self.header_first {
379            Some(header) => Some(write_header_footer_part(
380                &mut package,
381                &mut state,
382                &mut document_relationships,
383                header,
384                "w:hdr",
385                HEADER_FIRST_PART_NAME,
386                HEADER_CONTENT_TYPE,
387                HEADER_RELATIONSHIP_TYPE,
388                HEADER_FIRST_ENTRY_NAME,
389            )?),
390            None => None,
391        };
392        let footer_first_relationship_id = match &self.footer_first {
393            Some(footer) => Some(write_header_footer_part(
394                &mut package,
395                &mut state,
396                &mut document_relationships,
397                footer,
398                "w:ftr",
399                FOOTER_FIRST_PART_NAME,
400                FOOTER_CONTENT_TYPE,
401                FOOTER_RELATIONSHIP_TYPE,
402                FOOTER_FIRST_ENTRY_NAME,
403            )?),
404            None => None,
405        };
406        let header_even_relationship_id = match &self.header_even {
407            Some(header) => Some(write_header_footer_part(
408                &mut package,
409                &mut state,
410                &mut document_relationships,
411                header,
412                "w:hdr",
413                HEADER_EVEN_PART_NAME,
414                HEADER_CONTENT_TYPE,
415                HEADER_RELATIONSHIP_TYPE,
416                HEADER_EVEN_ENTRY_NAME,
417            )?),
418            None => None,
419        };
420        let footer_even_relationship_id = match &self.footer_even {
421            Some(footer) => Some(write_header_footer_part(
422                &mut package,
423                &mut state,
424                &mut document_relationships,
425                footer,
426                "w:ftr",
427                FOOTER_EVEN_PART_NAME,
428                FOOTER_CONTENT_TYPE,
429                FOOTER_RELATIONSHIP_TYPE,
430                FOOTER_EVEN_ENTRY_NAME,
431            )?),
432            None => None,
433        };
434        let title_pg = self.header_first.is_some() || self.footer_first.is_some();
435        let even_and_odd_headers = self.header_even.is_some() || self.footer_even.is_some();
436
437        let document_xml = self.to_document_xml(
438            &mut state,
439            &mut document_relationships,
440            SectionReferenceIds {
441                header: header_relationship_id.as_deref(),
442                footer: footer_relationship_id.as_deref(),
443                header_first: header_first_relationship_id.as_deref(),
444                footer_first: footer_first_relationship_id.as_deref(),
445                header_even: header_even_relationship_id.as_deref(),
446                footer_even: footer_even_relationship_id.as_deref(),
447                title_pg,
448            },
449        )?;
450        let mut document_part = Part::new(
451            MAIN_DOCUMENT_PART_NAME,
452            MAIN_DOCUMENT_CONTENT_TYPE,
453            document_xml,
454        );
455        document_part.relationships = document_relationships.relationships;
456        package.add_part(document_part);
457
458        for media_part in state.images.media_parts {
459            package.add_part(Part::new(
460                format!("/word/{}", media_part.entry_name),
461                media_part.content_type,
462                media_part.data,
463            ));
464        }
465
466        for chart_part in state.charts.chart_parts {
467            package.add_part(Part::new(
468                format!("/word/{}", chart_part.entry_name),
469                CHART_CONTENT_TYPE,
470                chart_part.data,
471            ));
472        }
473
474        package.add_part(Part::new(
475            SETTINGS_PART_NAME,
476            SETTINGS_CONTENT_TYPE,
477            to_settings_xml(self.track_changes, self.protection, even_and_odd_headers)?,
478        ));
479        package.add_part(Part::new(
480            CORE_PROPERTIES_PART_NAME,
481            CORE_PROPERTIES_CONTENT_TYPE,
482            to_core_properties_xml(&self.properties)?,
483        ));
484        package.add_part(Part::new(
485            EXTENDED_PROPERTIES_PART_NAME,
486            EXTENDED_PROPERTIES_CONTENT_TYPE,
487            to_extended_properties_xml(&self.extended_properties)?,
488        ));
489        if !self.custom_properties.is_empty() {
490            package.add_part(Part::new(
491                CUSTOM_PROPERTIES_PART_NAME,
492                CUSTOM_PROPERTIES_CONTENT_TYPE,
493                to_custom_properties_xml(&self.custom_properties)?,
494            ));
495        }
496
497        Ok(package.write_to(writer)?)
498    }
499
500    /// Serializes this document to the bytes of a `document.xml` part. Images encountered along the
501    /// way are registered into `state` (assigning each a relationship id), rather than written here
502    /// — the caller (`write_to`) adds their media parts and relationships once the whole document
503    /// has been walked. `section_references`'s relationship ids, if set, are embedded in `w:sectPr`
504    /// as `w:headerReference`/ `w:footerReference`.
505    fn to_document_xml(
506        &self,
507        state: &mut PackageState,
508        relationships: &mut PartRelationships,
509        section_references: SectionReferenceIds<'_>,
510    ) -> Result<Vec<u8>> {
511        let mut writer = Writer::new(Vec::new());
512
513        writer.write_event(Event::Decl(BytesDecl::new(
514            "1.0",
515            Some("UTF-8"),
516            Some("yes"),
517        )))?;
518
519        let mut root = BytesStart::new("w:document");
520        root.push_attribute(("xmlns:w", WORDPROCESSINGML_NAMESPACE));
521        root.push_attribute(("xmlns:r", RELATIONSHIPS_NAMESPACE));
522        root.push_attribute(("xmlns:wp", WORDPROCESSING_DRAWING_NAMESPACE));
523        root.push_attribute(("xmlns:a", DRAWINGML_NAMESPACE));
524        root.push_attribute(("xmlns:pic", DRAWINGML_PICTURE_NAMESPACE));
525        writer.write_event(Event::Start(root))?;
526        writer.write_event(Event::Start(BytesStart::new("w:body")))?;
527
528        for block in &self.blocks {
529            write_block(&mut writer, block, state, relationships)?;
530        }
531
532        write_section_properties(&mut writer, section_references, &self.page_setup)?;
533
534        writer.write_event(Event::End(BytesEnd::new("w:body")))?;
535        writer.write_event(Event::End(BytesEnd::new("w:document")))?;
536
537        Ok(writer.into_inner())
538    }
539}
540
541/// The relationship ids of every header/footer variant `w:sectPr` may reference
542/// (`w:headerReference`/`w:footerReference`, one pair per `default`/`first`/`even` — see
543/// `Document.header`/`.header_first`/ `.header_even`'s doc comments), plus whether `w:titlePg`
544/// should be written (derived from whether a first-page header/footer is set — see
545/// `Document.write_to`). Bundled into one struct rather than passed as 7 separate parameters,
546/// purely to keep `to_document_xml`'s/ `write_section_properties`'s signatures readable.
547#[derive(Default)]
548struct SectionReferenceIds<'a> {
549    header: Option<&'a str>,
550    footer: Option<&'a str>,
551    header_first: Option<&'a str>,
552    footer_first: Option<&'a str>,
553    header_even: Option<&'a str>,
554    footer_even: Option<&'a str>,
555    title_pg: bool,
556}
557
558/// Serializes a header or footer's content to its own part, adds it to `package`, declares a
559/// relationship to it in `document_relationships` (`word/document.xml`'s own relationships, since a
560/// `headerReference`/`footerReference` in `document.xml`'s `sectPr` points there), and returns that
561/// relationship's id.
562#[allow(clippy::too_many_arguments)]
563fn write_header_footer_part(
564    package: &mut Package,
565    state: &mut PackageState,
566    document_relationships: &mut PartRelationships,
567    header_footer: &HeaderFooter,
568    root_name: &str,
569    part_name: &str,
570    content_type: &str,
571    relationship_type: &str,
572    entry_name: &str,
573) -> Result<String> {
574    let mut part_relationships = PartRelationships::new();
575    let xml = to_header_footer_xml(
576        root_name,
577        &header_footer.blocks,
578        state,
579        &mut part_relationships,
580    )?;
581
582    let mut part = Part::new(part_name, content_type, xml);
583    part.relationships = part_relationships.relationships;
584    package.add_part(part);
585
586    Ok(document_relationships.add(relationship_type, entry_name))
587}
588
589/// Serializes a header or footer part's content (`word/header1.xml` / `word/footer1.xml`).
590/// `CT_HdrFtr` (ECMA-376 Part 1, `wml.xsd`) reuses the exact same block-level content model as the
591/// document body (`EG_BlockLevelElts`: paragraphs and tables) — just without the `w:body` wrapper
592/// or a `w:sectPr` (section properties only ever appear in the main document body). `root_name` is
593/// `"w:hdr"` or `"w:ftr"` (`<xsd:element name="hdr" type="CT_HdrFtr"/>` / `name="ftr"`).
594fn to_header_footer_xml(
595    root_name: &str,
596    blocks: &[Block],
597    state: &mut PackageState,
598    relationships: &mut PartRelationships,
599) -> Result<Vec<u8>> {
600    let mut writer = Writer::new(Vec::new());
601
602    writer.write_event(Event::Decl(BytesDecl::new(
603        "1.0",
604        Some("UTF-8"),
605        Some("yes"),
606    )))?;
607
608    let mut root = BytesStart::new(root_name);
609    root.push_attribute(("xmlns:w", WORDPROCESSINGML_NAMESPACE));
610    root.push_attribute(("xmlns:r", RELATIONSHIPS_NAMESPACE));
611    root.push_attribute(("xmlns:wp", WORDPROCESSING_DRAWING_NAMESPACE));
612    root.push_attribute(("xmlns:a", DRAWINGML_NAMESPACE));
613    root.push_attribute(("xmlns:pic", DRAWINGML_PICTURE_NAMESPACE));
614    writer.write_event(Event::Start(root))?;
615
616    for block in blocks {
617        write_block(&mut writer, block, state, relationships)?;
618    }
619
620    writer.write_event(Event::End(BytesEnd::new(root_name)))?;
621
622    Ok(writer.into_inner())
623}
624
625/// Writes a single block-level element — a paragraph, a table, or a structured document tag —
626/// dispatching to the matching writer. Shared by every place a block sequence is written: the
627/// document body, header/ footer parts, footnotes/endnotes, comments, and a structured document
628/// tag's own content (`w:sdtContent` reuses the same block content model, so a nested `w:sdt`
629/// round-trips through this same function again).
630fn write_block<W: Write>(
631    writer: &mut Writer<W>,
632    block: &Block,
633    state: &mut PackageState,
634    relationships: &mut PartRelationships,
635) -> Result<()> {
636    match block {
637        Block::Paragraph(paragraph) => write_paragraph(writer, paragraph, state, relationships),
638        Block::Table(table) => write_table(writer, table, state, relationships),
639        Block::StructuredDocumentTag(sdt) => {
640            write_structured_document_tag(writer, sdt, state, relationships)
641        }
642    }
643}
644
645/// Writes a single `<w:p>` paragraph, including its `w:pPr`/`w:jc` alignment and each run's content
646/// — text with `w:rPr` formatting, an inline image (`<w:drawing>`), or a field (`<w:fldSimple>`,
647/// e.g. a page number) — each optionally wrapped in a `<w:hyperlink>` if the run sets one. Shared
648/// between the document body, table cells (`CT_Tc` requires paragraphs too) and header/footer parts
649/// (`CT_HdrFtr` reuses the same content model).
650fn write_paragraph<W: Write>(
651    writer: &mut Writer<W>,
652    paragraph: &Paragraph,
653    state: &mut PackageState,
654    relationships: &mut PartRelationships,
655) -> Result<()> {
656    if paragraph.runs.is_empty()
657        && paragraph.alignment.is_none()
658        && paragraph.style_id.is_none()
659        && paragraph.numbering_id.is_none()
660        && paragraph.line_spacing.is_none()
661        && paragraph.space_before.is_none()
662        && paragraph.space_after.is_none()
663        && paragraph.shading_color.is_none()
664        && !paragraph.border
665        && !paragraph.keep_with_next
666        && !paragraph.keep_lines_together
667        && !paragraph.page_break_before
668        && paragraph.tabs.is_empty()
669        && !paragraph.contextual_spacing
670    {
671        writer.write_event(Event::Empty(BytesStart::new("w:p")))?;
672        return Ok(());
673    }
674
675    writer.write_event(Event::Start(BytesStart::new("w:p")))?;
676
677    // `w:pPr`, when present, must be the first child of `w:p` (WordprocessingML `CT_P` is a strict
678    // `xsd:sequence`, unlike the `CT_RPr` run-properties group below — order matters here). Within
679    // `w:pPr` itself, `CT_PPrBase`'s own strict sequence lists `pStyle`, then
680    // `keepNext`/`keepLines`/`pageBreakBefore`, then (much later) `numPr`, then `pBdr`, then `shd`,
681    // then `tabs`, then (much later still) `spacing`, then `contextualSpacing`, then (later still)
682    // `jc` — confirmed via `wml.xsd`'s `CT_PPrBase` (see `write_paragraph`'s call sites).
683    if paragraph.style_id.is_some()
684        || paragraph.numbering_id.is_some()
685        || paragraph.shading_color.is_some()
686        || paragraph.border
687        || paragraph.line_spacing.is_some()
688        || paragraph.space_before.is_some()
689        || paragraph.space_after.is_some()
690        || paragraph.alignment.is_some()
691        || paragraph.keep_with_next
692        || paragraph.keep_lines_together
693        || paragraph.page_break_before
694        || !paragraph.tabs.is_empty()
695        || paragraph.contextual_spacing
696    {
697        writer.write_event(Event::Start(BytesStart::new("w:pPr")))?;
698        if let Some(style_id) = &paragraph.style_id {
699            let mut p_style = BytesStart::new("w:pStyle");
700            p_style.push_attribute(("w:val", style_id.as_str()));
701            writer.write_event(Event::Empty(p_style))?;
702        }
703        if paragraph.keep_with_next {
704            writer.write_event(Event::Empty(BytesStart::new("w:keepNext")))?;
705        }
706        if paragraph.keep_lines_together {
707            writer.write_event(Event::Empty(BytesStart::new("w:keepLines")))?;
708        }
709        if paragraph.page_break_before {
710            writer.write_event(Event::Empty(BytesStart::new("w:pageBreakBefore")))?;
711        }
712        if let Some(numbering_id) = paragraph.numbering_id {
713            // `CT_NumPr`'s own sequence: `ilvl` before `numId`.
714            writer.write_event(Event::Start(BytesStart::new("w:numPr")))?;
715            let mut ilvl = BytesStart::new("w:ilvl");
716            let level = paragraph.numbering_level.to_string();
717            ilvl.push_attribute(("w:val", level.as_str()));
718            writer.write_event(Event::Empty(ilvl))?;
719            let mut num_id = BytesStart::new("w:numId");
720            let id = numbering_id.to_string();
721            num_id.push_attribute(("w:val", id.as_str()));
722            writer.write_event(Event::Empty(num_id))?;
723            writer.write_event(Event::End(BytesEnd::new("w:numPr")))?;
724        }
725        if paragraph.border {
726            write_paragraph_border(writer)?;
727        }
728        if let Some(shading_color) = &paragraph.shading_color {
729            write_shading(writer, "w:shd", shading_color)?;
730        }
731        if !paragraph.tabs.is_empty() {
732            write_tabs(writer, &paragraph.tabs)?;
733        }
734        if paragraph.line_spacing.is_some()
735            || paragraph.space_before.is_some()
736            || paragraph.space_after.is_some()
737        {
738            write_paragraph_spacing(
739                writer,
740                paragraph.line_spacing,
741                paragraph.space_before,
742                paragraph.space_after,
743            )?;
744        }
745        if paragraph.contextual_spacing {
746            writer.write_event(Event::Empty(BytesStart::new("w:contextualSpacing")))?;
747        }
748        if let Some(alignment) = paragraph.alignment {
749            let mut jc = BytesStart::new("w:jc");
750            jc.push_attribute(("w:val", alignment_value(alignment)));
751            writer.write_event(Event::Empty(jc))?;
752        }
753        writer.write_event(Event::End(BytesEnd::new("w:pPr")))?;
754    }
755
756    // `w:commentRangeStart`/`w:commentRangeEnd` (`CT_MarkupRange`) and the matching
757    // `w:commentReference` are, like `w:hyperlink`, siblings of `w:r` rather than run content — but
758    // unlike a hyperlink, they aren't wrapped one-per-run: real WordprocessingML pairs a single
759    // start/end per comment id around whichever run(s) it covers (see `Run.comment_ids`'s doc
760    // comment). This tracks which comment ids are currently "open" as the loop walks the
761    // paragraph's runs in order, closing/opening them exactly at the run boundaries where the set
762    // changes.
763    let mut active_comment_ids: Vec<u32> = Vec::new();
764
765    // `w:bookmarkStart`/`w:bookmarkEnd` (`CT_Bookmark`/`CT_MarkupRange`) track "open" bookmarks the
766    // exact same way as `active_comment_ids` above, opening/closing at run boundaries — the two
767    // loops run in parallel but independently, since a run can be inside a bookmark, a comment,
768    // both, or neither, with no relationship between the two id spaces. See `Run.bookmarks`'s doc
769    // comment.
770    let mut active_bookmarks: Vec<Bookmark> = Vec::new();
771
772    for run in &paragraph.runs {
773        let to_close: Vec<u32> = active_comment_ids
774            .iter()
775            .copied()
776            .filter(|id| !run.comment_ids.contains(id))
777            .collect();
778        for id in &to_close {
779            write_comment_range_end_and_reference(writer, *id)?;
780        }
781        active_comment_ids.retain(|id| !to_close.contains(id));
782
783        let to_open: Vec<u32> = run
784            .comment_ids
785            .iter()
786            .copied()
787            .filter(|id| !active_comment_ids.contains(id))
788            .collect();
789        for id in &to_open {
790            let id_string = id.to_string();
791            let mut start = BytesStart::new("w:commentRangeStart");
792            start.push_attribute(("w:id", id_string.as_str()));
793            writer.write_event(Event::Empty(start))?;
794        }
795        active_comment_ids.extend(to_open);
796
797        let bookmarks_to_close: Vec<u32> = active_bookmarks
798            .iter()
799            .map(|bookmark| bookmark.id)
800            .filter(|id| !run.bookmarks.iter().any(|bookmark| bookmark.id == *id))
801            .collect();
802        for id in &bookmarks_to_close {
803            write_bookmark_end(writer, *id)?;
804        }
805        active_bookmarks.retain(|bookmark| !bookmarks_to_close.contains(&bookmark.id));
806
807        let bookmarks_to_open: Vec<Bookmark> = run
808            .bookmarks
809            .iter()
810            .filter(|bookmark| {
811                !active_bookmarks
812                    .iter()
813                    .any(|active| active.id == bookmark.id)
814            })
815            .cloned()
816            .collect();
817        for bookmark in &bookmarks_to_open {
818            write_bookmark_start(writer, bookmark)?;
819        }
820        active_bookmarks.extend(bookmarks_to_open);
821
822        match &run.hyperlink {
823            // `w:hyperlink` (`CT_Hyperlink`) is, like `w:fldSimple`, a sibling of `w:r` in
824            // `EG_PContent` that wraps its own nested run(s) — not a run property in the XML, even
825            // though it is modeled as one on `Run` for API simplicity (see `Hyperlink`'s doc
826            // comment). So a hyperlinked run gets an extra wrapper around whatever `write_run`
827            // would otherwise write on its own.
828            Some(Hyperlink::External(url)) => {
829                let relationship_id =
830                    relationships.add_external(HYPERLINK_RELATIONSHIP_TYPE, url.as_str());
831                let mut hyperlink = BytesStart::new("w:hyperlink");
832                hyperlink.push_attribute(("r:id", relationship_id.as_str()));
833                writer.write_event(Event::Start(hyperlink))?;
834                write_run(writer, run, state, relationships)?;
835                writer.write_event(Event::End(BytesEnd::new("w:hyperlink")))?;
836            }
837            Some(Hyperlink::Internal(anchor)) => {
838                let mut hyperlink = BytesStart::new("w:hyperlink");
839                hyperlink.push_attribute(("w:anchor", anchor.as_str()));
840                writer.write_event(Event::Start(hyperlink))?;
841                write_run(writer, run, state, relationships)?;
842                writer.write_event(Event::End(BytesEnd::new("w:hyperlink")))?;
843            }
844            None => write_run(writer, run, state, relationships)?,
845        }
846    }
847
848    // Any comment ids still open at the end of the paragraph close here — covers both the common
849    // case (a range ending with the paragraph's last run) and the degenerate one (a comment range
850    // never closed because the caller kept adding the same id past the last run that should carry
851    // it — still produces a well-formed, if perhaps unintended, range).
852    for id in active_comment_ids {
853        write_comment_range_end_and_reference(writer, id)?;
854    }
855
856    // Any bookmarks still open at the end of the paragraph close here, same reasoning as
857    // `active_comment_ids` above.
858    for bookmark in active_bookmarks {
859        write_bookmark_end(writer, bookmark.id)?;
860    }
861
862    writer.write_event(Event::End(BytesEnd::new("w:p")))?;
863    Ok(())
864}
865
866/// Writes a bookmark's opening marker (`<w:bookmarkStart w:id=".." w:name="..">`, `CT_Bookmark`).
867fn write_bookmark_start<W: Write>(writer: &mut Writer<W>, bookmark: &Bookmark) -> Result<()> {
868    let id_string = bookmark.id.to_string();
869    let mut start = BytesStart::new("w:bookmarkStart");
870    start.push_attribute(("w:id", id_string.as_str()));
871    start.push_attribute(("w:name", bookmark.name.as_str()));
872    writer.write_event(Event::Empty(start))?;
873    Ok(())
874}
875
876/// Writes a bookmark's closing marker (`<w:bookmarkEnd w:id="..">`, `CT_MarkupRange`) — unlike
877/// `write_comment_range_end_and_reference`, no trailing reference run: a bookmark has no "balloon
878/// anchor" of its own to render.
879fn write_bookmark_end<W: Write>(writer: &mut Writer<W>, id: u32) -> Result<()> {
880    let id_string = id.to_string();
881    let mut end = BytesStart::new("w:bookmarkEnd");
882    end.push_attribute(("w:id", id_string.as_str()));
883    writer.write_event(Event::Empty(end))?;
884    Ok(())
885}
886
887/// Writes a comment range's closing marker (`<w:commentRangeEnd>`) followed by its
888/// `w:commentReference` run (the balloon anchor mark, styled "CommentReference" the same way
889/// `write_run`'s `NoteReference` styles itself "FootnoteReference"/"EndnoteReference") — always
890/// emitted as a pair, immediately after each other, matching the shape ECMA-376's own example for
891/// `commentRangeEnd` shows.
892fn write_comment_range_end_and_reference<W: Write>(writer: &mut Writer<W>, id: u32) -> Result<()> {
893    let id_string = id.to_string();
894
895    let mut end = BytesStart::new("w:commentRangeEnd");
896    end.push_attribute(("w:id", id_string.as_str()));
897    writer.write_event(Event::Empty(end))?;
898
899    writer.write_event(Event::Start(BytesStart::new("w:r")))?;
900    writer.write_event(Event::Start(BytesStart::new("w:rPr")))?;
901    let mut r_style = BytesStart::new("w:rStyle");
902    r_style.push_attribute(("w:val", COMMENT_REFERENCE_STYLE_ID));
903    writer.write_event(Event::Empty(r_style))?;
904    writer.write_event(Event::End(BytesEnd::new("w:rPr")))?;
905    let mut reference = BytesStart::new("w:commentReference");
906    reference.push_attribute(("w:id", id_string.as_str()));
907    writer.write_event(Event::Empty(reference))?;
908    writer.write_event(Event::End(BytesEnd::new("w:r")))?;
909
910    Ok(())
911}
912
913/// Writes a single run's content — text with `w:rPr` formatting, an inline image (`<w:drawing>`), a
914/// field (`<w:fldSimple>`), or a footnote/endnote reference/marker — with no knowledge of whether
915/// it's wrapped in a `<w:hyperlink>` (that wrapping, if any, is the caller's responsibility,
916/// [`write_paragraph`]'s).
917fn write_run<W: Write>(
918    writer: &mut Writer<W>,
919    run: &Run,
920    state: &mut PackageState,
921    relationships: &mut PartRelationships,
922) -> Result<()> {
923    match &run.content {
924        // `w:fldSimple` (`CT_SimpleField`) is a sibling of `w:r` in `EG_PContent`, not a run itself
925        // — it wraps its own inner run — so, unlike the other content kinds, it is not itself
926        // written inside a `<w:r>` wrapper here.
927        RunContent::Field(field) => write_field(writer, run, *field)?,
928        RunContent::Image(image) => {
929            writer.write_event(Event::Start(BytesStart::new("w:r")))?;
930            write_drawing(writer, image, state, relationships)?;
931            writer.write_event(Event::End(BytesEnd::new("w:r")))?;
932        }
933        RunContent::Chart(chart) => {
934            writer.write_event(Event::Start(BytesStart::new("w:r")))?;
935            write_embedded_chart(writer, chart, state, relationships)?;
936            writer.write_event(Event::End(BytesEnd::new("w:r")))?;
937        }
938        RunContent::Text(text) => {
939            writer.write_event(Event::Start(BytesStart::new("w:r")))?;
940            write_run_properties(writer, run)?;
941
942            let mut text_element = BytesStart::new("w:t");
943            if starts_or_ends_with_whitespace(text) {
944                text_element.push_attribute(("xml:space", "preserve"));
945            }
946            writer.write_event(Event::Start(text_element))?;
947            writer.write_event(Event::Text(BytesText::new(text)))?;
948            writer.write_event(Event::End(BytesEnd::new("w:t")))?;
949            writer.write_event(Event::End(BytesEnd::new("w:r")))?;
950        }
951        // `footnoteReference`/`endnoteReference` (`CT_FtnEdnRef`) and `footnoteRef`/`endnoteRef`
952        // (`CT_Empty`) are, unlike `w:fldSimple` and `w:hyperlink`, genuine members of
953        // `EG_RunInnerContent` — plain children of `<w:r>`, just like `w:t`. No special wrapping
954        // needed beyond the usual `w:rPr` (which carries the "FootnoteReference"/"EndnoteReference"
955        // `w:rStyle` these constructors set — see `Run::with_note_reference`/
956        // `Run::with_note_marker`).
957        RunContent::NoteReference(reference) => {
958            writer.write_event(Event::Start(BytesStart::new("w:r")))?;
959            write_run_properties(writer, run)?;
960            let (element_name, id) = match reference {
961                NoteReference::Footnote(id) => ("w:footnoteReference", *id),
962                NoteReference::Endnote(id) => ("w:endnoteReference", *id),
963            };
964            let id = id.to_string();
965            let mut element = BytesStart::new(element_name);
966            element.push_attribute(("w:id", id.as_str()));
967            writer.write_event(Event::Empty(element))?;
968            writer.write_event(Event::End(BytesEnd::new("w:r")))?;
969        }
970        RunContent::NoteMarker(kind) => {
971            writer.write_event(Event::Start(BytesStart::new("w:r")))?;
972            write_run_properties(writer, run)?;
973            let element_name = match kind {
974                NoteKind::Footnote => "w:footnoteRef",
975                NoteKind::Endnote => "w:endnoteRef",
976            };
977            writer.write_event(Event::Empty(BytesStart::new(element_name)))?;
978            writer.write_event(Event::End(BytesEnd::new("w:r")))?;
979        }
980        // `w:br` (`CT_Br`) and `w:cr` (`CT_Empty`) are, like `footnoteReference`/`footnoteRef`
981        // above, genuine members of `EG_RunInnerContent` — plain children of `<w:r>` needing no
982        // special wrapping. `w:type` is only written for `page`/ `column` breaks — `textWrapping`
983        // is `ST_BrType`'s default when the attribute is omitted entirely, so omitting it there
984        // keeps the common case's output minimal (matches this crate's existing "no attribute for
985        // the schema default" convention, e.g. `Highlight::None`).
986        RunContent::Break(kind) => {
987            writer.write_event(Event::Start(BytesStart::new("w:r")))?;
988            write_run_properties(writer, run)?;
989            let mut element = BytesStart::new("w:br");
990            if *kind != BreakKind::TextWrapping {
991                element.push_attribute(("w:type", kind.attribute_value()));
992            }
993            writer.write_event(Event::Empty(element))?;
994            writer.write_event(Event::End(BytesEnd::new("w:r")))?;
995        }
996        RunContent::CarriageReturn => {
997            writer.write_event(Event::Start(BytesStart::new("w:r")))?;
998            write_run_properties(writer, run)?;
999            writer.write_event(Event::Empty(BytesStart::new("w:cr")))?;
1000            writer.write_event(Event::End(BytesEnd::new("w:r")))?;
1001        }
1002    }
1003    Ok(())
1004}
1005
1006/// Writes a run's `w:rPr` (`w:rStyle`, then font family/color/size, then bold/italic/underline), if
1007/// a character style or any of those is set. `w:rStyle`, when present, is written first, matching
1008/// real Word output (`EG_RPrBase` is a repeatable `choice` group, so this isn't schema-mandated,
1009/// just conventional). None of this is meaningful for an image run, which has no `w:rPr` of its own
1010/// in our model — only text and field/note runs call this.
1011fn write_run_properties<W: Write>(writer: &mut Writer<W>, run: &Run) -> Result<()> {
1012    if run.style_id.is_none()
1013        && run.font_family.is_none()
1014        && run.color.is_none()
1015        && run.font_size.is_none()
1016        && run.highlight.is_none()
1017        && run.vertical_align.is_none()
1018        && run.underline.is_none()
1019        && run.shading_color.is_none()
1020        && run.character_spacing.is_none()
1021        && run.vertical_position.is_none()
1022        && !(run.bold
1023            || run.italic
1024            || run.strike
1025            || run.small_caps
1026            || run.all_caps
1027            || run.text_border
1028            || run.hidden)
1029    {
1030        return Ok(());
1031    }
1032
1033    writer.write_event(Event::Start(BytesStart::new("w:rPr")))?;
1034    if let Some(style_id) = &run.style_id {
1035        let mut r_style = BytesStart::new("w:rStyle");
1036        r_style.push_attribute(("w:val", style_id.as_str()));
1037        writer.write_event(Event::Empty(r_style))?;
1038    }
1039    write_character_formatting(
1040        writer,
1041        run.font_family.as_deref(),
1042        run.color.as_deref(),
1043        run.font_size,
1044        run.highlight,
1045        run.bold,
1046        run.italic,
1047        run.underline,
1048        run.underline_color.as_deref(),
1049        run.strike,
1050        run.vertical_align,
1051        run.small_caps,
1052        run.all_caps,
1053        run.shading_color.as_deref(),
1054        run.text_border,
1055        run.character_spacing,
1056        run.vertical_position,
1057        run.hidden,
1058    )?;
1059    writer.write_event(Event::End(BytesEnd::new("w:rPr")))?;
1060    Ok(())
1061}
1062
1063/// Writes the `w:rFonts`/`w:color`/`w:sz`+`w:szCs`/`w:highlight`/`w:b`/`w:i`/`w:u`/
1064/// `w:strike`/`w:vertAlign` children of a `w:rPr`, shared by run formatting
1065/// ([`write_run_properties`]) and named-style definitions (`to_styles_xml`), which set the same
1066/// character formatting on a style's default run formatting. `font_family` is written to both
1067/// `w:rFonts`'s `w:ascii` and `w:hAnsi` attributes (Latin-only, see `Run.font_family`'s doc
1068/// comment); `size_points` is doubled into half-points and written to both `w:sz` and `w:szCs` (see
1069/// `Run.font_size`'s doc comment). `highlight` is written as `<w:highlight w:val="..">`
1070/// (`CT_Highlight`/`ST_HighlightColor`, see `Highlight`'s doc comment) — a fixed 16-color palette,
1071/// not an arbitrary hex value like `w:color`, hence the enum rather than a `String`.
1072/// `vertical_align` is written as `<w:vertAlign w:val="..">`
1073/// (`CT_VerticalAlignRun`/`ST_VerticalAlignRun`, see [`VerticalAlign`]'s doc comment). `underline`
1074/// is written as `<w:u w:val="..">` (`CT_Underline`/`ST_Underline`, see [`UnderlineStyle`]'s doc
1075/// comment), with `underline_color` as that same element's `color` attribute when set.
1076/// `shading_color` is written as `<w:shd w:val="clear" w:color="auto" w:fill="..">` (`CT_Shd`, see
1077/// `Run.shading_color`'s doc comment for why `val`/`color` are fixed). `text_border` is written as
1078/// a fixed `<w:bdr w:val="single" w:sz="4" w:space="0" w:color="auto"/>` when `true` (see
1079/// `Run.text_border`'s doc comment for why this is on/off rather than exposing `CT_Border`'s full
1080/// richness). `character_spacing` is written as `<w:spacing w:val="..">` (`CT_SignedTwipsMeasure`,
1081/// plain twips, no unit conversion needed). `vertical_position` is written as `<w:position
1082/// w:val="..">` (`CT_SignedHpsMeasure`, plain half-points).
1083#[allow(clippy::too_many_arguments)]
1084fn write_character_formatting<W: Write>(
1085    writer: &mut Writer<W>,
1086    font_family: Option<&str>,
1087    color: Option<&str>,
1088    size_points: Option<u16>,
1089    highlight: Option<Highlight>,
1090    bold: bool,
1091    italic: bool,
1092    underline: Option<UnderlineStyle>,
1093    underline_color: Option<&str>,
1094    strike: bool,
1095    vertical_align: Option<VerticalAlign>,
1096    small_caps: bool,
1097    all_caps: bool,
1098    shading_color: Option<&str>,
1099    text_border: bool,
1100    character_spacing: Option<i16>,
1101    vertical_position: Option<i16>,
1102    hidden: bool,
1103) -> Result<()> {
1104    if let Some(font_family) = font_family {
1105        let mut r_fonts = BytesStart::new("w:rFonts");
1106        r_fonts.push_attribute(("w:ascii", font_family));
1107        r_fonts.push_attribute(("w:hAnsi", font_family));
1108        writer.write_event(Event::Empty(r_fonts))?;
1109    }
1110    if let Some(color) = color {
1111        let mut color_element = BytesStart::new("w:color");
1112        color_element.push_attribute(("w:val", color));
1113        writer.write_event(Event::Empty(color_element))?;
1114    }
1115    if let Some(size_points) = size_points {
1116        let half_points = (u32::from(size_points) * 2).to_string();
1117        let mut sz = BytesStart::new("w:sz");
1118        sz.push_attribute(("w:val", half_points.as_str()));
1119        writer.write_event(Event::Empty(sz))?;
1120        let mut sz_cs = BytesStart::new("w:szCs");
1121        sz_cs.push_attribute(("w:val", half_points.as_str()));
1122        writer.write_event(Event::Empty(sz_cs))?;
1123    }
1124    if let Some(highlight) = highlight {
1125        let mut highlight_element = BytesStart::new("w:highlight");
1126        highlight_element.push_attribute(("w:val", highlight.attribute_value()));
1127        writer.write_event(Event::Empty(highlight_element))?;
1128    }
1129    if bold {
1130        writer.write_event(Event::Empty(BytesStart::new("w:b")))?;
1131    }
1132    if italic {
1133        writer.write_event(Event::Empty(BytesStart::new("w:i")))?;
1134    }
1135    if let Some(underline) = underline {
1136        let mut underline_element = BytesStart::new("w:u");
1137        underline_element.push_attribute(("w:val", underline.attribute_value()));
1138        if let Some(underline_color) = underline_color {
1139            underline_element.push_attribute(("w:color", underline_color));
1140        }
1141        writer.write_event(Event::Empty(underline_element))?;
1142    }
1143    if strike {
1144        writer.write_event(Event::Empty(BytesStart::new("w:strike")))?;
1145    }
1146    if let Some(vertical_align) = vertical_align {
1147        let mut vert_align_element = BytesStart::new("w:vertAlign");
1148        vert_align_element.push_attribute(("w:val", vertical_align.attribute_value()));
1149        writer.write_event(Event::Empty(vert_align_element))?;
1150    }
1151    if small_caps {
1152        writer.write_event(Event::Empty(BytesStart::new("w:smallCaps")))?;
1153    }
1154    if all_caps {
1155        writer.write_event(Event::Empty(BytesStart::new("w:caps")))?;
1156    }
1157    if let Some(shading_color) = shading_color {
1158        write_shading(writer, "w:shd", shading_color)?;
1159    }
1160    if text_border {
1161        let mut bdr = BytesStart::new("w:bdr");
1162        bdr.push_attribute(("w:val", "single"));
1163        bdr.push_attribute(("w:sz", "4"));
1164        bdr.push_attribute(("w:space", "0"));
1165        bdr.push_attribute(("w:color", "auto"));
1166        writer.write_event(Event::Empty(bdr))?;
1167    }
1168    if let Some(character_spacing) = character_spacing {
1169        let mut spacing = BytesStart::new("w:spacing");
1170        spacing.push_attribute(("w:val", character_spacing.to_string().as_str()));
1171        writer.write_event(Event::Empty(spacing))?;
1172    }
1173    if let Some(vertical_position) = vertical_position {
1174        let mut position = BytesStart::new("w:position");
1175        position.push_attribute(("w:val", vertical_position.to_string().as_str()));
1176        writer.write_event(Event::Empty(position))?;
1177    }
1178    if hidden {
1179        writer.write_event(Event::Empty(BytesStart::new("w:vanish")))?;
1180    }
1181    Ok(())
1182}
1183
1184/// Writes a `<w:shd w:val="clear" w:color="auto" w:fill="..">` element under the given tag name
1185/// (`CT_Shd`) — shared by the run-level `w:shd` (inside `w:rPr`, `Run.shading_color`) and the
1186/// paragraph-level `w:shd` (inside `w:pPr`, `Paragraph.shading_color`), which use the exact same
1187/// element shape and the same fixed `val`/`color` pair, just nested under a different parent. See
1188/// either field's doc comment for why `val`/`color` are always fixed rather than exposed.
1189fn write_shading<W: Write>(writer: &mut Writer<W>, tag_name: &str, fill: &str) -> Result<()> {
1190    let mut shd = BytesStart::new(tag_name);
1191    shd.push_attribute(("w:val", "clear"));
1192    shd.push_attribute(("w:color", "auto"));
1193    shd.push_attribute(("w:fill", fill));
1194    writer.write_event(Event::Empty(shd))?;
1195    Ok(())
1196}
1197
1198/// Writes a single fixed-appearance border side element (`<w:top w:val="single" w:sz="4"
1199/// w:space="0" w:color="auto"/>`, or the `left`/`bottom`/`right` equivalent under a different tag
1200/// name) — the same fixed attributes as the run-level `w:bdr` above, reused for each of
1201/// `write_paragraph_border`'s four sides.
1202fn write_border_side<W: Write>(writer: &mut Writer<W>, tag_name: &str) -> Result<()> {
1203    let mut side = BytesStart::new(tag_name);
1204    side.push_attribute(("w:val", "single"));
1205    side.push_attribute(("w:sz", "4"));
1206    side.push_attribute(("w:space", "0"));
1207    side.push_attribute(("w:color", "auto"));
1208    writer.write_event(Event::Empty(side))?;
1209    Ok(())
1210}
1211
1212/// Writes `<w:pBdr>` with a fixed single-line border on all four sides (`CT_PBdr`'s own sequence:
1213/// `top`, `left`, `bottom`, `right`, then `between`/`bar`, which aren't written — see
1214/// `Paragraph.border`'s doc comment), mirroring `Run.text_border`'s fixed appearance for a
1215/// paragraph rather than a run.
1216fn write_paragraph_border<W: Write>(writer: &mut Writer<W>) -> Result<()> {
1217    writer.write_event(Event::Start(BytesStart::new("w:pBdr")))?;
1218    write_border_side(writer, "w:top")?;
1219    write_border_side(writer, "w:left")?;
1220    write_border_side(writer, "w:bottom")?;
1221    write_border_side(writer, "w:right")?;
1222    writer.write_event(Event::End(BytesEnd::new("w:pBdr")))?;
1223    Ok(())
1224}
1225
1226/// Writes `<w:tabs>` with one `<w:tab>` per [`TabStop`] (`CT_Tabs`/ `CT_TabStop`), in the order
1227/// they were added — Word treats a paragraph's custom tab stops as an ordered list, not a set
1228/// sorted by position, so no reordering happens here even if the caller added them out of position
1229/// order.
1230fn write_tabs<W: Write>(writer: &mut Writer<W>, tabs: &[TabStop]) -> Result<()> {
1231    writer.write_event(Event::Start(BytesStart::new("w:tabs")))?;
1232    for tab in tabs {
1233        write_tab_stop(writer, tab)?;
1234    }
1235    writer.write_event(Event::End(BytesEnd::new("w:tabs")))?;
1236    Ok(())
1237}
1238
1239/// Writes a single `<w:tab>` (`CT_TabStop`): `w:val` (`ST_TabJc`, required), `w:pos`
1240/// (`ST_SignedTwipsMeasure`, required, signed — a tab stop can sit left of the margin under a
1241/// negative indent), and `w:leader` (`ST_TabTlc`, optional).
1242fn write_tab_stop<W: Write>(writer: &mut Writer<W>, tab: &TabStop) -> Result<()> {
1243    let mut element = BytesStart::new("w:tab");
1244    let position = tab.position_twips.to_string();
1245    element.push_attribute(("w:val", tab.alignment.attribute_value()));
1246    if let Some(leader) = tab.leader {
1247        element.push_attribute(("w:leader", leader.attribute_value()));
1248    }
1249    element.push_attribute(("w:pos", position.as_str()));
1250    writer.write_event(Event::Empty(element))?;
1251    Ok(())
1252}
1253
1254/// Writes `<w:spacing>` with whichever of `line`/`before`/`after` are set (`CT_Spacing`,
1255/// paragraph-level, `w:pPr`-only). This happens to share its tag name with the run-level
1256/// character-spacing element inside `w:rPr` (`write_character_formatting`'s `character_spacing`
1257/// parameter), but it's a different type (`CT_Spacing` vs. `CT_SignedTwipsMeasure`) with a
1258/// different attribute set — the two never appear as siblings, so the shared name isn't ambiguous
1259/// in context. `line` is always paired with `lineRule="auto"` — see `Paragraph.line_spacing`'s doc
1260/// comment for the twips-based `atLeast`/`exact` modes this doesn't cover.
1261fn write_paragraph_spacing<W: Write>(
1262    writer: &mut Writer<W>,
1263    line: Option<u32>,
1264    before: Option<u32>,
1265    after: Option<u32>,
1266) -> Result<()> {
1267    let mut spacing = BytesStart::new("w:spacing");
1268    let before_string = before.map(|value| value.to_string());
1269    let after_string = after.map(|value| value.to_string());
1270    let line_string = line.map(|value| value.to_string());
1271    if let Some(before_string) = &before_string {
1272        spacing.push_attribute(("w:before", before_string.as_str()));
1273    }
1274    if let Some(after_string) = &after_string {
1275        spacing.push_attribute(("w:after", after_string.as_str()));
1276    }
1277    if let Some(line_string) = &line_string {
1278        spacing.push_attribute(("w:line", line_string.as_str()));
1279        spacing.push_attribute(("w:lineRule", "auto"));
1280    }
1281    writer.write_event(Event::Empty(spacing))?;
1282    Ok(())
1283}
1284
1285/// Writes a run whose content is a [`Field`] as `<w:fldSimple w:instr="..">`, WordprocessingML's
1286/// "simple field" construct (`CT_SimpleField`, ECMA-376 Part 1 `wml.xsd`) — a sibling of `w:r` in
1287/// `EG_PContent`, wrapping its own inner run with the field's formatting and a cached display
1288/// value: an inner run nested inside the `w:fldSimple` element carrying the field's own formatting.
1289///
1290/// The cached value ("1") doesn't need forcing to update on open: `PAGE` and `NUMPAGES` are
1291/// pagination-derived fields Word's layout engine keeps live automatically (unlike, say, a `REF` or
1292/// `TOC` field, whose cached value is genuinely stale until an explicit recalculation). Setting
1293/// `w:updateFields` in `word/settings.xml` to force recalculation regardless is deliberately
1294/// avoided: in real-world Word/Trust-Center configurations, `updateFields` makes Word treat every
1295/// field's update as a "link" needing the user's confirmation, triggering a "this document contains
1296/// links to other files" warning on open even though nothing here links to another file. Since
1297/// `PAGE`/`NUMPAGES` don't need it in the first place, simply not setting `updateFields` avoids the
1298/// prompt entirely with no loss of correctness.
1299fn write_field<W: Write>(writer: &mut Writer<W>, run: &Run, field: Field) -> Result<()> {
1300    let mut fld_simple = BytesStart::new("w:fldSimple");
1301    fld_simple.push_attribute(("w:instr", field.instruction()));
1302    writer.write_event(Event::Start(fld_simple))?;
1303
1304    writer.write_event(Event::Start(BytesStart::new("w:r")))?;
1305    write_run_properties(writer, run)?;
1306    writer.write_event(Event::Start(BytesStart::new("w:t")))?;
1307    writer.write_event(Event::Text(BytesText::new("1")))?;
1308    writer.write_event(Event::End(BytesEnd::new("w:t")))?;
1309    writer.write_event(Event::End(BytesEnd::new("w:r")))?;
1310
1311    writer.write_event(Event::End(BytesEnd::new("w:fldSimple")))?;
1312    Ok(())
1313}
1314
1315/// Collects the media parts (images) encountered anywhere in the document (body, header, footer) so
1316/// `write_to` can add them to the package once every part has been walked. Media part file names
1317/// (`media/imageN.ext`) must be unique across the whole package, so this is shared across the
1318/// document body and any header/footer parts — unlike [`PartRelationships`], which is scoped to a
1319/// single part.
1320struct ImageRegistry {
1321    next_media_index: usize,
1322    media_parts: Vec<MediaPart>,
1323}
1324
1325struct MediaPart {
1326    /// The media part's name, relative to `word/` (e.g. `media/image1.png`).
1327    entry_name: String,
1328    content_type: &'static str,
1329    data: Vec<u8>,
1330}
1331
1332impl ImageRegistry {
1333    fn new() -> Self {
1334        Self {
1335            next_media_index: 1,
1336            media_parts: Vec::new(),
1337        }
1338    }
1339
1340    /// Registers an image's bytes as a new, package-wide-unique media part and returns its entry
1341    /// name (relative to `word/`). Does not itself assign a relationship id — the caller adds a
1342    /// relationship to this entry name in whichever part's [`PartRelationships`] is currently being
1343    /// written (that part's own `.rels` file).
1344    fn register(&mut self, image: &Image) -> String {
1345        let index = self.next_media_index;
1346        self.next_media_index += 1;
1347        let entry_name = format!("media/image{index}.{}", image.format.extension());
1348        self.media_parts.push(MediaPart {
1349            entry_name: entry_name.clone(),
1350            content_type: image.format.content_type(),
1351            data: image.data.clone(),
1352        });
1353        entry_name
1354    }
1355}
1356
1357/// package-wide-unique `word/charts/chartN.xml` parts, same posture as [`ImageRegistry`] (a
1358/// header's own chart still needs a name that doesn't collide with the document body's).
1359struct ChartRegistry {
1360    next_chart_index: usize,
1361    chart_parts: Vec<ChartPart>,
1362}
1363
1364struct ChartPart {
1365    /// The chart part's name, relative to `word/` (e.g. `charts/chart1.xml`).
1366    entry_name: String,
1367    data: Vec<u8>,
1368}
1369
1370impl ChartRegistry {
1371    fn new() -> Self {
1372        Self {
1373            next_chart_index: 1,
1374            chart_parts: Vec::new(),
1375        }
1376    }
1377
1378    /// Serializes a chart's whole content (via `chart::write_chart_space`) as a new,
1379    /// package-wide-unique chart part and returns its entry name (relative to `word/`). Same "no
1380    /// relationship id assigned here" posture as [`ImageRegistry::register`].
1381    fn register(&mut self, chart_space: &chart::ChartSpace) -> Result<String> {
1382        let index = self.next_chart_index;
1383        self.next_chart_index += 1;
1384        let entry_name = format!("charts/chart{index}.xml");
1385        let mut chart_writer = Writer::new(Vec::new());
1386        chart::write_chart_space(&mut chart_writer, chart_space)?;
1387        self.chart_parts.push(ChartPart {
1388            entry_name: entry_name.clone(),
1389            data: chart_writer.into_inner(),
1390        });
1391        Ok(entry_name)
1392    }
1393}
1394
1395/// Package-wide state threaded through the whole write (body, header, footer): images encountered
1396/// anywhere ([`ImageRegistry`]), charts encountered anywhere ([`ChartRegistry`],).
1397///
1398/// Unlike [`PartRelationships`] (scoped to whichever single part is currently being written), this
1399/// is the same instance for the whole package — a header's own image still needs a
1400/// package-wide-unique media part name, shared with the document body's.
1401///
1402/// (This used to also track whether any field had been written, to force `w:updateFields` in
1403/// `word/settings.xml` — removed, see `write_field`'s doc comment for why. Kept as a dedicated
1404/// struct, rather than inlining `ImageRegistry` directly, since it's a natural place to add other
1405/// package-wide bookkeeping a future feature might need.)
1406struct PackageState {
1407    images: ImageRegistry,
1408    charts: ChartRegistry,
1409    /// A single, package-wide counter behind every inline drawing's `wp:docPr/@id` (pictures and
1410    /// charts alike). Before charts existed, this was simply derived from
1411    /// `images.media_parts.len()` (accurate then, since pictures were the only kind of inline
1412    /// drawing), but that would let a picture and a chart compute the same numeric id once both
1413    /// exist; a single shared counter, incremented by whichever kind is written next, keeps every
1414    /// `wp:docPr/@id` in a document part unique regardless of kind.
1415    next_drawing_id: usize,
1416}
1417
1418impl PackageState {
1419    fn new() -> Self {
1420        Self {
1421            images: ImageRegistry::new(),
1422            charts: ChartRegistry::new(),
1423            next_drawing_id: 1,
1424        }
1425    }
1426
1427    /// Returns the next document-unique `wp:docPr/@id` and advances the counter.
1428    fn next_drawing_id(&mut self) -> usize {
1429        let id = self.next_drawing_id;
1430        self.next_drawing_id += 1;
1431        id
1432    }
1433}
1434
1435/// The relationships being accumulated for whichever part is currently being written
1436/// (`document.xml`, or a header/footer part). Each part has its own independently-scoped `rIdN`
1437/// numbering (an `r:id` is only guaranteed unique within the `.rels` part that declares it), so a
1438/// fresh allocator is used per part rather than a single package-wide counter.
1439struct PartRelationships {
1440    next_id: usize,
1441    relationships: Relationships,
1442}
1443
1444impl PartRelationships {
1445    fn new() -> Self {
1446        Self {
1447            next_id: 1,
1448            relationships: Relationships::new(),
1449        }
1450    }
1451
1452    /// Adds an internal relationship (target resolves to another part of this same package),
1453    /// assigning it the next available `rIdN` in this part's own numbering, and returns that id.
1454    fn add(&mut self, rel_type: &str, target: impl Into<String>) -> String {
1455        self.add_with_mode(rel_type, target, TargetMode::Internal)
1456    }
1457
1458    /// Adds an external relationship (target is an arbitrary URL outside the package, e.g. a
1459    /// hyperlink) and returns its id, same numbering as [`PartRelationships::add`].
1460    fn add_external(&mut self, rel_type: &str, target: impl Into<String>) -> String {
1461        self.add_with_mode(rel_type, target, TargetMode::External)
1462    }
1463
1464    fn add_with_mode(
1465        &mut self,
1466        rel_type: &str,
1467        target: impl Into<String>,
1468        target_mode: TargetMode,
1469    ) -> String {
1470        let id = format!("rId{}", self.next_id);
1471        self.next_id += 1;
1472        self.relationships.add(Relationship {
1473            id: id.clone(),
1474            rel_type: rel_type.to_string(),
1475            target: target.into(),
1476            target_mode,
1477        });
1478        id
1479    }
1480}
1481
1482/// Writes a single `<w:drawing>` inline image.
1483///
1484/// Structure: a `wp:inline` with `wp:extent`/`wp:docPr`, wrapping an
1485/// `a:graphic`/`a:graphicData`/`pic:pic` with a `pic:blipFill` (the `a:blip`'s `r:embed` pointing
1486/// at the image's relationship) and a `pic:spPr` (position/size, rectangular, no cropping or
1487/// effects).
1488fn write_drawing<W: Write>(
1489    writer: &mut Writer<W>,
1490    image: &Image,
1491    state: &mut PackageState,
1492    relationships: &mut PartRelationships,
1493) -> Result<()> {
1494    let entry_name = state.images.register(image);
1495    let relationship_id = relationships.add(IMAGE_RELATIONSHIP_TYPE, entry_name);
1496    // A document-unique, non-zero id for this drawing (`wp:docPr`'s `id`), distinct from
1497    // `pic:cNvPr`'s id below (fixed at `0` here, per ECMA-376 20.2.2.3 — the two ids serve
1498    // different scopes).
1499    let drawing_id = state.next_drawing_id().to_string();
1500    let width = image.width_emu.to_string();
1501    let height = image.height_emu.to_string();
1502
1503    writer.write_event(Event::Start(BytesStart::new("w:drawing")))?;
1504
1505    let mut inline = BytesStart::new("wp:inline");
1506    inline.push_attribute(("distT", "0"));
1507    inline.push_attribute(("distB", "0"));
1508    inline.push_attribute(("distL", "0"));
1509    inline.push_attribute(("distR", "0"));
1510    writer.write_event(Event::Start(inline))?;
1511
1512    let mut extent = BytesStart::new("wp:extent");
1513    extent.push_attribute(("cx", width.as_str()));
1514    extent.push_attribute(("cy", height.as_str()));
1515    writer.write_event(Event::Empty(extent))?;
1516
1517    let mut doc_pr = BytesStart::new("wp:docPr");
1518    doc_pr.push_attribute(("id", drawing_id.as_str()));
1519    doc_pr.push_attribute(("name", relationship_id.as_str()));
1520    doc_pr.push_attribute(("descr", image.description.as_str()));
1521    writer.write_event(Event::Empty(doc_pr))?;
1522
1523    writer.write_event(Event::Start(BytesStart::new("a:graphic")))?;
1524    let mut graphic_data = BytesStart::new("a:graphicData");
1525    graphic_data.push_attribute(("uri", DRAWINGML_PICTURE_NAMESPACE));
1526    writer.write_event(Event::Start(graphic_data))?;
1527
1528    writer.write_event(Event::Start(BytesStart::new("pic:pic")))?;
1529
1530    writer.write_event(Event::Start(BytesStart::new("pic:nvPicPr")))?;
1531    let mut c_nv_pr = BytesStart::new("pic:cNvPr");
1532    c_nv_pr.push_attribute(("id", "0"));
1533    c_nv_pr.push_attribute(("name", relationship_id.as_str()));
1534    c_nv_pr.push_attribute(("descr", image.description.as_str()));
1535    writer.write_event(Event::Empty(c_nv_pr))?;
1536    writer.write_event(Event::Start(BytesStart::new("pic:cNvPicPr")))?;
1537    let mut pic_locks = BytesStart::new("a:picLocks");
1538    pic_locks.push_attribute(("noChangeAspect", "1"));
1539    writer.write_event(Event::Empty(pic_locks))?;
1540    writer.write_event(Event::End(BytesEnd::new("pic:cNvPicPr")))?;
1541    writer.write_event(Event::End(BytesEnd::new("pic:nvPicPr")))?;
1542
1543    writer.write_event(Event::Start(BytesStart::new("pic:blipFill")))?;
1544    let mut blip = BytesStart::new("a:blip");
1545    blip.push_attribute(("r:embed", relationship_id.as_str()));
1546    writer.write_event(Event::Empty(blip))?;
1547    writer.write_event(Event::Start(BytesStart::new("a:stretch")))?;
1548    writer.write_event(Event::Empty(BytesStart::new("a:fillRect")))?;
1549    writer.write_event(Event::End(BytesEnd::new("a:stretch")))?;
1550    writer.write_event(Event::End(BytesEnd::new("pic:blipFill")))?;
1551
1552    writer.write_event(Event::Start(BytesStart::new("pic:spPr")))?;
1553    writer.write_event(Event::Start(BytesStart::new("a:xfrm")))?;
1554    let mut off = BytesStart::new("a:off");
1555    off.push_attribute(("x", "0"));
1556    off.push_attribute(("y", "0"));
1557    writer.write_event(Event::Empty(off))?;
1558    let mut ext = BytesStart::new("a:ext");
1559    ext.push_attribute(("cx", width.as_str()));
1560    ext.push_attribute(("cy", height.as_str()));
1561    writer.write_event(Event::Empty(ext))?;
1562    writer.write_event(Event::End(BytesEnd::new("a:xfrm")))?;
1563
1564    // / consolidation: `image.shape_properties`'s own geometry/fill/line/effects, if any, replace
1565    // the fixed default rectangle below — `image.shape_properties.transform` is deliberately never
1566    // consulted here (the `a:xfrm` above, derived from `width_emu`/`height_emu`, is always
1567    // authoritative for a Word picture's size, unlike Excel's cell-anchored pictures). `None`
1568    // reproduces the exact fixed rectangle geometry/no-fill/no-line/no-effects this function always
1569    // wrote. Delegates to `drawing`'s shared helper (rather than a hand-rolled copy of the same
1570    // fallback) so a field like `effects` — silently dropped here before this fix, even though it
1571    // round-tripped correctly on read — can't go missing again the next time `ShapeProperties`
1572    // grows a field.
1573    let empty_properties = drawing::ShapeProperties::default();
1574    let shape_properties = image.shape_properties.as_ref().unwrap_or(&empty_properties);
1575    drawing::write_geometry_fill_line_effects(writer, shape_properties, "rect")?;
1576
1577    writer.write_event(Event::End(BytesEnd::new("pic:spPr")))?;
1578
1579    writer.write_event(Event::End(BytesEnd::new("pic:pic")))?;
1580    writer.write_event(Event::End(BytesEnd::new("a:graphicData")))?;
1581    writer.write_event(Event::End(BytesEnd::new("a:graphic")))?;
1582    writer.write_event(Event::End(BytesEnd::new("wp:inline")))?;
1583    writer.write_event(Event::End(BytesEnd::new("w:drawing")))?;
1584
1585    Ok(())
1586}
1587
1588/// Writes a single run's embedded chart (`<w:drawing><wp:inline>..`). Same
1589/// `wp:inline`/`wp:extent`/`wp:docPr` skeleton as `write_drawing`, but `a:graphicData` (`uri` = the
1590/// chart namespace, not the picture one) wraps a bare `<c:chart r:id="..">` directly — no
1591/// `pic:pic`/`pic:spPr` wrapper, since a chart has no picture-style shape properties here, and no
1592/// `xdr:graphicFrame`-equivalent wrapper either (unlike Excel's spreadsheet-drawing schema,
1593/// WordprocessingDrawing's `a:graphicData` holds the chart reference directly). References a
1594/// dedicated `word/charts/chartN.xml` part holding this crate's own `chart::ChartSpace`, written
1595/// whole via `chart::write_chart_space` — cached values only, no embedded workbook (see
1596/// [`EmbeddedChart`]'s doc comment).
1597fn write_embedded_chart<W: Write>(
1598    writer: &mut Writer<W>,
1599    chart: &EmbeddedChart,
1600    state: &mut PackageState,
1601    relationships: &mut PartRelationships,
1602) -> Result<()> {
1603    let entry_name = state.charts.register(&chart.chart_space)?;
1604    let relationship_id = relationships.add(CHART_RELATIONSHIP_TYPE, entry_name);
1605    let drawing_id = state.next_drawing_id().to_string();
1606    let width = chart.width_emu.to_string();
1607    let height = chart.height_emu.to_string();
1608
1609    writer.write_event(Event::Start(BytesStart::new("w:drawing")))?;
1610
1611    let mut inline = BytesStart::new("wp:inline");
1612    inline.push_attribute(("distT", "0"));
1613    inline.push_attribute(("distB", "0"));
1614    inline.push_attribute(("distL", "0"));
1615    inline.push_attribute(("distR", "0"));
1616    writer.write_event(Event::Start(inline))?;
1617
1618    let mut extent = BytesStart::new("wp:extent");
1619    extent.push_attribute(("cx", width.as_str()));
1620    extent.push_attribute(("cy", height.as_str()));
1621    writer.write_event(Event::Empty(extent))?;
1622
1623    let mut doc_pr = BytesStart::new("wp:docPr");
1624    doc_pr.push_attribute(("id", drawing_id.as_str()));
1625    doc_pr.push_attribute(("name", chart.name.as_str()));
1626    // `parse_drawing`'s shared `docPr` handling only reads back `descr` (matching `write_drawing`'s
1627    // picture-side convention), so `descr` must be written here too, not just `name`, or
1628    // `EmbeddedChart.name` would never survive a write-then-read round trip.
1629    doc_pr.push_attribute(("descr", chart.name.as_str()));
1630    writer.write_event(Event::Empty(doc_pr))?;
1631
1632    writer.write_event(Event::Start(BytesStart::new("a:graphic")))?;
1633    let mut graphic_data = BytesStart::new("a:graphicData");
1634    graphic_data.push_attribute(("uri", DRAWINGML_CHART_NAMESPACE));
1635    writer.write_event(Event::Start(graphic_data))?;
1636
1637    // `xmlns:c` is declared locally here (rather than alongside `xmlns:w`/
1638    // `xmlns:wp`/`xmlns:a`/`xmlns:pic` on the enclosing part's own root element) since only parts
1639    // that actually embed a chart need it — keeps this feature fully additive to
1640    // `write_document_xml`/ `write_header_xml`/`write_footer_xml`'s existing root-element code.
1641    let mut c_chart = BytesStart::new("c:chart");
1642    c_chart.push_attribute(("xmlns:c", DRAWINGML_CHART_NAMESPACE));
1643    c_chart.push_attribute(("r:id", relationship_id.as_str()));
1644    writer.write_event(Event::Empty(c_chart))?;
1645
1646    writer.write_event(Event::End(BytesEnd::new("a:graphicData")))?;
1647    writer.write_event(Event::End(BytesEnd::new("a:graphic")))?;
1648    writer.write_event(Event::End(BytesEnd::new("wp:inline")))?;
1649    writer.write_event(Event::End(BytesEnd::new("w:drawing")))?;
1650
1651    Ok(())
1652}
1653
1654/// The fixed width, in twips (1/20 pt), given to a table column with no corresponding entry in
1655/// `Table.column_widths` — either because `column_widths` is empty entirely (every column gets this
1656/// width, the crate's original behavior before per-column widths were supported), or because the
1657/// table has more real columns than `column_widths` has entries for.
1658const DEFAULT_COLUMN_WIDTH_TWIPS: &str = "2000";
1659
1660/// Writes a single `<w:tbl>` table.
1661///
1662/// Structure and minimum elements cross-checked against ECMA-376 Part 1 (`CT_Tbl`: `tblPr` then
1663/// `tblGrid` then rows, in that strict order): a minimal table needs a `tblPr` with a width and
1664/// borders, and every cell containing at least one paragraph. `tblGrid` is always written here with
1665/// one `gridCol` per column, since every real Word-authored `.docx` has one — omitting it is
1666/// exactly what caused the repeated "unreadable content" bugs earlier in this project.
1667fn write_table<W: Write>(
1668    writer: &mut Writer<W>,
1669    table: &Table,
1670    state: &mut PackageState,
1671    relationships: &mut PartRelationships,
1672) -> Result<()> {
1673    writer.write_event(Event::Start(BytesStart::new("w:tbl")))?;
1674
1675    // `tblGrid`: one `gridCol` per column. The column count is the widest row's total SPANNED
1676    // width, not just its number of `TableCell` entries — a row using `horizontal_span` to merge
1677    // cells legitimately has fewer `TableCell`s than the table's real column count (see
1678    // `TableCell.horizontal_span`'s doc comment), so simply counting `row.cells.len()` would
1679    // under-count `tblGrid` whenever every row happens to merge at least one cell. Getting this
1680    // number wrong produces a `.docx` Word refuses to open cleanly — the same class of
1681    // strict-schema bug this project has been bitten by before (see `CHANGELOG.md`'s
1682    // `[Content_Types].xml` `Default` entry fix). Computed before `tblPr` (even though `tblPr` is
1683    // written first, per `CT_Tbl`'s sequence) since `tblPr`'s own `w:tblW`/`w:tblLayout` need to
1684    // know whether `column_widths` actually covers every column.
1685    let column_count = table
1686        .rows
1687        .iter()
1688        .map(|row| {
1689            row.cells
1690                .iter()
1691                .map(|cell| cell.horizontal_span.unwrap_or(1).max(1) as usize)
1692                .sum::<usize>()
1693        })
1694        .max()
1695        .unwrap_or(0);
1696
1697    // `tblPr`: single borders on all sides, so the table is actually visible by default. Width:
1698    // `auto` (Word sizes the table itself) unless `column_widths` is set, in which case the table's
1699    // total preferred width becomes the exact sum of its columns (`dxa`, i.e. twips) — see
1700    // `Table.column_widths`'s doc comment for why `w:tblLayout w:type="fixed"` is written alongside
1701    // it: without it, Word's default `autofit` layout treats `tblGrid`'s widths as mere starting
1702    // suggestions it may override based on cell content.
1703    let has_column_widths = !table.column_widths.is_empty();
1704    writer.write_event(Event::Start(BytesStart::new("w:tblPr")))?;
1705    // `CT_TblPrBase`'s sequence: `tblStyle` is the very first child, before even `tblW` — confirmed
1706    // via `schemas.liquid-technologies.com`'s `tblpr2.html` (`CT_TblPr` derivation).
1707    if let Some(style_id) = &table.style_id {
1708        let mut tbl_style = BytesStart::new("w:tblStyle");
1709        tbl_style.push_attribute(("w:val", style_id.as_str()));
1710        writer.write_event(Event::Empty(tbl_style))?;
1711    }
1712    let mut table_width = BytesStart::new("w:tblW");
1713    if has_column_widths {
1714        let total_width: u32 = table.column_widths.iter().sum();
1715        table_width.push_attribute(("w:w", total_width.to_string().as_str()));
1716        table_width.push_attribute(("w:type", "dxa"));
1717    } else {
1718        table_width.push_attribute(("w:w", "0"));
1719        table_width.push_attribute(("w:type", "auto"));
1720    }
1721    writer.write_event(Event::Empty(table_width))?;
1722    // `CT_TblPrBase`'s sequence: `jc`, then (much later, skipping `tblCellSpacing` which isn't
1723    // modeled) `tblInd`, then `tblBorders`.
1724    if let Some(alignment) = table.alignment {
1725        let mut jc = BytesStart::new("w:jc");
1726        jc.push_attribute(("w:val", alignment_value(alignment)));
1727        writer.write_event(Event::Empty(jc))?;
1728    }
1729    if let Some(indent_twips) = table.indent_twips {
1730        let mut tbl_ind = BytesStart::new("w:tblInd");
1731        tbl_ind.push_attribute(("w:w", indent_twips.to_string().as_str()));
1732        tbl_ind.push_attribute(("w:type", "dxa"));
1733        writer.write_event(Event::Empty(tbl_ind))?;
1734    }
1735    let border_color = table.border_color.as_deref().unwrap_or("auto");
1736    writer.write_event(Event::Start(BytesStart::new("w:tblBorders")))?;
1737    for side_element_name in [
1738        "w:top",
1739        "w:left",
1740        "w:bottom",
1741        "w:right",
1742        "w:insideH",
1743        "w:insideV",
1744    ] {
1745        let mut border = BytesStart::new(side_element_name);
1746        border.push_attribute(("w:val", "single"));
1747        border.push_attribute(("w:sz", "4"));
1748        border.push_attribute(("w:space", "0"));
1749        border.push_attribute(("w:color", border_color));
1750        writer.write_event(Event::Empty(border))?;
1751    }
1752    writer.write_event(Event::End(BytesEnd::new("w:tblBorders")))?;
1753    if has_column_widths {
1754        // `CT_TblPrBase`'s sequence: `tblW`.., `tblBorders`, `shd` (not written here), `tblLayout`.
1755        // — so after `tblBorders`.
1756        let mut tbl_layout = BytesStart::new("w:tblLayout");
1757        tbl_layout.push_attribute(("w:type", "fixed"));
1758        writer.write_event(Event::Empty(tbl_layout))?;
1759    }
1760    writer.write_event(Event::End(BytesEnd::new("w:tblPr")))?;
1761
1762    writer.write_event(Event::Start(BytesStart::new("w:tblGrid")))?;
1763    for column_index in 0..column_count {
1764        let width = table
1765            .column_widths
1766            .get(column_index)
1767            .map(u32::to_string)
1768            .unwrap_or_else(|| DEFAULT_COLUMN_WIDTH_TWIPS.to_string());
1769        let mut grid_col = BytesStart::new("w:gridCol");
1770        grid_col.push_attribute(("w:w", width.as_str()));
1771        writer.write_event(Event::Empty(grid_col))?;
1772    }
1773    writer.write_event(Event::End(BytesEnd::new("w:tblGrid")))?;
1774
1775    for row in &table.rows {
1776        writer.write_event(Event::Start(BytesStart::new("w:tr")))?;
1777        write_table_row_properties(writer, row)?;
1778        for cell in &row.cells {
1779            writer.write_event(Event::Start(BytesStart::new("w:tc")))?;
1780            write_table_cell_properties(writer, cell)?;
1781            for block in &cell.blocks {
1782                write_block(writer, block, state, relationships)?;
1783            }
1784            // `CT_Tc`'s block-level content is `minOccurs="1"`: a cell must contain at least one
1785            // paragraph, even if empty. Beyond that, real Word additionally expects the cell's
1786            // content to actually END with a paragraph — including right after a nested table — or
1787            // it shows a repair prompt on open; so a trailing empty paragraph is appended whenever
1788            // `blocks` is empty or its last entry isn't already a paragraph (e.g. it ends in a
1789            // nested `Block::Table`).
1790            let needs_trailing_paragraph = !matches!(cell.blocks.last(), Some(Block::Paragraph(_)));
1791            if needs_trailing_paragraph {
1792                writer.write_event(Event::Empty(BytesStart::new("w:p")))?;
1793            }
1794            writer.write_event(Event::End(BytesEnd::new("w:tc")))?;
1795        }
1796        writer.write_event(Event::End(BytesEnd::new("w:tr")))?;
1797    }
1798
1799    writer.write_event(Event::End(BytesEnd::new("w:tbl")))?;
1800    Ok(())
1801}
1802
1803/// Writes a single `<w:tr>` row's `<w:trPr>` (`CT_TrPr`), when it has any properties to carry —
1804/// `cantSplit`, `trHeight`, `tblHeader`, in that order (`CT_TrPr`'s own sequence, confirmed via
1805/// `schemas.liquid-technologies.com`'s `ct_trpr.html`: `cantSplit` then `trHeight` then
1806/// `tblHeader`, skipping the unmodeled `cnfStyle`/`divId`/
1807/// `gridBefore`/`gridAfter`/`wBefore`/`wAfter` that precede them and the unmodeled
1808/// `tblCellSpacing`/`jc`/`hidden`/`ins`/`del`/`trPrChange` that follow). `w:trPr` is the first
1809/// child of `w:tr` when present, same properties-element-first convention as
1810/// `w:pPr`/`w:tblPr`/`w:tcPr`.
1811fn write_table_row_properties<W: Write>(writer: &mut Writer<W>, row: &TableRow) -> Result<()> {
1812    if !row.cant_split && row.height_twips.is_none() && !row.repeat_as_header_row {
1813        return Ok(());
1814    }
1815    writer.write_event(Event::Start(BytesStart::new("w:trPr")))?;
1816    if row.cant_split {
1817        writer.write_event(Event::Empty(BytesStart::new("w:cantSplit")))?;
1818    }
1819    if let Some(height_twips) = row.height_twips {
1820        let mut tr_height = BytesStart::new("w:trHeight");
1821        tr_height.push_attribute(("w:val", height_twips.to_string().as_str()));
1822        tr_height.push_attribute(("w:hRule", "atLeast"));
1823        writer.write_event(Event::Empty(tr_height))?;
1824    }
1825    if row.repeat_as_header_row {
1826        writer.write_event(Event::Empty(BytesStart::new("w:tblHeader")))?;
1827    }
1828    writer.write_event(Event::End(BytesEnd::new("w:trPr")))?;
1829    Ok(())
1830}
1831
1832/// Writes a single `<w:tc>` cell's `<w:tcPr>` (`CT_TcPr`), when it has any properties to carry —
1833/// `gridSpan`, `vMerge`, `tcBorders`, `shd`, `tcMar`, `textDirection`, `vAlign`, in that order
1834/// (`CT_TcPr`'s own sequence, confirmed via `schemas.liquid-technologies.com`'s `tcpr2.html`:
1835/// `cnfStyle`(not modeled), `tcW`(not modeled), `gridSpan`, `hMerge`(not modeled), `vMerge`,
1836/// `tcBorders`, `shd`, `noWrap`(not modeled), `tcMar`, `textDirection`, `tcFitText`(not modeled),
1837/// `vAlign`, `hideMark`(not modeled)..). A fixed-appearance `<w:vMerge w:val="..">` always writes
1838/// an explicit `val` even for `Continue` (which schema-wise could be expressed just as validly by
1839/// omitting `w:vMerge`/`val` entirely) — writing it explicitly is clearer round-tripped output and
1840/// avoids relying on a reader correctly implementing `CT_VMerge`'s omitted-means-`continue`
1841/// default. `w:tcPr` is the first child of `w:tc` when present (`CT_Tc`'s own sequence: `tcPr`,
1842/// then block-level content) — omitted entirely for a cell with none of these properties set, same
1843/// convention as `write_paragraph`'s `w:pPr`.
1844fn write_table_cell_properties<W: Write>(writer: &mut Writer<W>, cell: &TableCell) -> Result<()> {
1845    if cell.horizontal_span.is_none()
1846        && cell.vertical_merge.is_none()
1847        && !cell.border
1848        && cell.shading_color.is_none()
1849        && cell.margin_top_twips.is_none()
1850        && cell.margin_left_twips.is_none()
1851        && cell.margin_bottom_twips.is_none()
1852        && cell.margin_right_twips.is_none()
1853        && cell.text_direction.is_none()
1854        && cell.vertical_alignment.is_none()
1855    {
1856        return Ok(());
1857    }
1858    writer.write_event(Event::Start(BytesStart::new("w:tcPr")))?;
1859    if let Some(horizontal_span) = cell.horizontal_span {
1860        let mut grid_span = BytesStart::new("w:gridSpan");
1861        grid_span.push_attribute(("w:val", horizontal_span.to_string().as_str()));
1862        writer.write_event(Event::Empty(grid_span))?;
1863    }
1864    if let Some(vertical_merge) = cell.vertical_merge {
1865        let mut v_merge = BytesStart::new("w:vMerge");
1866        v_merge.push_attribute(("w:val", vertical_merge.attribute_value()));
1867        writer.write_event(Event::Empty(v_merge))?;
1868    }
1869    if cell.border {
1870        writer.write_event(Event::Start(BytesStart::new("w:tcBorders")))?;
1871        write_border_side(writer, "w:top")?;
1872        write_border_side(writer, "w:left")?;
1873        write_border_side(writer, "w:bottom")?;
1874        write_border_side(writer, "w:right")?;
1875        writer.write_event(Event::End(BytesEnd::new("w:tcBorders")))?;
1876    }
1877    if let Some(shading_color) = &cell.shading_color {
1878        write_shading(writer, "w:shd", shading_color)?;
1879    }
1880    if cell.margin_top_twips.is_some()
1881        || cell.margin_left_twips.is_some()
1882        || cell.margin_bottom_twips.is_some()
1883        || cell.margin_right_twips.is_some()
1884    {
1885        // `CT_TcMar`'s own sequence: `top`, `left`, `bottom`, `right`.
1886        writer.write_event(Event::Start(BytesStart::new("w:tcMar")))?;
1887        write_cell_margin_side(writer, "w:top", cell.margin_top_twips)?;
1888        write_cell_margin_side(writer, "w:left", cell.margin_left_twips)?;
1889        write_cell_margin_side(writer, "w:bottom", cell.margin_bottom_twips)?;
1890        write_cell_margin_side(writer, "w:right", cell.margin_right_twips)?;
1891        writer.write_event(Event::End(BytesEnd::new("w:tcMar")))?;
1892    }
1893    if let Some(text_direction) = cell.text_direction {
1894        let mut element = BytesStart::new("w:textDirection");
1895        element.push_attribute(("w:val", text_direction.attribute_value()));
1896        writer.write_event(Event::Empty(element))?;
1897    }
1898    if let Some(vertical_alignment) = cell.vertical_alignment {
1899        let mut v_align = BytesStart::new("w:vAlign");
1900        v_align.push_attribute(("w:val", vertical_alignment.attribute_value()));
1901        writer.write_event(Event::Empty(v_align))?;
1902    }
1903    writer.write_event(Event::End(BytesEnd::new("w:tcPr")))?;
1904    Ok(())
1905}
1906
1907/// Writes one `<w:tcMar>` side element (`w:top`/`w:left`/`w:bottom`/ `w:right`, `CT_TblWidth`,
1908/// always `w:type="dxa"`), if `value` is set — shared by `write_table_cell_properties`'s four
1909/// calls, one per side, mirroring `write_border_side`'s per-side-call shape for `w:tcBorders`/
1910/// `w:pBdr`.
1911fn write_cell_margin_side<W: Write>(
1912    writer: &mut Writer<W>,
1913    tag_name: &str,
1914    value: Option<u32>,
1915) -> Result<()> {
1916    if let Some(value) = value {
1917        let mut element = BytesStart::new(tag_name);
1918        element.push_attribute(("w:w", value.to_string().as_str()));
1919        element.push_attribute(("w:type", "dxa"));
1920        writer.write_event(Event::Empty(element))?;
1921    }
1922    Ok(())
1923}
1924
1925/// Writes a single `<w:sdt>` structured document tag / content control (`CT_SdtBlock`): an optional
1926/// `<w:sdtPr>` with whichever of `id`/`tag`/`alias` are set (`CT_SdtPr`'s children are an unbounded
1927/// `choice`, not a fixed sequence, so any order is schema-valid — this writes `alias`, `tag`, `id`,
1928/// mirroring ECMA-376's own example order for `alias`), then `<w:sdtContent>` wrapping the
1929/// control's blocks (written via [`write_block`], so a nested `w:sdt` — schema-legal, see
1930/// [`StructuredDocumentTag`]'s doc comment — round-trips correctly). No `w:sdtEndPr` or
1931/// type-indicator element (`w:richText`/`w:text`/..) is ever written; see
1932/// [`StructuredDocumentTag`]'s doc comment for why.
1933fn write_structured_document_tag<W: Write>(
1934    writer: &mut Writer<W>,
1935    sdt: &StructuredDocumentTag,
1936    state: &mut PackageState,
1937    relationships: &mut PartRelationships,
1938) -> Result<()> {
1939    writer.write_event(Event::Start(BytesStart::new("w:sdt")))?;
1940
1941    if sdt.alias.is_some() || sdt.tag.is_some() || sdt.id.is_some() {
1942        writer.write_event(Event::Start(BytesStart::new("w:sdtPr")))?;
1943        if let Some(alias) = &sdt.alias {
1944            let mut element = BytesStart::new("w:alias");
1945            element.push_attribute(("w:val", alias.as_str()));
1946            writer.write_event(Event::Empty(element))?;
1947        }
1948        if let Some(tag) = &sdt.tag {
1949            let mut element = BytesStart::new("w:tag");
1950            element.push_attribute(("w:val", tag.as_str()));
1951            writer.write_event(Event::Empty(element))?;
1952        }
1953        if let Some(id) = sdt.id {
1954            let id_string = id.to_string();
1955            let mut element = BytesStart::new("w:id");
1956            element.push_attribute(("w:val", id_string.as_str()));
1957            writer.write_event(Event::Empty(element))?;
1958        }
1959        writer.write_event(Event::End(BytesEnd::new("w:sdtPr")))?;
1960    }
1961
1962    writer.write_event(Event::Start(BytesStart::new("w:sdtContent")))?;
1963    for block in &sdt.blocks {
1964        write_block(writer, block, state, relationships)?;
1965    }
1966    writer.write_event(Event::End(BytesEnd::new("w:sdtContent")))?;
1967
1968    writer.write_event(Event::End(BytesEnd::new("w:sdt")))?;
1969    Ok(())
1970}
1971
1972/// The fixed inter-column spacing (twips) written for `w:cols/@w:space` when `PageSetup.columns`
1973/// asks for more than one equal-width column. ECMA-376's `CT_Columns` also allows unequal,
1974/// individually-sized `w:col` children, but that goes beyond what this crate models (see
1975/// `PageSetup.columns`'s doc comment) — a fixed half-inch gutter between equal columns is the
1976/// common case and matches Word's own "Two"/"Three" quick-column presets.
1977const DEFAULT_COLUMN_SPACING_TWIPS: u32 = 720;
1978
1979/// Writes a `<w:sectPr>`: up to three `headerReference`/`footerReference` pairs
1980/// (default/first/even, whichever are set), then the section's page size, margins, optional
1981/// equal-width columns, and `w:titlePg`.
1982///
1983/// `EG_HdrFtrReferences` (`headerReference`/`footerReference`) must precede `EG_SectPrContents`
1984/// (`pgSz`/`pgMar`/`cols`/`titlePg`/..) in `CT_SectPr`'s strict sequence (ECMA-376 Part 1,
1985/// `wml.xsd`) — confirmed by reading the schema directly (not assumed), since getting element order
1986/// wrong is exactly what caused earlier "unreadable content" bugs in this project.
1987/// `EG_HdrFtrReferences` is itself an `xsd:choice` repeated 0-6 times, so the relative order of the
1988/// up-to-6 references is not schema-constrained; default/first/even is written here purely for
1989/// readability. Within `EG_SectPrContents`, the confirmed relative order of the fields this crate
1990/// models is `pgSz` → `pgMar` → `cols` → `titlePg`.
1991///
1992/// True multi-section documents (independent mid-document page setups via section breaks) are
1993/// deliberately out of scope — a rare, advanced case with no real demand seen yet. Only one
1994/// `w:sectPr` (this one, at the end of `w:body`) is ever written.
1995fn write_section_properties<W: Write>(
1996    writer: &mut Writer<W>,
1997    section_references: SectionReferenceIds<'_>,
1998    page_setup: &PageSetup,
1999) -> Result<()> {
2000    writer.write_event(Event::Start(BytesStart::new("w:sectPr")))?;
2001
2002    write_header_footer_reference(
2003        writer,
2004        "w:headerReference",
2005        "default",
2006        section_references.header,
2007    )?;
2008    write_header_footer_reference(
2009        writer,
2010        "w:headerReference",
2011        "first",
2012        section_references.header_first,
2013    )?;
2014    write_header_footer_reference(
2015        writer,
2016        "w:headerReference",
2017        "even",
2018        section_references.header_even,
2019    )?;
2020    write_header_footer_reference(
2021        writer,
2022        "w:footerReference",
2023        "default",
2024        section_references.footer,
2025    )?;
2026    write_header_footer_reference(
2027        writer,
2028        "w:footerReference",
2029        "first",
2030        section_references.footer_first,
2031    )?;
2032    write_header_footer_reference(
2033        writer,
2034        "w:footerReference",
2035        "even",
2036        section_references.footer_even,
2037    )?;
2038
2039    let mut page_size = BytesStart::new("w:pgSz");
2040    let width = page_setup.width_twips.to_string();
2041    let height = page_setup.height_twips.to_string();
2042    page_size.push_attribute(("w:w", width.as_str()));
2043    page_size.push_attribute(("w:h", height.as_str()));
2044    if page_setup.orientation == Orientation::Landscape {
2045        page_size.push_attribute(("w:orient", page_setup.orientation.attribute_value()));
2046    }
2047    writer.write_event(Event::Empty(page_size))?;
2048
2049    let mut page_margin = BytesStart::new("w:pgMar");
2050    let top = page_setup.margin_top_twips.to_string();
2051    let right = page_setup.margin_right_twips.to_string();
2052    let bottom = page_setup.margin_bottom_twips.to_string();
2053    let left = page_setup.margin_left_twips.to_string();
2054    let header = page_setup.margin_header_twips.to_string();
2055    let footer = page_setup.margin_footer_twips.to_string();
2056    let gutter = page_setup.margin_gutter_twips.to_string();
2057    page_margin.push_attribute(("w:top", top.as_str()));
2058    page_margin.push_attribute(("w:right", right.as_str()));
2059    page_margin.push_attribute(("w:bottom", bottom.as_str()));
2060    page_margin.push_attribute(("w:left", left.as_str()));
2061    page_margin.push_attribute(("w:header", header.as_str()));
2062    page_margin.push_attribute(("w:footer", footer.as_str()));
2063    page_margin.push_attribute(("w:gutter", gutter.as_str()));
2064    writer.write_event(Event::Empty(page_margin))?;
2065
2066    if let Some(columns) = page_setup.columns {
2067        let mut cols = BytesStart::new("w:cols");
2068        let num = columns.to_string();
2069        let space = DEFAULT_COLUMN_SPACING_TWIPS.to_string();
2070        cols.push_attribute(("w:num", num.as_str()));
2071        cols.push_attribute(("w:space", space.as_str()));
2072        cols.push_attribute(("w:equalWidth", "true"));
2073        writer.write_event(Event::Empty(cols))?;
2074    }
2075
2076    if section_references.title_pg {
2077        writer.write_event(Event::Empty(BytesStart::new("w:titlePg")))?;
2078    }
2079
2080    writer.write_event(Event::End(BytesEnd::new("w:sectPr")))?;
2081    Ok(())
2082}
2083
2084/// Writes one `<w:headerReference>`/`<w:footerReference>` (`element_name`), with `w:type` set to
2085/// `type_value` (`"default"`/`"first"`/`"even"`, `ST_HdrFtr`'s three enumeration values), if
2086/// `relationship_id` is set.
2087fn write_header_footer_reference<W: Write>(
2088    writer: &mut Writer<W>,
2089    element_name: &str,
2090    type_value: &str,
2091    relationship_id: Option<&str>,
2092) -> Result<()> {
2093    if let Some(id) = relationship_id {
2094        let mut reference = BytesStart::new(element_name);
2095        reference.push_attribute(("w:type", type_value));
2096        reference.push_attribute(("r:id", id));
2097        writer.write_event(Event::Empty(reference))?;
2098    }
2099    Ok(())
2100}
2101
2102fn starts_or_ends_with_whitespace(text: &str) -> bool {
2103    text.starts_with(char::is_whitespace) || text.ends_with(char::is_whitespace)
2104}
2105
2106/// Maps an [`Alignment`] to its `w:jc`/`w:val` value (WordprocessingML `ST_Jc`).
2107/// `left`/`center`/`right`/`both` are the values real Word output uses in practice
2108/// (`tests-data/word/minimal.docx`'s `word/styles.xml` has `w:jc w:val="center"`); `ST_Jc` also
2109/// allows the logical `start`/`end` pair (ISO/IEC 29500 Strict-style), not used here to match
2110/// real-world Word output.
2111fn alignment_value(alignment: Alignment) -> &'static str {
2112    match alignment {
2113        Alignment::Left => "left",
2114        Alignment::Center => "center",
2115        Alignment::Right => "right",
2116        Alignment::Justify => "both",
2117    }
2118}
2119
2120/// Serializes `word/styles.xml` (`CT_Styles`): one `<w:style>` per declared [`Style`]. Only ever
2121/// called with a non-empty `styles` slice (see `write_to`); an empty `<w:styles>` root is
2122/// schema-valid too (`CT_Styles`'s `style` is `minOccurs="0"`), but there would be no reason to
2123/// write the part at all in that case.
2124///
2125/// Each `<w:style>`'s children follow `CT_Style`'s strict sequence: `w:name`, then `w:basedOn` (if
2126/// set), then `w:pPr` (paragraph styles only, and only if alignment is set), then `w:rPr` (if any
2127/// of bold/italic/underline is set).
2128fn to_styles_xml(styles: &[Style]) -> Result<Vec<u8>> {
2129    let mut writer = Writer::new(Vec::new());
2130
2131    writer.write_event(Event::Decl(BytesDecl::new(
2132        "1.0",
2133        Some("UTF-8"),
2134        Some("yes"),
2135    )))?;
2136
2137    let mut root = BytesStart::new("w:styles");
2138    root.push_attribute(("xmlns:w", WORDPROCESSINGML_NAMESPACE));
2139    writer.write_event(Event::Start(root))?;
2140
2141    for style in styles {
2142        let mut style_element = BytesStart::new("w:style");
2143        style_element.push_attribute(("w:type", style.kind.attribute_value()));
2144        style_element.push_attribute(("w:styleId", style.id.as_str()));
2145        writer.write_event(Event::Start(style_element))?;
2146
2147        let mut name = BytesStart::new("w:name");
2148        name.push_attribute(("w:val", style.name.as_str()));
2149        writer.write_event(Event::Empty(name))?;
2150
2151        if let Some(based_on) = &style.based_on {
2152            let mut based_on_element = BytesStart::new("w:basedOn");
2153            based_on_element.push_attribute(("w:val", based_on.as_str()));
2154            writer.write_event(Event::Empty(based_on_element))?;
2155        }
2156
2157        // A character style has no paragraph-level formatting of its own
2158        // (`style.alignment`/`paragraph_shading_color`/`paragraph_border`/
2159        // `line_spacing`/`space_before`/`space_after`/`keep_with_next`/
2160        // `keep_lines_together`/`page_break_before`/`tabs`/ `contextual_spacing` are all documented
2161        // as ignored for that kind). Same `CT_PPrBase` sequence order as `write_paragraph`:
2162        // `keepNext`, `keepLines`, `pageBreakBefore`, then `pBdr`, then `shd`, then `tabs`, then
2163        // `spacing`, then `contextualSpacing`, then `jc`.
2164        if style.kind == StyleKind::Paragraph
2165            && (style.alignment.is_some()
2166                || style.paragraph_shading_color.is_some()
2167                || style.paragraph_border
2168                || style.line_spacing.is_some()
2169                || style.space_before.is_some()
2170                || style.space_after.is_some()
2171                || style.keep_with_next
2172                || style.keep_lines_together
2173                || style.page_break_before
2174                || !style.tabs.is_empty()
2175                || style.contextual_spacing)
2176        {
2177            writer.write_event(Event::Start(BytesStart::new("w:pPr")))?;
2178            if style.keep_with_next {
2179                writer.write_event(Event::Empty(BytesStart::new("w:keepNext")))?;
2180            }
2181            if style.keep_lines_together {
2182                writer.write_event(Event::Empty(BytesStart::new("w:keepLines")))?;
2183            }
2184            if style.page_break_before {
2185                writer.write_event(Event::Empty(BytesStart::new("w:pageBreakBefore")))?;
2186            }
2187            if style.paragraph_border {
2188                write_paragraph_border(&mut writer)?;
2189            }
2190            if let Some(paragraph_shading_color) = &style.paragraph_shading_color {
2191                write_shading(&mut writer, "w:shd", paragraph_shading_color)?;
2192            }
2193            if !style.tabs.is_empty() {
2194                write_tabs(&mut writer, &style.tabs)?;
2195            }
2196            if style.line_spacing.is_some()
2197                || style.space_before.is_some()
2198                || style.space_after.is_some()
2199            {
2200                write_paragraph_spacing(
2201                    &mut writer,
2202                    style.line_spacing,
2203                    style.space_before,
2204                    style.space_after,
2205                )?;
2206            }
2207            if style.contextual_spacing {
2208                writer.write_event(Event::Empty(BytesStart::new("w:contextualSpacing")))?;
2209            }
2210            if let Some(alignment) = style.alignment {
2211                let mut jc = BytesStart::new("w:jc");
2212                jc.push_attribute(("w:val", alignment_value(alignment)));
2213                writer.write_event(Event::Empty(jc))?;
2214            }
2215            writer.write_event(Event::End(BytesEnd::new("w:pPr")))?;
2216        }
2217
2218        if style.font_family.is_some()
2219            || style.color.is_some()
2220            || style.font_size.is_some()
2221            || style.highlight.is_some()
2222            || style.vertical_align.is_some()
2223            || style.underline.is_some()
2224            || style.shading_color.is_some()
2225            || style.character_spacing.is_some()
2226            || style.vertical_position.is_some()
2227            || style.bold
2228            || style.italic
2229            || style.strike
2230            || style.small_caps
2231            || style.all_caps
2232            || style.text_border
2233            || style.hidden
2234        {
2235            writer.write_event(Event::Start(BytesStart::new("w:rPr")))?;
2236            write_character_formatting(
2237                &mut writer,
2238                style.font_family.as_deref(),
2239                style.color.as_deref(),
2240                style.font_size,
2241                style.highlight,
2242                style.bold,
2243                style.italic,
2244                style.underline,
2245                style.underline_color.as_deref(),
2246                style.strike,
2247                style.vertical_align,
2248                style.small_caps,
2249                style.all_caps,
2250                style.shading_color.as_deref(),
2251                style.text_border,
2252                style.character_spacing,
2253                style.vertical_position,
2254                style.hidden,
2255            )?;
2256            writer.write_event(Event::End(BytesEnd::new("w:rPr")))?;
2257        }
2258
2259        writer.write_event(Event::End(BytesEnd::new("w:style")))?;
2260    }
2261
2262    writer.write_event(Event::End(BytesEnd::new("w:styles")))?;
2263
2264    Ok(writer.into_inner())
2265}
2266
2267/// Serializes `word/numbering.xml` (`CT_Numbering`): one `<w:abstractNum>` + one linked `<w:num>`
2268/// per declared [`NumberingDefinition`] (see its doc comment for why this crate always writes a
2269/// dedicated `w:abstractNum` per `w:num`, rather than letting several `w:num` entries share one).
2270/// `CT_Numbering`'s own sequence is `numPicBullet*, abstractNum*, num*` — no `numPicBullet` is
2271/// written (picture bullets aren't modeled), so every `<w:abstractNum>` is written first, then
2272/// every `<w:num>`, matching that order. Only ever called with a non-empty `definitions` slice (see
2273/// `write_to`).
2274fn to_numbering_xml(definitions: &[NumberingDefinition]) -> Result<Vec<u8>> {
2275    let mut writer = Writer::new(Vec::new());
2276
2277    writer.write_event(Event::Decl(BytesDecl::new(
2278        "1.0",
2279        Some("UTF-8"),
2280        Some("yes"),
2281    )))?;
2282
2283    let mut root = BytesStart::new("w:numbering");
2284    root.push_attribute(("xmlns:w", WORDPROCESSINGML_NAMESPACE));
2285    writer.write_event(Event::Start(root))?;
2286
2287    for definition in definitions {
2288        let abstract_num_id = definition.id.to_string();
2289        let mut abstract_num = BytesStart::new("w:abstractNum");
2290        abstract_num.push_attribute(("w:abstractNumId", abstract_num_id.as_str()));
2291        writer.write_event(Event::Start(abstract_num))?;
2292
2293        for (level_index, level) in definition.levels.iter().enumerate() {
2294            let ilvl = level_index.to_string();
2295            let mut lvl = BytesStart::new("w:lvl");
2296            lvl.push_attribute(("w:ilvl", ilvl.as_str()));
2297            writer.write_event(Event::Start(lvl))?;
2298
2299            // `CT_Lvl`'s sequence: `start`, `numFmt`.., `lvlText`.., `pPr`, `rPr`. `start` is
2300            // always `1` — this crate doesn't model restarting a list's counter partway through.
2301            let mut start = BytesStart::new("w:start");
2302            start.push_attribute(("w:val", "1"));
2303            writer.write_event(Event::Empty(start))?;
2304
2305            let mut num_fmt = BytesStart::new("w:numFmt");
2306            num_fmt.push_attribute(("w:val", level.format.attribute_value()));
2307            writer.write_event(Event::Empty(num_fmt))?;
2308
2309            let mut lvl_text = BytesStart::new("w:lvlText");
2310            lvl_text.push_attribute(("w:val", level.text.as_str()));
2311            writer.write_event(Event::Empty(lvl_text))?;
2312
2313            let mut lvl_jc = BytesStart::new("w:lvlJc");
2314            lvl_jc.push_attribute(("w:val", "left"));
2315            writer.write_event(Event::Empty(lvl_jc))?;
2316
2317            writer.write_event(Event::Start(BytesStart::new("w:pPr")))?;
2318            let mut ind = BytesStart::new("w:ind");
2319            ind.push_attribute(("w:left", level.indent_twips.to_string().as_str()));
2320            ind.push_attribute(("w:hanging", level.hanging_twips.to_string().as_str()));
2321            writer.write_event(Event::Empty(ind))?;
2322            writer.write_event(Event::End(BytesEnd::new("w:pPr")))?;
2323
2324            writer.write_event(Event::End(BytesEnd::new("w:lvl")))?;
2325        }
2326
2327        writer.write_event(Event::End(BytesEnd::new("w:abstractNum")))?;
2328    }
2329
2330    for definition in definitions {
2331        let num_id = definition.id.to_string();
2332        let mut num = BytesStart::new("w:num");
2333        num.push_attribute(("w:numId", num_id.as_str()));
2334        writer.write_event(Event::Start(num))?;
2335
2336        let mut abstract_num_id_ref = BytesStart::new("w:abstractNumId");
2337        abstract_num_id_ref.push_attribute(("w:val", num_id.as_str()));
2338        writer.write_event(Event::Empty(abstract_num_id_ref))?;
2339
2340        writer.write_event(Event::End(BytesEnd::new("w:num")))?;
2341    }
2342
2343    writer.write_event(Event::End(BytesEnd::new("w:numbering")))?;
2344
2345    Ok(writer.into_inner())
2346}
2347
2348/// Serializes `word/footnotes.xml`/`word/endnotes.xml` (`CT_Footnotes`/ `CT_Endnotes`): one
2349/// `<w:footnote>`/`<w:endnote>` (`CT_FtnEdn`) per declared [`Note`], reusing the same block-writing
2350/// helpers as the document body (`CT_FtnEdn` shares `EG_BlockLevelElts` with `w:body`, just like
2351/// [`HeaderFooter`]). `root_name` is `"w:footnotes"`/ `"w:endnotes"`, `note_element_name` is
2352/// `"w:footnote"`/`"w:endnote"`.
2353///
2354/// Only ever called with a non-empty `notes` slice (see `write_to`). Never writes the
2355/// `type="separator"`/`type="continuationSeparator"` boilerplate notes real Word documents also
2356/// carry — see [`Note`]'s doc comment for why that's fine.
2357fn to_notes_xml(
2358    root_name: &str,
2359    note_element_name: &str,
2360    notes: &[Note],
2361    state: &mut PackageState,
2362    relationships: &mut PartRelationships,
2363) -> Result<Vec<u8>> {
2364    let mut writer = Writer::new(Vec::new());
2365
2366    writer.write_event(Event::Decl(BytesDecl::new(
2367        "1.0",
2368        Some("UTF-8"),
2369        Some("yes"),
2370    )))?;
2371
2372    let mut root = BytesStart::new(root_name);
2373    root.push_attribute(("xmlns:w", WORDPROCESSINGML_NAMESPACE));
2374    root.push_attribute(("xmlns:r", RELATIONSHIPS_NAMESPACE));
2375    root.push_attribute(("xmlns:wp", WORDPROCESSING_DRAWING_NAMESPACE));
2376    root.push_attribute(("xmlns:a", DRAWINGML_NAMESPACE));
2377    root.push_attribute(("xmlns:pic", DRAWINGML_PICTURE_NAMESPACE));
2378    writer.write_event(Event::Start(root))?;
2379
2380    for note in notes {
2381        let id = note.id.to_string();
2382        let mut note_element = BytesStart::new(note_element_name);
2383        note_element.push_attribute(("w:id", id.as_str()));
2384        writer.write_event(Event::Start(note_element))?;
2385
2386        for block in &note.blocks {
2387            write_block(&mut writer, block, state, relationships)?;
2388        }
2389
2390        writer.write_event(Event::End(BytesEnd::new(note_element_name)))?;
2391    }
2392
2393    writer.write_event(Event::End(BytesEnd::new(root_name)))?;
2394
2395    Ok(writer.into_inner())
2396}
2397
2398/// Serializes `word/comments.xml` (`CT_Comments`), the collection of [`Comment`]s a document
2399/// declares. Structurally close to [`to_notes_xml`] (a flat list of block-level content wrapped in
2400/// an id'd element), but with `w:comment`'s extra `author`/`date`/`initials` attributes
2401/// (`CT_Comment` extending `CT_TrackChange`) and, unlike footnotes/endnotes, only ever this one
2402/// root/element name — no need for `to_notes_xml`'s `root_name`/`note_element_name` parameters.
2403fn to_comments_xml(
2404    comments: &[Comment],
2405    state: &mut PackageState,
2406    relationships: &mut PartRelationships,
2407) -> Result<Vec<u8>> {
2408    let mut writer = Writer::new(Vec::new());
2409
2410    writer.write_event(Event::Decl(BytesDecl::new(
2411        "1.0",
2412        Some("UTF-8"),
2413        Some("yes"),
2414    )))?;
2415
2416    let mut root = BytesStart::new("w:comments");
2417    root.push_attribute(("xmlns:w", WORDPROCESSINGML_NAMESPACE));
2418    root.push_attribute(("xmlns:r", RELATIONSHIPS_NAMESPACE));
2419    root.push_attribute(("xmlns:wp", WORDPROCESSING_DRAWING_NAMESPACE));
2420    root.push_attribute(("xmlns:a", DRAWINGML_NAMESPACE));
2421    root.push_attribute(("xmlns:pic", DRAWINGML_PICTURE_NAMESPACE));
2422    writer.write_event(Event::Start(root))?;
2423
2424    for comment in comments {
2425        let id = comment.id.to_string();
2426        let mut comment_element = BytesStart::new("w:comment");
2427        comment_element.push_attribute(("w:id", id.as_str()));
2428        if let Some(author) = &comment.author {
2429            comment_element.push_attribute(("w:author", author.as_str()));
2430        }
2431        if let Some(date) = &comment.date {
2432            comment_element.push_attribute(("w:date", date.as_str()));
2433        }
2434        if let Some(initials) = &comment.initials {
2435            comment_element.push_attribute(("w:initials", initials.as_str()));
2436        }
2437        writer.write_event(Event::Start(comment_element))?;
2438
2439        for block in &comment.blocks {
2440            write_block(&mut writer, block, state, relationships)?;
2441        }
2442
2443        writer.write_event(Event::End(BytesEnd::new("w:comment")))?;
2444    }
2445
2446    writer.write_event(Event::End(BytesEnd::new("w:comments")))?;
2447
2448    Ok(writer.into_inner())
2449}
2450
2451/// Serializes `word/theme/theme1.xml` (`<a:theme>`, `CT_OfficeStyleSheet`) from a [`Theme`]: the
2452/// color scheme and font scheme are built from the model, `fmtScheme` is always the fixed Office
2453/// default boilerplate (see [`Theme`]'s doc comment for why), and `objectDefaults`/
2454/// `extraClrSchemeLst` are omitted entirely (both `minOccurs="0"`).
2455fn to_theme_xml(theme: &Theme) -> Result<Vec<u8>> {
2456    let mut writer = Writer::new(Vec::new());
2457
2458    writer.write_event(Event::Decl(BytesDecl::new(
2459        "1.0",
2460        Some("UTF-8"),
2461        Some("yes"),
2462    )))?;
2463
2464    let mut root = BytesStart::new("a:theme");
2465    root.push_attribute(("xmlns:a", DRAWINGML_NAMESPACE));
2466    root.push_attribute(("name", theme.name.as_str()));
2467    writer.write_event(Event::Start(root))?;
2468
2469    writer.write_event(Event::Start(BytesStart::new("a:themeElements")))?;
2470    write_color_scheme(&mut writer, &theme.name, &theme.colors)?;
2471    write_font_scheme(&mut writer, &theme.name, &theme.fonts)?;
2472    writer.write_raw(FIXED_FORMAT_SCHEME_XML)?;
2473    writer.write_event(Event::End(BytesEnd::new("a:themeElements")))?;
2474
2475    writer.write_event(Event::End(BytesEnd::new("a:theme")))?;
2476
2477    Ok(writer.into_inner())
2478}
2479
2480/// Writes `<a:clrScheme>`'s 12 fixed-order color slots
2481/// (`dk1`/`lt1`/`dk2`/`lt2`/`accent1`-`accent6`/`hlink`/`folHlink`), each wrapping a plain
2482/// `<a:srgbClr val="..">` — see [`ColorScheme`]'s doc comment for why `<a:sysClr>` isn't written.
2483fn write_color_scheme<W: Write>(
2484    writer: &mut Writer<W>,
2485    name: &str,
2486    colors: &ColorScheme,
2487) -> Result<()> {
2488    let mut clr_scheme = BytesStart::new("a:clrScheme");
2489    clr_scheme.push_attribute(("name", name));
2490    writer.write_event(Event::Start(clr_scheme))?;
2491
2492    for (slot, value) in [
2493        ("dk1", &colors.dark1),
2494        ("lt1", &colors.light1),
2495        ("dk2", &colors.dark2),
2496        ("lt2", &colors.light2),
2497        ("accent1", &colors.accent1),
2498        ("accent2", &colors.accent2),
2499        ("accent3", &colors.accent3),
2500        ("accent4", &colors.accent4),
2501        ("accent5", &colors.accent5),
2502        ("accent6", &colors.accent6),
2503        ("hlink", &colors.hyperlink),
2504        ("folHlink", &colors.followed_hyperlink),
2505    ] {
2506        writer.write_event(Event::Start(BytesStart::new(format!("a:{slot}"))))?;
2507        let mut srgb_clr = BytesStart::new("a:srgbClr");
2508        srgb_clr.push_attribute(("val", value.as_str()));
2509        writer.write_event(Event::Empty(srgb_clr))?;
2510        writer.write_event(Event::End(BytesEnd::new(format!("a:{slot}"))))?;
2511    }
2512
2513    writer.write_event(Event::End(BytesEnd::new("a:clrScheme")))?;
2514    Ok(())
2515}
2516
2517/// Writes `<a:fontScheme>`'s `majorFont`/`minorFont`, each just a `<a:latin>` typeface plus
2518/// empty-typeface `<a:ea>`/`<a:cs>` placeholders — see [`FontScheme`]'s doc comment for why the
2519/// per-script fallback list isn't written.
2520fn write_font_scheme<W: Write>(
2521    writer: &mut Writer<W>,
2522    name: &str,
2523    fonts: &FontScheme,
2524) -> Result<()> {
2525    let mut font_scheme = BytesStart::new("a:fontScheme");
2526    font_scheme.push_attribute(("name", name));
2527    writer.write_event(Event::Start(font_scheme))?;
2528
2529    for (wrapper, typeface) in [
2530        ("a:majorFont", &fonts.major_latin),
2531        ("a:minorFont", &fonts.minor_latin),
2532    ] {
2533        writer.write_event(Event::Start(BytesStart::new(wrapper)))?;
2534
2535        let mut latin = BytesStart::new("a:latin");
2536        latin.push_attribute(("typeface", typeface.as_str()));
2537        writer.write_event(Event::Empty(latin))?;
2538
2539        let mut ea = BytesStart::new("a:ea");
2540        ea.push_attribute(("typeface", ""));
2541        writer.write_event(Event::Empty(ea))?;
2542
2543        let mut cs = BytesStart::new("a:cs");
2544        cs.push_attribute(("typeface", ""));
2545        writer.write_event(Event::Empty(cs))?;
2546
2547        writer.write_event(Event::End(BytesEnd::new(wrapper)))?;
2548    }
2549
2550    writer.write_event(Event::End(BytesEnd::new("a:fontScheme")))?;
2551    Ok(())
2552}
2553
2554/// `<a:fmtScheme>`'s fixed content, an exact copy of Office's own built-in default fill/line/effect
2555/// "style matrix" — see [`Theme`]'s doc comment for why this is always written verbatim rather than
2556/// built from the model. Spliced in as-is via [`Writer::write_raw`] rather than rebuilt one event
2557/// at a time, since it never varies.
2558const FIXED_FORMAT_SCHEME_XML: &str = concat!(
2559    "<a:fmtScheme name=\"Office\">",
2560    "<a:fillStyleLst>",
2561    "<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill>",
2562    "<a:gradFill rotWithShape=\"1\"><a:gsLst>",
2563    "<a:gs pos=\"0\"><a:schemeClr val=\"phClr\"><a:tint val=\"50000\"/><a:satMod val=\"300000\"/></a:schemeClr></a:gs>",
2564    "<a:gs pos=\"35000\"><a:schemeClr val=\"phClr\"><a:tint val=\"37000\"/><a:satMod val=\"300000\"/></a:schemeClr></a:gs>",
2565    "<a:gs pos=\"100000\"><a:schemeClr val=\"phClr\"><a:tint val=\"15000\"/><a:satMod val=\"350000\"/></a:schemeClr></a:gs>",
2566    "</a:gsLst><a:lin ang=\"16200000\" scaled=\"1\"/></a:gradFill>",
2567    "<a:gradFill rotWithShape=\"1\"><a:gsLst>",
2568    "<a:gs pos=\"0\"><a:schemeClr val=\"phClr\"><a:tint val=\"100000\"/><a:shade val=\"100000\"/><a:satMod val=\"130000\"/></a:schemeClr></a:gs>",
2569    "<a:gs pos=\"100000\"><a:schemeClr val=\"phClr\"><a:tint val=\"50000\"/><a:shade val=\"100000\"/><a:satMod val=\"350000\"/></a:schemeClr></a:gs>",
2570    "</a:gsLst><a:lin ang=\"16200000\" scaled=\"0\"/></a:gradFill>",
2571    "</a:fillStyleLst>",
2572    "<a:lnStyleLst>",
2573    "<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>",
2574    "<a:ln w=\"25400\" cap=\"flat\" cmpd=\"sng\" algn=\"ctr\"><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill><a:prstDash val=\"solid\"/></a:ln>",
2575    "<a:ln w=\"38100\" cap=\"flat\" cmpd=\"sng\" algn=\"ctr\"><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill><a:prstDash val=\"solid\"/></a:ln>",
2576    "</a:lnStyleLst>",
2577    "<a:effectStyleLst>",
2578    "<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>",
2579    "<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>",
2580    "<a:effectStyle>",
2581    "<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>",
2582    "<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>",
2583    "<a:sp3d><a:bevelT w=\"63500\" h=\"25400\"/></a:sp3d>",
2584    "</a:effectStyle>",
2585    "</a:effectStyleLst>",
2586    "<a:bgFillStyleLst>",
2587    "<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill>",
2588    "<a:gradFill rotWithShape=\"1\"><a:gsLst>",
2589    "<a:gs pos=\"0\"><a:schemeClr val=\"phClr\"><a:tint val=\"40000\"/><a:satMod val=\"350000\"/></a:schemeClr></a:gs>",
2590    "<a:gs pos=\"40000\"><a:schemeClr val=\"phClr\"><a:tint val=\"45000\"/><a:shade val=\"99000\"/><a:satMod val=\"350000\"/></a:schemeClr></a:gs>",
2591    "<a:gs pos=\"100000\"><a:schemeClr val=\"phClr\"><a:shade val=\"20000\"/><a:satMod val=\"255000\"/></a:schemeClr></a:gs>",
2592    "</a:gsLst><a:path path=\"circle\"><a:fillToRect l=\"50000\" t=\"-80000\" r=\"50000\" b=\"180000\"/></a:path></a:gradFill>",
2593    "<a:gradFill rotWithShape=\"1\"><a:gsLst>",
2594    "<a:gs pos=\"0\"><a:schemeClr val=\"phClr\"><a:tint val=\"80000\"/><a:satMod val=\"300000\"/></a:schemeClr></a:gs>",
2595    "<a:gs pos=\"100000\"><a:schemeClr val=\"phClr\"><a:shade val=\"30000\"/><a:satMod val=\"200000\"/></a:schemeClr></a:gs>",
2596    "</a:gsLst><a:path path=\"circle\"><a:fillToRect l=\"50000\" t=\"50000\" r=\"50000\" b=\"50000\"/></a:path></a:gradFill>",
2597    "</a:bgFillStyleLst>",
2598    "</a:fmtScheme>",
2599);
2600
2601/// Serializes a minimal `word/settings.xml` part, near-empty except for `w:trackRevisions` when
2602/// `track_changes` is set (`Document.track_changes`), `w:documentProtection` when `protection` is
2603/// `Some` (`Document.protection`), and `w:evenAndOddHeaders` when `even_and_odd_headers` is set
2604/// (written when the document has a `Document.header_even`/`.footer_even`, per
2605/// `Document.write_to`).
2606///
2607/// Every child of `CT_Settings` (WordprocessingML schema) has `minOccurs="0"`, so an empty
2608/// `<w:settings>` root is schema-valid. Real Word files carry many more settings, but none of them
2609/// are required for the document to open.
2610///
2611/// `CT_Settings`'s own sequence puts `trackRevisions` well before `evenAndOddHeaders` (right after
2612/// `revisionView`, long before the `defaultTableStyle`/`evenAndOddHeaders`/`bookFoldRevPrinting`
2613/// cluster), with `documentProtection` in between the two (right after
2614/// `doNotTrackMoves`/`doNotTrackFormatting`, still well before `evenAndOddHeaders`) — confirmed via
2615/// ECMA-376's own schema fragment for `CT_Settings` — so the write order below (`trackRevisions`,
2616/// then `documentProtection`, then `evenAndOddHeaders`) matches the schema.
2617///
2618/// `w:documentProtection` is only ever written with `w:enforcement="1"` and no password/hash
2619/// attributes — see `Document.protection`'s doc comment for why the password-protected variant
2620/// isn't modeled.
2621///
2622/// Deliberately does NOT set `w:updateFields`, even though the document may contain fields (page
2623/// numbers) — see `write_field`'s doc comment: forcing it caused Word to warn about "links to other
2624/// files" on open, and `PAGE`/`NUMPAGES` don't need it in the first place (Word's layout engine
2625/// keeps them live on its own).
2626fn to_settings_xml(
2627    track_changes: bool,
2628    protection: Option<ProtectionKind>,
2629    even_and_odd_headers: bool,
2630) -> Result<Vec<u8>> {
2631    let mut writer = Writer::new(Vec::new());
2632
2633    writer.write_event(Event::Decl(BytesDecl::new(
2634        "1.0",
2635        Some("UTF-8"),
2636        Some("yes"),
2637    )))?;
2638
2639    let mut root = BytesStart::new("w:settings");
2640    root.push_attribute(("xmlns:w", WORDPROCESSINGML_NAMESPACE));
2641
2642    if track_changes || protection.is_some() || even_and_odd_headers {
2643        writer.write_event(Event::Start(root))?;
2644        if track_changes {
2645            writer.write_event(Event::Empty(BytesStart::new("w:trackRevisions")))?;
2646        }
2647        if let Some(protection) = protection {
2648            let mut document_protection = BytesStart::new("w:documentProtection");
2649            document_protection.push_attribute(("w:edit", protection.attribute_value()));
2650            document_protection.push_attribute(("w:enforcement", "1"));
2651            writer.write_event(Event::Empty(document_protection))?;
2652        }
2653        if even_and_odd_headers {
2654            writer.write_event(Event::Empty(BytesStart::new("w:evenAndOddHeaders")))?;
2655        }
2656        writer.write_event(Event::End(BytesEnd::new("w:settings")))?;
2657    } else {
2658        writer.write_event(Event::Empty(root))?;
2659    }
2660
2661    Ok(writer.into_inner())
2662}
2663
2664/// Serializes `docProps/core.xml` (OPC core properties) from a [`DocumentProperties`].
2665///
2666/// Every child of `CT_CoreProperties` (ECMA-376 Part 2, OPC core properties schema) has
2667/// `minOccurs="0"`, so an empty `<cp:coreProperties>` root is schema-valid —
2668/// `DocumentProperties::default()` (every field `None`) still produces exactly that, unchanged from
2669/// what this crate has always written. Unlike every `CT_PPrBase`/`CT_Settings`/. sequence this
2670/// project has had to pin down elsewhere, `CT_CoreProperties` is an `xsd:all` group — confirmed via
2671/// ECMA-376's own schema fragment — so its children may appear in any order; the order below
2672/// (matching `DocumentProperties`' own field order) is simply this crate's own convention, not
2673/// schema-mandated. The four namespaces below are declared even when no property needs one of them,
2674/// matching real Word output.
2675fn to_core_properties_xml(properties: &DocumentProperties) -> Result<Vec<u8>> {
2676    let mut writer = Writer::new(Vec::new());
2677
2678    writer.write_event(Event::Decl(BytesDecl::new(
2679        "1.0",
2680        Some("UTF-8"),
2681        Some("yes"),
2682    )))?;
2683
2684    let mut root = BytesStart::new("cp:coreProperties");
2685    root.push_attribute(("xmlns:cp", CORE_PROPERTIES_NAMESPACE));
2686    root.push_attribute(("xmlns:dc", DC_NAMESPACE));
2687    root.push_attribute(("xmlns:dcterms", DCTERMS_NAMESPACE));
2688    root.push_attribute(("xmlns:xsi", XSI_NAMESPACE));
2689
2690    let has_any_property = properties.title.is_some()
2691        || properties.subject.is_some()
2692        || properties.creator.is_some()
2693        || properties.keywords.is_some()
2694        || properties.description.is_some()
2695        || properties.last_modified_by.is_some()
2696        || properties.revision.is_some()
2697        || properties.created.is_some()
2698        || properties.modified.is_some()
2699        || properties.category.is_some()
2700        || properties.content_status.is_some();
2701
2702    if !has_any_property {
2703        writer.write_event(Event::Empty(root))?;
2704        return Ok(writer.into_inner());
2705    }
2706
2707    writer.write_event(Event::Start(root))?;
2708    write_core_property_text(&mut writer, "dc:title", properties.title.as_deref())?;
2709    write_core_property_text(&mut writer, "dc:subject", properties.subject.as_deref())?;
2710    write_core_property_text(&mut writer, "dc:creator", properties.creator.as_deref())?;
2711    write_core_property_text(&mut writer, "cp:keywords", properties.keywords.as_deref())?;
2712    write_core_property_text(
2713        &mut writer,
2714        "dc:description",
2715        properties.description.as_deref(),
2716    )?;
2717    write_core_property_text(
2718        &mut writer,
2719        "cp:lastModifiedBy",
2720        properties.last_modified_by.as_deref(),
2721    )?;
2722    write_core_property_text(&mut writer, "cp:revision", properties.revision.as_deref())?;
2723    write_core_property_datetime(
2724        &mut writer,
2725        "dcterms:created",
2726        properties.created.as_deref(),
2727    )?;
2728    write_core_property_datetime(
2729        &mut writer,
2730        "dcterms:modified",
2731        properties.modified.as_deref(),
2732    )?;
2733    write_core_property_text(&mut writer, "cp:category", properties.category.as_deref())?;
2734    write_core_property_text(
2735        &mut writer,
2736        "cp:contentStatus",
2737        properties.content_status.as_deref(),
2738    )?;
2739    writer.write_event(Event::End(BytesEnd::new("cp:coreProperties")))?;
2740
2741    Ok(writer.into_inner())
2742}
2743
2744/// Writes a single plain-text core property element (e.g. `<dc:title>..`) when `value` is `Some`, a
2745/// no-op otherwise.
2746fn write_core_property_text<W: Write>(
2747    writer: &mut Writer<W>,
2748    element_name: &str,
2749    value: Option<&str>,
2750) -> Result<()> {
2751    if let Some(value) = value {
2752        writer.write_event(Event::Start(BytesStart::new(element_name)))?;
2753        writer.write_event(Event::Text(BytesText::new(value)))?;
2754        writer.write_event(Event::End(BytesEnd::new(element_name)))?;
2755    }
2756    Ok(())
2757}
2758
2759/// Writes a single `dcterms:created`/`dcterms:modified` element when `value` is `Some`, a no-op
2760/// otherwise. Both require an explicit `xsi:type="dcterms:W3CDTF"` attribute (confirmed via real
2761/// Word output) — `value` is written verbatim as the caller supplied it, not validated as a real
2762/// W3CDTF string (see `DocumentProperties`'s doc comment).
2763fn write_core_property_datetime<W: Write>(
2764    writer: &mut Writer<W>,
2765    element_name: &str,
2766    value: Option<&str>,
2767) -> Result<()> {
2768    if let Some(value) = value {
2769        let mut start = BytesStart::new(element_name);
2770        start.push_attribute(("xsi:type", "dcterms:W3CDTF"));
2771        writer.write_event(Event::Start(start))?;
2772        writer.write_event(Event::Text(BytesText::new(value)))?;
2773        writer.write_event(Event::End(BytesEnd::new(element_name)))?;
2774    }
2775    Ok(())
2776}
2777
2778/// Serializes `docProps/app.xml` (extended/application properties) from an [`ExtendedProperties`].
2779/// Every child of `CT_Properties` (ECMA-376 Part 1, extended properties schema) has
2780/// `minOccurs="0"`, and — confirmed via the schema fragment itself — `CT_Properties` is an
2781/// `xsd:all` group like `CT_CoreProperties`, so no fixed child order applies. `Application` is
2782/// always set, to identify this library as the document's producer — unchanged from before
2783/// `ExtendedProperties` existed. `Company`/`Manager` are written after it when set; see
2784/// [`ExtendedProperties`]'s own doc comment for why no other field is modeled.
2785fn to_extended_properties_xml(extended_properties: &ExtendedProperties) -> Result<Vec<u8>> {
2786    let mut writer = Writer::new(Vec::new());
2787
2788    writer.write_event(Event::Decl(BytesDecl::new(
2789        "1.0",
2790        Some("UTF-8"),
2791        Some("yes"),
2792    )))?;
2793
2794    let mut root = BytesStart::new("Properties");
2795    root.push_attribute(("xmlns", EXTENDED_PROPERTIES_NAMESPACE));
2796    writer.write_event(Event::Start(root))?;
2797
2798    writer.write_event(Event::Start(BytesStart::new("Application")))?;
2799    writer.write_event(Event::Text(BytesText::new("office-toolkit")))?;
2800    writer.write_event(Event::End(BytesEnd::new("Application")))?;
2801
2802    write_core_property_text(
2803        &mut writer,
2804        "Manager",
2805        extended_properties.manager.as_deref(),
2806    )?;
2807    write_core_property_text(
2808        &mut writer,
2809        "Company",
2810        extended_properties.company.as_deref(),
2811    )?;
2812
2813    writer.write_event(Event::End(BytesEnd::new("Properties")))?;
2814
2815    Ok(writer.into_inner())
2816}
2817
2818/// Serializes `docProps/custom.xml` (OPC custom properties) from `custom_properties` — only called
2819/// when non-empty, see `Document.custom_properties`'s doc comment. Each entry becomes a
2820/// `<property>` element (`CT_Property`) with the fixed custom-properties `fmtid`, a `pid` assigned
2821/// sequentially starting at `FIRST_CUSTOM_PROPERTY_PID` in `custom_properties`' own order (not
2822/// re-sorted), the entry's name, and a single variant-type child chosen from
2823/// `CustomPropertyValue`'s variant (`vt:lpwstr`/`vt:bool`/`vt:i4`/ `vt:r8` — see that enum's doc
2824/// comment for which types are modeled).
2825fn to_custom_properties_xml(
2826    custom_properties: &[(String, CustomPropertyValue)],
2827) -> Result<Vec<u8>> {
2828    let mut writer = Writer::new(Vec::new());
2829
2830    writer.write_event(Event::Decl(BytesDecl::new(
2831        "1.0",
2832        Some("UTF-8"),
2833        Some("yes"),
2834    )))?;
2835
2836    let mut root = BytesStart::new("Properties");
2837    root.push_attribute(("xmlns", CUSTOM_PROPERTIES_NAMESPACE));
2838    root.push_attribute(("xmlns:vt", CUSTOM_PROPERTIES_VT_NAMESPACE));
2839    writer.write_event(Event::Start(root))?;
2840
2841    for (index, (name, value)) in custom_properties.iter().enumerate() {
2842        let pid = FIRST_CUSTOM_PROPERTY_PID + index as i32;
2843
2844        let mut property = BytesStart::new("property");
2845        property.push_attribute(("fmtid", CUSTOM_PROPERTY_FMTID));
2846        property.push_attribute(("pid", pid.to_string().as_str()));
2847        property.push_attribute(("name", name.as_str()));
2848        writer.write_event(Event::Start(property))?;
2849
2850        let (variant_element, text) = match value {
2851            CustomPropertyValue::Text(text) => ("vt:lpwstr", text.clone()),
2852            CustomPropertyValue::Bool(value) => (
2853                "vt:bool",
2854                if *value {
2855                    "true".to_string()
2856                } else {
2857                    "false".to_string()
2858                },
2859            ),
2860            CustomPropertyValue::Int(value) => ("vt:i4", value.to_string()),
2861            CustomPropertyValue::Number(value) => ("vt:r8", value.to_string()),
2862        };
2863        writer.write_event(Event::Start(BytesStart::new(variant_element)))?;
2864        writer.write_event(Event::Text(BytesText::new(&text)))?;
2865        writer.write_event(Event::End(BytesEnd::new(variant_element)))?;
2866
2867        writer.write_event(Event::End(BytesEnd::new("property")))?;
2868    }
2869
2870    writer.write_event(Event::End(BytesEnd::new("Properties")))?;
2871
2872    Ok(writer.into_inner())
2873}
2874
2875#[cfg(test)]
2876mod tests {
2877    use super::*;
2878    use crate::model::Paragraph;
2879
2880    /// Serializes `document`'s body to XML with no header/footer references, `document`'s own
2881    /// `page_setup`, and fresh, empty package state — convenient for the many tests that only care
2882    /// about body content.
2883    fn document_xml(document: &Document) -> String {
2884        let xml = document
2885            .to_document_xml(
2886                &mut PackageState::new(),
2887                &mut PartRelationships::new(),
2888                SectionReferenceIds::default(),
2889            )
2890            .unwrap();
2891        String::from_utf8(xml).unwrap()
2892    }
2893
2894    #[test]
2895    fn writes_paragraphs_and_runs() {
2896        let document = Document::new()
2897            .with_paragraph(Paragraph::with_text("Hello"))
2898            .with_paragraph(Paragraph::with_text("World"));
2899
2900        let xml = document_xml(&document);
2901
2902        assert!(xml.contains("<w:t>Hello</w:t>"), "{xml}");
2903        assert!(xml.contains("<w:t>World</w:t>"), "{xml}");
2904        assert!(xml.contains("<w:sectPr>"), "{xml}");
2905    }
2906
2907    #[test]
2908    fn marks_runs_with_significant_whitespace_as_preserved() {
2909        let document = Document::new().with_paragraph(Paragraph::with_text(" padded "));
2910
2911        let xml = document_xml(&document);
2912
2913        assert!(
2914            xml.contains(r#"<w:t xml:space="preserve"> padded </w:t>"#),
2915            "{xml}"
2916        );
2917    }
2918
2919    #[test]
2920    fn escapes_xml_special_characters_in_written_text() {
2921        // Diagnostic/regression test isolating the WRITE side only: dumps the raw XML `write_run`
2922        // actually produces for text containing all five XML-special characters, independent of any
2923        // reading back. If this fails, the bug is in `BytesText::new`/`write_run` itself; if it
2924        // passes but a round-trip test still fails, the bug is on the reading side instead.
2925        let text = r#"A & B < C > D ' E " F"#;
2926        let document = Document::new().with_paragraph(Paragraph::with_text(text));
2927
2928        let xml = document_xml(&document);
2929
2930        assert!(
2931            xml.contains("<w:t>A &amp; B &lt; C &gt; D &apos; E &quot; F</w:t>"),
2932            "expected escaped entities in the raw XML, got: {xml}"
2933        );
2934    }
2935
2936    #[test]
2937    fn writes_bold_italic_and_underline_run_properties() {
2938        use crate::model::{Run, UnderlineStyle};
2939
2940        let document = Document::new().with_paragraph(
2941            Paragraph::new().with_run(
2942                Run::new("Styled")
2943                    .with_bold(true)
2944                    .with_italic(true)
2945                    .with_underline(UnderlineStyle::Single),
2946            ),
2947        );
2948
2949        let xml = document_xml(&document);
2950
2951        assert!(
2952            xml.contains(r#"<w:rPr><w:b/><w:i/><w:u w:val="single"/></w:rPr><w:t>Styled</w:t>"#),
2953            "{xml}"
2954        );
2955    }
2956
2957    #[test]
2958    fn writes_color_size_and_font_family_run_properties() {
2959        use crate::model::Run;
2960
2961        let document = Document::new().with_paragraph(
2962            Paragraph::new().with_run(
2963                Run::new("Colorful")
2964                    .with_color("FF0000")
2965                    .with_font_size(14)
2966                    .with_font_family("Georgia"),
2967            ),
2968        );
2969
2970        let xml = document_xml(&document);
2971
2972        assert!(
2973            xml.contains(
2974                r#"<w:rPr><w:rFonts w:ascii="Georgia" w:hAnsi="Georgia"/><w:color w:val="FF0000"/><w:sz w:val="28"/><w:szCs w:val="28"/></w:rPr><w:t>Colorful</w:t>"#
2975            ),
2976            "{xml}"
2977        );
2978    }
2979
2980    #[test]
2981    fn combines_color_size_font_and_bold_italic_underline_in_one_run() {
2982        use crate::model::{Run, UnderlineStyle};
2983
2984        let document = Document::new().with_paragraph(
2985            Paragraph::new().with_run(
2986                Run::new("All of it")
2987                    .with_color("0000FF")
2988                    .with_font_size(10)
2989                    .with_font_family("Verdana")
2990                    .with_bold(true)
2991                    .with_italic(true)
2992                    .with_underline(UnderlineStyle::Single),
2993            ),
2994        );
2995
2996        let xml = document_xml(&document);
2997
2998        assert!(
2999            xml.contains(
3000                r#"<w:rPr><w:rFonts w:ascii="Verdana" w:hAnsi="Verdana"/><w:color w:val="0000FF"/><w:sz w:val="20"/><w:szCs w:val="20"/><w:b/><w:i/><w:u w:val="single"/></w:rPr>"#
3001            ),
3002            "{xml}"
3003        );
3004    }
3005
3006    #[test]
3007    fn writes_a_highlighted_run() {
3008        use crate::model::{Highlight, Run};
3009
3010        let document = Document::new().with_paragraph(
3011            Paragraph::new().with_run(Run::new("Highlighted").with_highlight(Highlight::Yellow)),
3012        );
3013
3014        let xml = document_xml(&document);
3015
3016        assert!(
3017            xml.contains(r#"<w:rPr><w:highlight w:val="yellow"/></w:rPr><w:t>Highlighted</w:t>"#),
3018            "{xml}"
3019        );
3020    }
3021
3022    #[test]
3023    fn combines_highlight_with_color_size_font_and_bold_italic_underline() {
3024        use crate::model::{Highlight, Run, UnderlineStyle};
3025
3026        let document = Document::new().with_paragraph(
3027            Paragraph::new().with_run(
3028                Run::new("All of it")
3029                    .with_color("0000FF")
3030                    .with_font_size(10)
3031                    .with_font_family("Verdana")
3032                    .with_highlight(Highlight::Cyan)
3033                    .with_bold(true)
3034                    .with_italic(true)
3035                    .with_underline(UnderlineStyle::Single),
3036            ),
3037        );
3038
3039        let xml = document_xml(&document);
3040
3041        assert!(
3042            xml.contains(
3043                r#"<w:rPr><w:rFonts w:ascii="Verdana" w:hAnsi="Verdana"/><w:color w:val="0000FF"/><w:sz w:val="20"/><w:szCs w:val="20"/><w:highlight w:val="cyan"/><w:b/><w:i/><w:u w:val="single"/></w:rPr>"#
3044            ),
3045            "{xml}"
3046        );
3047    }
3048
3049    #[test]
3050    fn writes_a_struck_through_run() {
3051        use crate::model::Run;
3052
3053        let document = Document::new()
3054            .with_paragraph(Paragraph::new().with_run(Run::new("Struck").with_strike(true)));
3055
3056        let xml = document_xml(&document);
3057
3058        assert!(
3059            xml.contains(r#"<w:rPr><w:strike/></w:rPr><w:t>Struck</w:t>"#),
3060            "{xml}"
3061        );
3062    }
3063
3064    #[test]
3065    fn writes_a_superscript_run() {
3066        use crate::model::{Run, VerticalAlign};
3067
3068        let document = Document::new().with_paragraph(
3069            Paragraph::new()
3070                .with_run(Run::new("Superscript").with_vertical_align(VerticalAlign::Superscript)),
3071        );
3072
3073        let xml = document_xml(&document);
3074
3075        assert!(
3076            xml.contains(
3077                r#"<w:rPr><w:vertAlign w:val="superscript"/></w:rPr><w:t>Superscript</w:t>"#
3078            ),
3079            "{xml}"
3080        );
3081    }
3082
3083    #[test]
3084    fn writes_a_subscript_run() {
3085        use crate::model::{Run, VerticalAlign};
3086
3087        let document = Document::new().with_paragraph(
3088            Paragraph::new()
3089                .with_run(Run::new("Subscript").with_vertical_align(VerticalAlign::Subscript)),
3090        );
3091
3092        let xml = document_xml(&document);
3093
3094        assert!(
3095            xml.contains(r#"<w:rPr><w:vertAlign w:val="subscript"/></w:rPr><w:t>Subscript</w:t>"#),
3096            "{xml}"
3097        );
3098    }
3099
3100    #[test]
3101    fn combines_strike_and_vertical_align_with_highlight_color_size_font_and_bold_italic_underline()
3102    {
3103        use crate::model::{Highlight, Run, UnderlineStyle, VerticalAlign};
3104
3105        let document = Document::new().with_paragraph(
3106            Paragraph::new().with_run(
3107                Run::new("Everything")
3108                    .with_color("0000FF")
3109                    .with_font_size(10)
3110                    .with_font_family("Verdana")
3111                    .with_highlight(Highlight::Cyan)
3112                    .with_bold(true)
3113                    .with_italic(true)
3114                    .with_underline(UnderlineStyle::Single)
3115                    .with_strike(true)
3116                    .with_vertical_align(VerticalAlign::Superscript),
3117            ),
3118        );
3119
3120        let xml = document_xml(&document);
3121
3122        assert!(
3123            xml.contains(
3124                r#"<w:rPr><w:rFonts w:ascii="Verdana" w:hAnsi="Verdana"/><w:color w:val="0000FF"/><w:sz w:val="20"/><w:szCs w:val="20"/><w:highlight w:val="cyan"/><w:b/><w:i/><w:u w:val="single"/><w:strike/><w:vertAlign w:val="superscript"/></w:rPr>"#
3125            ),
3126            "{xml}"
3127        );
3128    }
3129
3130    #[test]
3131    fn writes_a_wave_underline() {
3132        use crate::model::{Run, UnderlineStyle};
3133
3134        let document = Document::new().with_paragraph(
3135            Paragraph::new().with_run(Run::new("Wavy").with_underline(UnderlineStyle::Wave)),
3136        );
3137
3138        let xml = document_xml(&document);
3139
3140        assert!(
3141            xml.contains(r#"<w:rPr><w:u w:val="wave"/></w:rPr><w:t>Wavy</w:t>"#),
3142            "{xml}"
3143        );
3144    }
3145
3146    #[test]
3147    fn writes_an_underline_color() {
3148        use crate::model::{Run, UnderlineStyle};
3149
3150        let document = Document::new().with_paragraph(
3151            Paragraph::new().with_run(
3152                Run::new("Red wave")
3153                    .with_underline(UnderlineStyle::Wave)
3154                    .with_underline_color("FF0000"),
3155            ),
3156        );
3157
3158        let xml = document_xml(&document);
3159
3160        assert!(
3161            xml.contains(
3162                r#"<w:rPr><w:u w:val="wave" w:color="FF0000"/></w:rPr><w:t>Red wave</w:t>"#
3163            ),
3164            "{xml}"
3165        );
3166    }
3167
3168    #[test]
3169    fn writes_small_caps_and_all_caps() {
3170        use crate::model::Run;
3171
3172        let document = Document::new()
3173            .with_paragraph(Paragraph::new().with_run(Run::new("Small caps").with_small_caps(true)))
3174            .with_paragraph(Paragraph::new().with_run(Run::new("All caps").with_all_caps(true)));
3175
3176        let xml = document_xml(&document);
3177
3178        assert!(
3179            xml.contains(r#"<w:rPr><w:smallCaps/></w:rPr><w:t>Small caps</w:t>"#),
3180            "{xml}"
3181        );
3182        assert!(
3183            xml.contains(r#"<w:rPr><w:caps/></w:rPr><w:t>All caps</w:t>"#),
3184            "{xml}"
3185        );
3186    }
3187
3188    #[test]
3189    fn writes_a_shading_color() {
3190        use crate::model::Run;
3191
3192        let document = Document::new().with_paragraph(
3193            Paragraph::new().with_run(Run::new("Shaded").with_shading_color("FFFF00")),
3194        );
3195
3196        let xml = document_xml(&document);
3197
3198        assert!(
3199            xml.contains(r#"<w:rPr><w:shd w:val="clear" w:color="auto" w:fill="FFFF00"/></w:rPr><w:t>Shaded</w:t>"#),
3200            "{xml}"
3201        );
3202    }
3203
3204    #[test]
3205    fn writes_a_text_border() {
3206        use crate::model::Run;
3207
3208        let document = Document::new()
3209            .with_paragraph(Paragraph::new().with_run(Run::new("Bordered").with_text_border(true)));
3210
3211        let xml = document_xml(&document);
3212
3213        assert!(
3214            xml.contains(
3215                r#"<w:rPr><w:bdr w:val="single" w:sz="4" w:space="0" w:color="auto"/></w:rPr><w:t>Bordered</w:t>"#
3216            ),
3217            "{xml}"
3218        );
3219    }
3220
3221    #[test]
3222    fn writes_character_spacing_and_vertical_position() {
3223        use crate::model::Run;
3224
3225        let document = Document::new()
3226            .with_paragraph(
3227                Paragraph::new().with_run(Run::new("Expanded").with_character_spacing(40)),
3228            )
3229            .with_paragraph(
3230                Paragraph::new().with_run(Run::new("Condensed").with_character_spacing(-20)),
3231            )
3232            .with_paragraph(Paragraph::new().with_run(Run::new("Raised").with_vertical_position(6)))
3233            .with_paragraph(
3234                Paragraph::new().with_run(Run::new("Lowered").with_vertical_position(-6)),
3235            );
3236
3237        let xml = document_xml(&document);
3238
3239        assert!(
3240            xml.contains(r#"<w:rPr><w:spacing w:val="40"/></w:rPr><w:t>Expanded</w:t>"#),
3241            "{xml}"
3242        );
3243        assert!(
3244            xml.contains(r#"<w:rPr><w:spacing w:val="-20"/></w:rPr><w:t>Condensed</w:t>"#),
3245            "{xml}"
3246        );
3247        assert!(
3248            xml.contains(r#"<w:rPr><w:position w:val="6"/></w:rPr><w:t>Raised</w:t>"#),
3249            "{xml}"
3250        );
3251        assert!(
3252            xml.contains(r#"<w:rPr><w:position w:val="-6"/></w:rPr><w:t>Lowered</w:t>"#),
3253            "{xml}"
3254        );
3255    }
3256
3257    #[test]
3258    fn writes_hidden_text() {
3259        use crate::model::Run;
3260
3261        let document = Document::new()
3262            .with_paragraph(Paragraph::new().with_run(Run::new("Hidden").with_hidden(true)));
3263
3264        let xml = document_xml(&document);
3265
3266        assert!(
3267            xml.contains(r#"<w:rPr><w:vanish/></w:rPr><w:t>Hidden</w:t>"#),
3268            "{xml}"
3269        );
3270    }
3271
3272    #[test]
3273    fn plain_runs_have_no_run_properties_element() {
3274        let document = Document::new().with_paragraph(Paragraph::with_text("Plain"));
3275
3276        let xml = document_xml(&document);
3277
3278        assert!(!xml.contains("w:rPr"), "{xml}");
3279    }
3280
3281    #[test]
3282    fn writes_paragraph_alignment_before_its_runs() {
3283        let document = Document::new()
3284            .with_paragraph(Paragraph::with_text("Centered").with_alignment(Alignment::Center));
3285
3286        let xml = document_xml(&document);
3287
3288        assert!(
3289            xml.contains(r#"<w:p><w:pPr><w:jc w:val="center"/></w:pPr><w:r>"#),
3290            "{xml}"
3291        );
3292    }
3293
3294    #[test]
3295    fn writes_paragraph_line_spacing_and_before_after() {
3296        let document = Document::new().with_paragraph(
3297            Paragraph::with_text("Spaced out")
3298                .with_line_spacing(480)
3299                .with_space_before(240)
3300                .with_space_after(120),
3301        );
3302
3303        let xml = document_xml(&document);
3304
3305        assert!(
3306            xml.contains(r#"<w:pPr><w:spacing w:before="240" w:after="120" w:line="480" w:lineRule="auto"/></w:pPr>"#),
3307            "{xml}"
3308        );
3309    }
3310
3311    #[test]
3312    fn writes_a_paragraph_shading_color() {
3313        let document = Document::new()
3314            .with_paragraph(Paragraph::with_text("Shaded paragraph").with_shading_color("D9D9D9"));
3315
3316        let xml = document_xml(&document);
3317
3318        assert!(
3319            xml.contains(r#"<w:pPr><w:shd w:val="clear" w:color="auto" w:fill="D9D9D9"/></w:pPr>"#),
3320            "{xml}"
3321        );
3322    }
3323
3324    #[test]
3325    fn writes_a_paragraph_border_on_all_four_sides() {
3326        let document = Document::new()
3327            .with_paragraph(Paragraph::with_text("Boxed paragraph").with_border(true));
3328
3329        let xml = document_xml(&document);
3330
3331        assert!(
3332            xml.contains(
3333                r#"<w:pPr><w:pBdr><w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/></w:pBdr></w:pPr>"#
3334            ),
3335            "{xml}"
3336        );
3337    }
3338
3339    #[test]
3340    fn writes_pbdr_before_shd_before_spacing_before_jc() {
3341        let document = Document::new().with_paragraph(
3342            Paragraph::with_text("All paragraph properties at once")
3343                .with_border(true)
3344                .with_shading_color("FFFF00")
3345                .with_line_spacing(360)
3346                .with_alignment(Alignment::Center),
3347        );
3348
3349        let xml = document_xml(&document);
3350
3351        let pbdr = xml.find("<w:pBdr>").expect("missing w:pBdr");
3352        let shd = xml.find("<w:shd").expect("missing w:shd");
3353        let spacing = xml.find("<w:spacing").expect("missing w:spacing");
3354        let jc = xml.find("<w:jc").expect("missing w:jc");
3355        assert!(pbdr < shd && shd < spacing && spacing < jc, "{xml}");
3356    }
3357
3358    #[test]
3359    fn writes_a_table_with_tblpr_before_tblgrid_before_rows() {
3360        use crate::model::{Table, TableCell, TableRow};
3361
3362        let document = Document::new().with_table(
3363            Table::new().with_row(
3364                TableRow::new()
3365                    .with_cell(TableCell::with_text("A1"))
3366                    .with_cell(TableCell::with_text("B1")),
3367            ),
3368        );
3369
3370        let xml = document_xml(&document);
3371
3372        // CT_Tbl is a strict sequence: tblPr, then tblGrid, then rows.
3373        let tbl_pr = xml.find("<w:tblPr>").expect("missing w:tblPr");
3374        let tbl_grid = xml.find("<w:tblGrid>").expect("missing w:tblGrid");
3375        let first_row = xml.find("<w:tr>").expect("missing w:tr");
3376        assert!(tbl_pr < tbl_grid && tbl_grid < first_row, "{xml}");
3377
3378        assert!(
3379            xml.contains("<w:gridCol w:w=\"2000\"/><w:gridCol w:w=\"2000\"/>"),
3380            "{xml}"
3381        );
3382        assert!(xml.contains("<w:t>A1</w:t>"), "{xml}");
3383        assert!(xml.contains("<w:t>B1</w:t>"), "{xml}");
3384    }
3385
3386    #[test]
3387    fn writes_explicit_column_widths_and_a_fixed_layout() {
3388        use crate::model::{Table, TableCell, TableRow};
3389
3390        let document = Document::new().with_table(
3391            Table::new().with_column_widths(vec![1000, 3000]).with_row(
3392                TableRow::new()
3393                    .with_cell(TableCell::with_text("A1"))
3394                    .with_cell(TableCell::with_text("B1")),
3395            ),
3396        );
3397
3398        let xml = document_xml(&document);
3399
3400        assert!(
3401            xml.contains(r#"<w:tblW w:w="4000" w:type="dxa"/>"#),
3402            "{xml}"
3403        );
3404        assert!(xml.contains(r#"<w:tblLayout w:type="fixed"/>"#), "{xml}");
3405        assert!(
3406            xml.contains(r#"<w:gridCol w:w="1000"/><w:gridCol w:w="3000"/>"#),
3407            "{xml}"
3408        );
3409    }
3410
3411    #[test]
3412    fn a_table_with_no_column_widths_keeps_auto_layout() {
3413        use crate::model::{Table, TableCell, TableRow};
3414
3415        let document = Document::new().with_table(
3416            Table::new().with_row(TableRow::new().with_cell(TableCell::with_text("A1"))),
3417        );
3418
3419        let xml = document_xml(&document);
3420
3421        assert!(xml.contains(r#"<w:tblW w:w="0" w:type="auto"/>"#), "{xml}");
3422        assert!(!xml.contains("w:tblLayout"), "{xml}");
3423    }
3424
3425    #[test]
3426    fn column_widths_shorter_than_the_real_column_count_fall_back_to_the_default_width() {
3427        use crate::model::{Table, TableCell, TableRow};
3428
3429        let document = Document::new().with_table(
3430            Table::new().with_column_widths(vec![1000]).with_row(
3431                TableRow::new()
3432                    .with_cell(TableCell::with_text("A1"))
3433                    .with_cell(TableCell::with_text("B1")),
3434            ),
3435        );
3436
3437        let xml = document_xml(&document);
3438
3439        assert!(
3440            xml.contains(r#"<w:gridCol w:w="1000"/><w:gridCol w:w="2000"/>"#),
3441            "{xml}"
3442        );
3443    }
3444
3445    #[test]
3446    fn writes_an_empty_paragraph_for_an_empty_table_cell() {
3447        use crate::model::{Table, TableCell, TableRow};
3448
3449        let document = Document::new()
3450            .with_table(Table::new().with_row(TableRow::new().with_cell(TableCell::new())));
3451
3452        let xml = document_xml(&document);
3453
3454        assert!(xml.contains("<w:tc><w:p/></w:tc>"), "{xml}");
3455    }
3456
3457    #[test]
3458    fn writes_a_nested_table_inside_a_cell_followed_by_a_trailing_paragraph() {
3459        use crate::model::{Table, TableCell, TableRow};
3460
3461        let inner_table =
3462            Table::new().with_row(TableRow::new().with_cell(TableCell::with_text("Inner")));
3463        let document = Document::new().with_table(
3464            Table::new()
3465                .with_row(TableRow::new().with_cell(TableCell::new().with_table(inner_table))),
3466        );
3467
3468        let xml = document_xml(&document);
3469
3470        // The outer `<w:tc>` contains a full nested `<w:tbl>..</w:tbl>`, and — since the cell's
3471        // last block is the table, not a paragraph — the writer appends a trailing empty `<w:p/>`
3472        // right after it, so real Word doesn't show a repair prompt on open.
3473        assert!(xml.contains("<w:tc><w:tbl>"), "{xml}");
3474        assert!(xml.contains("</w:tbl><w:p/></w:tc>"), "{xml}");
3475        assert!(xml.contains(">Inner<"), "{xml}");
3476    }
3477
3478    #[test]
3479    fn a_cell_ending_in_a_paragraph_after_a_nested_table_gets_no_extra_trailing_paragraph() {
3480        use crate::model::{Table, TableCell, TableRow};
3481
3482        let inner_table =
3483            Table::new().with_row(TableRow::new().with_cell(TableCell::with_text("Inner")));
3484        let document = Document::new().with_table(
3485            Table::new().with_row(
3486                TableRow::new().with_cell(
3487                    TableCell::new()
3488                        .with_table(inner_table)
3489                        .with_paragraph(Paragraph::with_text("Caption")),
3490                ),
3491            ),
3492        );
3493
3494        let xml = document_xml(&document);
3495
3496        assert!(xml.contains("</w:tbl><w:p>"), "{xml}");
3497        assert!(xml.contains(">Caption<"), "{xml}");
3498        // Only one paragraph should follow the nested table — not a second, auto-appended empty
3499        // one.
3500        assert!(!xml.contains("</w:tbl><w:p/><w:p>"), "{xml}");
3501    }
3502
3503    #[test]
3504    fn writes_a_horizontal_cell_merge() {
3505        use crate::model::{Table, TableCell, TableRow};
3506
3507        let document = Document::new().with_table(
3508            Table::new().with_row(
3509                TableRow::new()
3510                    .with_cell(TableCell::with_text("Spans two columns").with_horizontal_span(2)),
3511            ),
3512        );
3513
3514        let xml = document_xml(&document);
3515
3516        assert!(
3517            xml.contains(r#"<w:tc><w:tcPr><w:gridSpan w:val="2"/></w:tcPr><w:p>"#),
3518            "{xml}"
3519        );
3520    }
3521
3522    #[test]
3523    fn writes_a_vertical_cell_merge() {
3524        use crate::model::{Table, TableCell, TableRow, VerticalMerge};
3525
3526        let document = Document::new().with_table(
3527            Table::new()
3528                .with_row(TableRow::new().with_cell(
3529                    TableCell::with_text("Merged").with_vertical_merge(VerticalMerge::Restart),
3530                ))
3531                .with_row(
3532                    TableRow::new()
3533                        .with_cell(TableCell::new().with_vertical_merge(VerticalMerge::Continue)),
3534                ),
3535        );
3536
3537        let xml = document_xml(&document);
3538
3539        assert!(
3540            xml.contains(r#"<w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr><w:p>"#),
3541            "{xml}"
3542        );
3543        assert!(
3544            xml.contains(r#"<w:tc><w:tcPr><w:vMerge w:val="continue"/></w:tcPr><w:p/></w:tc>"#),
3545            "{xml}"
3546        );
3547    }
3548
3549    #[test]
3550    fn writes_gridspan_before_vmerge_in_tcpr() {
3551        use crate::model::{Table, TableCell, TableRow, VerticalMerge};
3552
3553        let document = Document::new().with_table(
3554            Table::new().with_row(
3555                TableRow::new().with_cell(
3556                    TableCell::with_text("Both at once")
3557                        .with_horizontal_span(2)
3558                        .with_vertical_merge(VerticalMerge::Restart),
3559                ),
3560            ),
3561        );
3562
3563        let xml = document_xml(&document);
3564
3565        assert!(
3566            xml.contains(r#"<w:tcPr><w:gridSpan w:val="2"/><w:vMerge w:val="restart"/></w:tcPr>"#),
3567            "{xml}"
3568        );
3569    }
3570
3571    #[test]
3572    fn tblgrid_column_count_accounts_for_horizontal_span() {
3573        use crate::model::{Table, TableCell, TableRow};
3574
3575        // Both rows merge cells, so EVERY row has fewer `TableCell` entries (2) than the real
3576        // spanned column count (4) — a naive `row.cells.len()`-based count would wrongly compute
3577        // `max(2, 2) = 2` `gridCol` entries here, since no row happens to have an un-merged cell to
3578        // reveal the true count. This is exactly the regression case `write_table`'s span-aware sum
3579        // guards against.
3580        let document = Document::new().with_table(
3581            Table::new()
3582                .with_row(
3583                    TableRow::new()
3584                        .with_cell(TableCell::with_text("A1").with_horizontal_span(2))
3585                        .with_cell(TableCell::with_text("B1").with_horizontal_span(2)),
3586                )
3587                .with_row(
3588                    TableRow::new()
3589                        .with_cell(TableCell::with_text("A2").with_horizontal_span(2))
3590                        .with_cell(TableCell::with_text("B2").with_horizontal_span(2)),
3591                ),
3592        );
3593
3594        let xml = document_xml(&document);
3595
3596        let grid_col_count = xml.matches("<w:gridCol ").count();
3597        assert_eq!(grid_col_count, 4, "{xml}");
3598    }
3599
3600    #[test]
3601    fn writes_a_custom_table_border_color() {
3602        use crate::model::{Table, TableCell, TableRow};
3603
3604        let document = Document::new().with_table(
3605            Table::new()
3606                .with_border_color("FF0000")
3607                .with_row(TableRow::new().with_cell(TableCell::with_text("A1"))),
3608        );
3609
3610        let xml = document_xml(&document);
3611
3612        assert!(
3613            xml.contains(r#"<w:top w:val="single" w:sz="4" w:space="0" w:color="FF0000"/>"#),
3614            "{xml}"
3615        );
3616        assert!(!xml.contains(r#"w:color="auto""#), "{xml}");
3617    }
3618
3619    #[test]
3620    fn a_table_with_no_border_color_keeps_the_fixed_auto_color() {
3621        use crate::model::{Table, TableCell, TableRow};
3622
3623        let document = Document::new().with_table(
3624            Table::new().with_row(TableRow::new().with_cell(TableCell::with_text("A1"))),
3625        );
3626
3627        let xml = document_xml(&document);
3628
3629        assert!(
3630            xml.contains(r#"<w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>"#),
3631            "{xml}"
3632        );
3633    }
3634
3635    #[test]
3636    fn writes_tblstyle_before_tblw_and_jc_and_tblind_after_it() {
3637        use crate::model::{Alignment, Table, TableCell, TableRow};
3638
3639        let document = Document::new().with_table(
3640            Table::new()
3641                .with_style_id("TableGrid")
3642                .with_alignment(Alignment::Center)
3643                .with_indent_twips(200)
3644                .with_row(TableRow::new().with_cell(TableCell::with_text("A1"))),
3645        );
3646
3647        let xml = document_xml(&document);
3648
3649        let tbl_style = xml
3650            .find(r#"<w:tblStyle w:val="TableGrid"/>"#)
3651            .expect("missing w:tblStyle");
3652        let tbl_w = xml.find("<w:tblW ").expect("missing w:tblW");
3653        let jc = xml.find(r#"<w:jc w:val="center"/>"#).expect("missing w:jc");
3654        let tbl_ind = xml
3655            .find(r#"<w:tblInd w:w="200" w:type="dxa"/>"#)
3656            .expect("missing w:tblInd");
3657        let tbl_borders = xml.find("<w:tblBorders>").expect("missing w:tblBorders");
3658        assert!(
3659            tbl_style < tbl_w && tbl_w < jc && jc < tbl_ind && tbl_ind < tbl_borders,
3660            "{xml}"
3661        );
3662    }
3663
3664    #[test]
3665    fn a_table_with_no_alignment_indent_or_style_omits_them() {
3666        use crate::model::{Table, TableCell, TableRow};
3667
3668        let document = Document::new().with_table(
3669            Table::new().with_row(TableRow::new().with_cell(TableCell::with_text("A1"))),
3670        );
3671
3672        let xml = document_xml(&document);
3673
3674        assert!(!xml.contains("w:tblStyle"), "{xml}");
3675        assert!(!xml.contains("w:tblInd"), "{xml}");
3676        // `w:jc` on its own could still appear from a paragraph's own alignment, but this document
3677        // has none, so the table-level `w:jc` (a direct child of `w:tblPr`) can be checked as
3678        // simply absent.
3679        assert!(!xml.contains("<w:jc "), "{xml}");
3680    }
3681
3682    #[test]
3683    fn writes_a_cell_border() {
3684        use crate::model::{Table, TableCell, TableRow};
3685
3686        let document =
3687            Document::new().with_table(Table::new().with_row(
3688                TableRow::new().with_cell(TableCell::with_text("Boxed").with_border(true)),
3689            ));
3690
3691        let xml = document_xml(&document);
3692
3693        assert!(
3694            xml.contains(
3695                r#"<w:tcPr><w:tcBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/></w:tcBorders></w:tcPr>"#
3696            ),
3697            "{xml}"
3698        );
3699    }
3700
3701    #[test]
3702    fn writes_a_cell_shading_color() {
3703        use crate::model::{Table, TableCell, TableRow};
3704
3705        let document = Document::new().with_table(Table::new().with_row(
3706            TableRow::new().with_cell(TableCell::with_text("Shaded").with_shading_color("FFFF00")),
3707        ));
3708
3709        let xml = document_xml(&document);
3710
3711        assert!(
3712            xml.contains(
3713                r#"<w:tcPr><w:shd w:val="clear" w:color="auto" w:fill="FFFF00"/></w:tcPr>"#
3714            ),
3715            "{xml}"
3716        );
3717    }
3718
3719    #[test]
3720    fn writes_a_cell_vertical_alignment() {
3721        use crate::model::{CellVerticalAlign, Table, TableCell, TableRow};
3722
3723        let document =
3724            Document::new().with_table(Table::new().with_row(TableRow::new().with_cell(
3725                TableCell::with_text("Centered").with_vertical_alignment(CellVerticalAlign::Center),
3726            )));
3727
3728        let xml = document_xml(&document);
3729
3730        assert!(
3731            xml.contains(r#"<w:tcPr><w:vAlign w:val="center"/></w:tcPr>"#),
3732            "{xml}"
3733        );
3734    }
3735
3736    #[test]
3737    fn writes_cell_margins_in_tcmar_order() {
3738        use crate::model::{Table, TableCell, TableRow};
3739
3740        let document =
3741            Document::new().with_table(Table::new().with_row(
3742                TableRow::new().with_cell(
3743                    TableCell::with_text("Padded").with_margins_twips(100, 200, 300, 400),
3744                ),
3745            ));
3746
3747        let xml = document_xml(&document);
3748
3749        assert!(
3750            xml.contains(
3751                r#"<w:tcMar><w:top w:w="100" w:type="dxa"/><w:left w:w="200" w:type="dxa"/><w:bottom w:w="300" w:type="dxa"/><w:right w:w="400" w:type="dxa"/></w:tcMar>"#
3752            ),
3753            "{xml}"
3754        );
3755    }
3756
3757    #[test]
3758    fn writes_a_cell_text_direction() {
3759        use crate::model::{Table, TableCell, TableRow, TextDirection};
3760
3761        let document =
3762            Document::new().with_table(Table::new().with_row(TableRow::new().with_cell(
3763                TableCell::with_text("Rotated").with_text_direction(TextDirection::TopToBottom),
3764            )));
3765
3766        let xml = document_xml(&document);
3767
3768        assert!(
3769            xml.contains(r#"<w:tcPr><w:textDirection w:val="tbRl"/></w:tcPr>"#),
3770            "{xml}"
3771        );
3772    }
3773
3774    #[test]
3775    fn writes_tcpr_children_in_ct_tcpr_sequence_order() {
3776        use crate::model::{
3777            CellVerticalAlign, Table, TableCell, TableRow, TextDirection, VerticalMerge,
3778        };
3779
3780        // gridSpan, vMerge, tcBorders, shd, tcMar, textDirection, vAlign — every optional `w:tcPr`
3781        // child this crate models, all set at once, to pin down the full write order against
3782        // `CT_TcPr`'s own sequence in one regression test.
3783        let document = Document::new().with_table(
3784            Table::new().with_row(
3785                TableRow::new().with_cell(
3786                    TableCell::with_text("Everything")
3787                        .with_horizontal_span(2)
3788                        .with_vertical_merge(VerticalMerge::Restart)
3789                        .with_border(true)
3790                        .with_shading_color("00FF00")
3791                        .with_margins_twips(10, 20, 30, 40)
3792                        .with_text_direction(TextDirection::BottomToTop)
3793                        .with_vertical_alignment(CellVerticalAlign::Bottom),
3794                ),
3795            ),
3796        );
3797
3798        let xml = document_xml(&document);
3799
3800        let grid_span = xml.find("<w:gridSpan").expect("missing w:gridSpan");
3801        let v_merge = xml.find("<w:vMerge").expect("missing w:vMerge");
3802        let tc_borders = xml.find("<w:tcBorders>").expect("missing w:tcBorders");
3803        let shd = xml.find("<w:shd").expect("missing w:shd");
3804        let tc_mar = xml.find("<w:tcMar>").expect("missing w:tcMar");
3805        let text_direction = xml
3806            .find("<w:textDirection")
3807            .expect("missing w:textDirection");
3808        let v_align = xml.find("<w:vAlign").expect("missing w:vAlign");
3809        assert!(
3810            grid_span < v_merge
3811                && v_merge < tc_borders
3812                && tc_borders < shd
3813                && shd < tc_mar
3814                && tc_mar < text_direction
3815                && text_direction < v_align,
3816            "{xml}"
3817        );
3818    }
3819
3820    #[test]
3821    fn writes_a_repeating_header_row() {
3822        use crate::model::{Table, TableCell, TableRow};
3823
3824        let document = Document::new().with_table(
3825            Table::new().with_row(
3826                TableRow::new()
3827                    .with_cell(TableCell::with_text("Header"))
3828                    .with_repeat_as_header_row(true),
3829            ),
3830        );
3831
3832        let xml = document_xml(&document);
3833
3834        assert!(
3835            xml.contains("<w:tr><w:trPr><w:tblHeader/></w:trPr>"),
3836            "{xml}"
3837        );
3838    }
3839
3840    #[test]
3841    fn writes_a_row_height_with_at_least_rule() {
3842        use crate::model::{Table, TableCell, TableRow};
3843
3844        let document = Document::new().with_table(
3845            Table::new().with_row(
3846                TableRow::new()
3847                    .with_cell(TableCell::with_text("Tall"))
3848                    .with_height_twips(800),
3849            ),
3850        );
3851
3852        let xml = document_xml(&document);
3853
3854        assert!(
3855            xml.contains(r#"<w:trHeight w:val="800" w:hRule="atLeast"/>"#),
3856            "{xml}"
3857        );
3858    }
3859
3860    #[test]
3861    fn writes_cant_split_before_tr_height_before_tbl_header() {
3862        use crate::model::{Table, TableCell, TableRow};
3863
3864        let document = Document::new().with_table(
3865            Table::new().with_row(
3866                TableRow::new()
3867                    .with_cell(TableCell::with_text("Row"))
3868                    .with_cant_split(true)
3869                    .with_height_twips(500)
3870                    .with_repeat_as_header_row(true),
3871            ),
3872        );
3873
3874        let xml = document_xml(&document);
3875
3876        assert!(
3877            xml.contains(r#"<w:trPr><w:cantSplit/><w:trHeight w:val="500" w:hRule="atLeast"/><w:tblHeader/></w:trPr>"#),
3878            "{xml}"
3879        );
3880    }
3881
3882    #[test]
3883    fn a_row_with_no_special_properties_omits_trpr() {
3884        use crate::model::{Table, TableCell, TableRow};
3885
3886        let document = Document::new().with_table(
3887            Table::new().with_row(TableRow::new().with_cell(TableCell::with_text("Row"))),
3888        );
3889
3890        let xml = document_xml(&document);
3891
3892        assert!(!xml.contains("w:trPr"), "{xml}");
3893    }
3894
3895    #[test]
3896    fn writes_an_inline_image_and_registers_it_starting_after_settings() {
3897        use crate::model::{Image, ImageFormat, Run};
3898
3899        let document = Document::new().with_paragraph(
3900            Paragraph::new().with_run(Run::with_image(
3901                Image::new(vec![0u8, 1, 2, 3], ImageFormat::Png, 914_400, 457_200)
3902                    .with_description("a test image"),
3903            )),
3904        );
3905
3906        let mut state = PackageState::new();
3907        let mut relationships = PartRelationships::new();
3908        // rId1 is reserved for the settings relationship in real usage (see `write_to`); reserve it
3909        // here too so the assertions below match what a real write actually produces.
3910        relationships.add(SETTINGS_RELATIONSHIP_TYPE, SETTINGS_ENTRY_NAME);
3911
3912        let xml = document
3913            .to_document_xml(
3914                &mut state,
3915                &mut relationships,
3916                SectionReferenceIds::default(),
3917            )
3918            .unwrap();
3919        let xml = String::from_utf8(xml).unwrap();
3920
3921        assert_eq!(state.images.media_parts.len(), 1);
3922        assert_eq!(state.images.media_parts[0].entry_name, "media/image1.png");
3923        assert_eq!(state.images.media_parts[0].content_type, "image/png");
3924        assert_eq!(state.images.media_parts[0].data, vec![0u8, 1, 2, 3]);
3925
3926        let relationship = relationships
3927            .relationships
3928            .by_id("rId2")
3929            .expect("missing image relationship");
3930        assert_eq!(relationship.target, "media/image1.png");
3931
3932        assert!(
3933            xml.contains(r#"<wp:extent cx="914400" cy="457200"/>"#),
3934            "{xml}"
3935        );
3936        assert!(xml.contains(r#"descr="a test image""#), "{xml}");
3937        assert!(xml.contains(r#"<a:blip r:embed="rId2"/>"#), "{xml}");
3938        // No `<w:t>` at all: this run is an image, not text.
3939        assert!(!xml.contains("<w:t>"), "{xml}");
3940    }
3941
3942    #[test]
3943    fn writes_header_and_footer_references_before_pgsz() {
3944        let document = Document::new()
3945            .with_paragraph(Paragraph::with_text("Body"))
3946            .with_header(HeaderFooter::new().with_paragraph(Paragraph::with_text("Header text")))
3947            .with_footer(HeaderFooter::new().with_paragraph(Paragraph::with_text("Footer text")));
3948
3949        let mut state = PackageState::new();
3950        let mut relationships = PartRelationships::new();
3951        relationships.add(SETTINGS_RELATIONSHIP_TYPE, SETTINGS_ENTRY_NAME); // rId1
3952        let header_id = relationships.add(HEADER_RELATIONSHIP_TYPE, HEADER_ENTRY_NAME); // rId2
3953        let footer_id = relationships.add(FOOTER_RELATIONSHIP_TYPE, FOOTER_ENTRY_NAME); // rId3
3954
3955        let xml = document
3956            .to_document_xml(
3957                &mut state,
3958                &mut relationships,
3959                SectionReferenceIds {
3960                    header: Some(&header_id),
3961                    footer: Some(&footer_id),
3962                    ..SectionReferenceIds::default()
3963                },
3964            )
3965            .unwrap();
3966        let xml = String::from_utf8(xml).unwrap();
3967
3968        let header_reference = xml.find(r#"<w:headerReference w:type="default" r:id="rId2"/>"#);
3969        let footer_reference = xml.find(r#"<w:footerReference w:type="default" r:id="rId3"/>"#);
3970        let sect_pr = xml.find("<w:sectPr>").expect("missing w:sectPr");
3971        let pg_sz = xml.find("<w:pgSz").expect("missing w:pgSz");
3972
3973        assert!(header_reference.is_some(), "{xml}");
3974        assert!(footer_reference.is_some(), "{xml}");
3975        // EG_HdrFtrReferences must precede EG_SectPrContents (pgSz/pgMar) in CT_SectPr's strict
3976        // sequence.
3977        assert!(
3978            sect_pr < header_reference.unwrap() && header_reference.unwrap() < pg_sz,
3979            "{xml}"
3980        );
3981        assert!(footer_reference.unwrap() < pg_sz, "{xml}");
3982    }
3983
3984    #[test]
3985    fn writes_header_and_footer_content_as_hdr_and_ftr_parts() {
3986        let header = HeaderFooter::new().with_paragraph(Paragraph::with_text("Header text"));
3987        let footer = HeaderFooter::new().with_paragraph(Paragraph::with_text("Footer text"));
3988
3989        let mut state = PackageState::new();
3990        let mut relationships = PartRelationships::new();
3991
3992        let header_xml =
3993            to_header_footer_xml("w:hdr", &header.blocks, &mut state, &mut relationships).unwrap();
3994        let header_xml = String::from_utf8(header_xml).unwrap();
3995        assert!(header_xml.starts_with("<?xml"), "{header_xml}");
3996        assert!(header_xml.contains("<w:hdr "), "{header_xml}");
3997        assert!(
3998            header_xml.contains("<w:t>Header text</w:t>"),
3999            "{header_xml}"
4000        );
4001        assert!(!header_xml.contains("w:body"), "{header_xml}");
4002        assert!(!header_xml.contains("w:sectPr"), "{header_xml}");
4003
4004        let footer_xml =
4005            to_header_footer_xml("w:ftr", &footer.blocks, &mut state, &mut relationships).unwrap();
4006        let footer_xml = String::from_utf8(footer_xml).unwrap();
4007        assert!(footer_xml.contains("<w:ftr "), "{footer_xml}");
4008        assert!(
4009            footer_xml.contains("<w:t>Footer text</w:t>"),
4010            "{footer_xml}"
4011        );
4012    }
4013
4014    #[test]
4015    fn write_to_produces_header1_and_footer1_parts_with_relationships() {
4016        use std::io::Cursor;
4017
4018        let document = Document::new()
4019            .with_paragraph(Paragraph::with_text("Body"))
4020            .with_header(HeaderFooter::new().with_paragraph(Paragraph::with_text("Header text")))
4021            .with_footer(HeaderFooter::new().with_paragraph(Paragraph::with_text("Footer text")));
4022
4023        let buffer = document.write_to(Cursor::new(Vec::new())).unwrap();
4024        let package = Package::read_from(buffer).unwrap();
4025
4026        let document_part = package
4027            .part(MAIN_DOCUMENT_PART_NAME)
4028            .expect("missing document.xml");
4029        let header_relationship = document_part
4030            .relationships
4031            .iter()
4032            .find(|relationship| relationship.rel_type == HEADER_RELATIONSHIP_TYPE)
4033            .expect("document.xml has no header relationship");
4034        let footer_relationship = document_part
4035            .relationships
4036            .iter()
4037            .find(|relationship| relationship.rel_type == FOOTER_RELATIONSHIP_TYPE)
4038            .expect("document.xml has no footer relationship");
4039        assert_eq!(header_relationship.target, HEADER_ENTRY_NAME);
4040        assert_eq!(footer_relationship.target, FOOTER_ENTRY_NAME);
4041
4042        let header_part = package.part(HEADER_PART_NAME).expect("missing header1.xml");
4043        let header_xml = std::str::from_utf8(&header_part.data).unwrap();
4044        assert!(
4045            header_xml.contains("<w:t>Header text</w:t>"),
4046            "{header_xml}"
4047        );
4048
4049        let footer_part = package.part(FOOTER_PART_NAME).expect("missing footer1.xml");
4050        let footer_xml = std::str::from_utf8(&footer_part.data).unwrap();
4051        assert!(
4052            footer_xml.contains("<w:t>Footer text</w:t>"),
4053            "{footer_xml}"
4054        );
4055
4056        let document_xml = std::str::from_utf8(&document_part.data).unwrap();
4057        assert!(
4058            document_xml.contains(&format!(
4059                r#"w:type="default" r:id="{}""#,
4060                header_relationship.id
4061            )),
4062            "{document_xml}"
4063        );
4064    }
4065
4066    #[test]
4067    fn writes_a_page_number_field_as_fldsimple_not_a_run() {
4068        use crate::model::Run;
4069
4070        let document = Document::new().with_paragraph(
4071            Paragraph::new()
4072                .with_run(Run::new("Page "))
4073                .with_run(Run::with_field(Field::PageNumber).with_bold(true)),
4074        );
4075
4076        let xml = document_xml(&document);
4077
4078        assert!(xml.contains(r#"<w:fldSimple w:instr="PAGE">"#), "{xml}");
4079        // The field's own formatting (bold) is on the run nested inside `fldSimple`, not on
4080        // `fldSimple` itself.
4081        assert!(xml.contains(r#"<w:fldSimple w:instr="PAGE"><w:r><w:rPr><w:b/></w:rPr><w:t>1</w:t></w:r></w:fldSimple>"#), "{xml}");
4082        // fldSimple is a sibling of w:r, not wrapped in its own outer w:r.
4083        assert!(!xml.contains("<w:r><w:fldSimple"), "{xml}");
4084    }
4085
4086    #[test]
4087    fn settings_xml_never_sets_update_fields_even_with_a_field_present() {
4088        // Regression test: an earlier version forced `w:updateFields` in `word/settings.xml`
4089        // whenever the document contained a field, to ensure page numbers displayed correctly on
4090        // open. Word treats that setting as "this document has links to other files", showing a
4091        // confusing "update links?" prompt on open even though nothing here links anywhere.
4092        // `PAGE`/`NUMPAGES` don't actually need it (Word's layout engine keeps them live on its
4093        // own), so `to_settings_xml` must never write it.
4094        use crate::model::Run;
4095
4096        let document = Document::new()
4097            .with_paragraph(Paragraph::new().with_run(Run::with_field(Field::TotalPages)));
4098        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4099        let package = Package::read_from(buffer).unwrap();
4100
4101        let settings_part = package
4102            .part(SETTINGS_PART_NAME)
4103            .expect("missing word/settings.xml");
4104        let settings_xml = std::str::from_utf8(&settings_part.data).unwrap();
4105        assert!(!settings_xml.contains("updateFields"), "{settings_xml}");
4106    }
4107
4108    #[test]
4109    fn writes_styles_xml_with_name_based_on_ppr_then_rpr_in_order() {
4110        let heading = Style::new("Heading1", "Heading 1", StyleKind::Paragraph)
4111            .with_bold(true)
4112            .with_alignment(Alignment::Center);
4113        // A character style's alignment is documented as ignored when writing — set here
4114        // deliberately to prove the writer actually drops it (asserted below via an exact-match
4115        // `contains` with no `w:pPr` in between `w:basedOn` and `w:rPr`), not just that nothing
4116        // crashes.
4117        let emphasis = Style::new("Strong", "Strong", StyleKind::Character)
4118            .with_based_on("DefaultParagraphFont")
4119            .with_bold(true)
4120            .with_alignment(Alignment::Center);
4121
4122        let xml = String::from_utf8(to_styles_xml(&[heading, emphasis]).unwrap()).unwrap();
4123
4124        assert!(xml.starts_with("<?xml"), "{xml}");
4125        assert!(
4126            xml.contains(r#"<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="Heading 1"/><w:pPr><w:jc w:val="center"/></w:pPr><w:rPr><w:b/></w:rPr></w:style>"#),
4127            "{xml}"
4128        );
4129        assert!(
4130            xml.contains(
4131                r#"<w:style w:type="character" w:styleId="Strong"><w:name w:val="Strong"/><w:basedOn w:val="DefaultParagraphFont"/><w:rPr><w:b/></w:rPr></w:style>"#
4132            ),
4133            "{xml}"
4134        );
4135    }
4136
4137    #[test]
4138    fn writes_a_paragraph_style_with_spacing_shading_and_border() {
4139        let heading = Style::new("Heading1", "Heading 1", StyleKind::Paragraph)
4140            .with_line_spacing(360)
4141            .with_space_before(240)
4142            .with_space_after(120)
4143            .with_paragraph_shading_color("D9D9D9")
4144            .with_paragraph_border(true);
4145
4146        let xml = String::from_utf8(to_styles_xml(&[heading]).unwrap()).unwrap();
4147
4148        assert!(
4149            xml.contains(
4150                r#"<w:pPr><w:pBdr><w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/></w:pBdr><w:shd w:val="clear" w:color="auto" w:fill="D9D9D9"/><w:spacing w:before="240" w:after="120" w:line="360" w:lineRule="auto"/></w:pPr>"#
4151            ),
4152            "{xml}"
4153        );
4154    }
4155
4156    #[test]
4157    fn writes_a_paragraph_style_with_keep_settings_tabs_and_contextual_spacing() {
4158        use crate::model::TabStopAlignment;
4159
4160        let list_paragraph = Style::new("ListParagraph", "List Paragraph", StyleKind::Paragraph)
4161            .with_keep_with_next(true)
4162            .with_keep_lines_together(true)
4163            .with_page_break_before(true)
4164            .with_tab(TabStop::new(720).with_alignment(TabStopAlignment::Left))
4165            .with_contextual_spacing(true);
4166
4167        let xml = String::from_utf8(to_styles_xml(&[list_paragraph]).unwrap()).unwrap();
4168
4169        // Same `CT_PPrBase` order as `write_paragraph`: keepNext/keepLines/ pageBreakBefore first,
4170        // tabs later, contextualSpacing right after spacing (there is none here, so right after
4171        // tabs).
4172        assert!(
4173            xml.contains(
4174                r#"<w:pPr><w:keepNext/><w:keepLines/><w:pageBreakBefore/><w:tabs><w:tab w:val="left" w:pos="720"/></w:tabs><w:contextualSpacing/></w:pPr>"#
4175            ),
4176            "{xml}"
4177        );
4178    }
4179
4180    #[test]
4181    fn a_character_style_ignores_keep_settings_tabs_and_contextual_spacing() {
4182        let emphasis = Style::new("Strong", "Strong", StyleKind::Character)
4183            .with_bold(true)
4184            .with_keep_with_next(true)
4185            .with_tab(TabStop::new(720))
4186            .with_contextual_spacing(true);
4187
4188        let xml = String::from_utf8(to_styles_xml(&[emphasis]).unwrap()).unwrap();
4189
4190        assert!(
4191            xml.contains(r#"<w:style w:type="character" w:styleId="Strong"><w:name w:val="Strong"/><w:rPr><w:b/></w:rPr></w:style>"#),
4192            "{xml}"
4193        );
4194    }
4195
4196    #[test]
4197    fn a_character_style_ignores_paragraph_level_spacing_shading_and_border() {
4198        let emphasis = Style::new("Strong", "Strong", StyleKind::Character)
4199            .with_bold(true)
4200            .with_line_spacing(360)
4201            .with_paragraph_shading_color("D9D9D9")
4202            .with_paragraph_border(true);
4203
4204        let xml = String::from_utf8(to_styles_xml(&[emphasis]).unwrap()).unwrap();
4205
4206        assert!(
4207            xml.contains(r#"<w:style w:type="character" w:styleId="Strong"><w:name w:val="Strong"/><w:rPr><w:b/></w:rPr></w:style>"#),
4208            "{xml}"
4209        );
4210    }
4211
4212    #[test]
4213    fn writes_a_style_with_color_size_and_font_family() {
4214        let heading = Style::new("Heading1", "Heading 1", StyleKind::Paragraph)
4215            .with_color("2E74B5")
4216            .with_font_size(16)
4217            .with_font_family("Cambria");
4218
4219        let xml = String::from_utf8(to_styles_xml(&[heading]).unwrap()).unwrap();
4220
4221        assert!(
4222            xml.contains(
4223                r#"<w:rPr><w:rFonts w:ascii="Cambria" w:hAnsi="Cambria"/><w:color w:val="2E74B5"/><w:sz w:val="32"/><w:szCs w:val="32"/></w:rPr>"#
4224            ),
4225            "{xml}"
4226        );
4227    }
4228
4229    #[test]
4230    fn writes_a_style_with_a_highlight() {
4231        use crate::model::Highlight;
4232
4233        let heading = Style::new("Heading1", "Heading 1", StyleKind::Paragraph)
4234            .with_highlight(Highlight::LightGray);
4235
4236        let xml = String::from_utf8(to_styles_xml(&[heading]).unwrap()).unwrap();
4237
4238        assert!(
4239            xml.contains(r#"<w:rPr><w:highlight w:val="lightGray"/></w:rPr>"#),
4240            "{xml}"
4241        );
4242    }
4243
4244    #[test]
4245    fn writes_a_style_with_strike_and_subscript() {
4246        use crate::model::VerticalAlign;
4247
4248        let footnote_ref = Style::new(
4249            "FootnoteReference",
4250            "footnote reference",
4251            StyleKind::Character,
4252        )
4253        .with_strike(true)
4254        .with_vertical_align(VerticalAlign::Subscript);
4255
4256        let xml = String::from_utf8(to_styles_xml(&[footnote_ref]).unwrap()).unwrap();
4257
4258        assert!(
4259            xml.contains(r#"<w:rPr><w:strike/><w:vertAlign w:val="subscript"/></w:rPr>"#),
4260            "{xml}"
4261        );
4262    }
4263
4264    #[test]
4265    fn writes_a_style_with_a_double_underline_and_color() {
4266        use crate::model::UnderlineStyle;
4267
4268        let heading = Style::new("Heading1", "Heading 1", StyleKind::Paragraph)
4269            .with_underline(UnderlineStyle::Double)
4270            .with_underline_color("2E74B5");
4271
4272        let xml = String::from_utf8(to_styles_xml(&[heading]).unwrap()).unwrap();
4273
4274        assert!(
4275            xml.contains(r#"<w:rPr><w:u w:val="double" w:color="2E74B5"/></w:rPr>"#),
4276            "{xml}"
4277        );
4278    }
4279
4280    #[test]
4281    fn writes_a_style_with_small_caps_shading_border_spacing_and_hidden() {
4282        let heading = Style::new("Redacted", "Redacted", StyleKind::Character)
4283            .with_small_caps(true)
4284            .with_shading_color("000000")
4285            .with_text_border(true)
4286            .with_character_spacing(10)
4287            .with_vertical_position(-2)
4288            .with_hidden(true);
4289
4290        let xml = String::from_utf8(to_styles_xml(&[heading]).unwrap()).unwrap();
4291
4292        assert!(
4293            xml.contains(
4294                r#"<w:rPr><w:smallCaps/><w:shd w:val="clear" w:color="auto" w:fill="000000"/><w:bdr w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:spacing w:val="10"/><w:position w:val="-2"/><w:vanish/></w:rPr>"#
4295            ),
4296            "{xml}"
4297        );
4298    }
4299
4300    #[test]
4301    fn a_document_with_no_styles_does_not_write_a_styles_part() {
4302        let document = Document::new().with_paragraph(Paragraph::with_text("No styles here"));
4303
4304        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4305        let package = Package::read_from(buffer).unwrap();
4306        assert!(
4307            package.part(STYLES_PART_NAME).is_none(),
4308            "word/styles.xml should not be written"
4309        );
4310    }
4311
4312    #[test]
4313    fn writes_pstyle_before_jc_and_rstyle_in_rpr() {
4314        let document = Document::new().with_paragraph(
4315            Paragraph::new()
4316                .with_style_id("Heading1")
4317                .with_alignment(Alignment::Center)
4318                .with_run(Run::new("Title").with_style_id("Strong")),
4319        );
4320
4321        let xml = document_xml(&document);
4322
4323        assert!(
4324            xml.contains(r#"<w:pPr><w:pStyle w:val="Heading1"/><w:jc w:val="center"/></w:pPr>"#),
4325            "{xml}"
4326        );
4327        assert!(
4328            xml.contains(r#"<w:rPr><w:rStyle w:val="Strong"/></w:rPr><w:t>Title</w:t>"#),
4329            "{xml}"
4330        );
4331    }
4332
4333    #[test]
4334    fn write_to_declares_a_styles_relationship_when_styles_are_present() {
4335        use std::io::Cursor;
4336
4337        let document = Document::new()
4338            .with_paragraph(Paragraph::with_text("Body"))
4339            .with_style(Style::new("Heading1", "Heading 1", StyleKind::Paragraph));
4340
4341        let buffer = document.write_to(Cursor::new(Vec::new())).unwrap();
4342        let package = Package::read_from(buffer).unwrap();
4343
4344        let document_part = package
4345            .part(MAIN_DOCUMENT_PART_NAME)
4346            .expect("missing document.xml");
4347        let styles_relationship = document_part
4348            .relationships
4349            .iter()
4350            .find(|relationship| relationship.rel_type == STYLES_RELATIONSHIP_TYPE)
4351            .expect("document.xml has no styles relationship");
4352        assert_eq!(styles_relationship.target, STYLES_ENTRY_NAME);
4353
4354        let styles_part = package
4355            .part(STYLES_PART_NAME)
4356            .expect("missing word/styles.xml");
4357        let styles_xml = std::str::from_utf8(&styles_part.data).unwrap();
4358        assert!(
4359            styles_xml.contains(r#"w:styleId="Heading1""#),
4360            "{styles_xml}"
4361        );
4362    }
4363
4364    #[test]
4365    fn writes_an_external_hyperlink_wrapping_a_run_not_wrapped_in_it() {
4366        let document = Document::new().with_paragraph(
4367            Paragraph::new().with_run(
4368                Run::new("Rust")
4369                    .with_hyperlink(Hyperlink::External("https://www.rust-lang.org".to_string())),
4370            ),
4371        );
4372
4373        let mut state = PackageState::new();
4374        let mut relationships = PartRelationships::new();
4375        relationships.add(SETTINGS_RELATIONSHIP_TYPE, SETTINGS_ENTRY_NAME); // rId1
4376
4377        let xml = document
4378            .to_document_xml(
4379                &mut state,
4380                &mut relationships,
4381                SectionReferenceIds::default(),
4382            )
4383            .unwrap();
4384        let xml = String::from_utf8(xml).unwrap();
4385
4386        // `w:hyperlink` wraps `w:r`, not the other way around.
4387        assert!(
4388            xml.contains(r#"<w:hyperlink r:id="rId2"><w:r><w:t>Rust</w:t></w:r></w:hyperlink>"#),
4389            "{xml}"
4390        );
4391        assert!(!xml.contains("<w:r><w:hyperlink"), "{xml}");
4392
4393        let relationship = relationships
4394            .relationships
4395            .by_id("rId2")
4396            .expect("missing hyperlink relationship");
4397        assert_eq!(relationship.rel_type, HYPERLINK_RELATIONSHIP_TYPE);
4398        assert_eq!(relationship.target, "https://www.rust-lang.org");
4399        assert_eq!(relationship.target_mode, TargetMode::External);
4400    }
4401
4402    #[test]
4403    fn writes_an_internal_hyperlink_as_an_anchor_with_no_relationship() {
4404        let document = Document::new().with_paragraph(Paragraph::new().with_run(
4405            Run::new("See above").with_hyperlink(Hyperlink::Internal("Section1".to_string())),
4406        ));
4407
4408        let xml = document_xml(&document);
4409
4410        assert!(
4411            xml.contains(
4412                r#"<w:hyperlink w:anchor="Section1"><w:r><w:t>See above</w:t></w:r></w:hyperlink>"#
4413            ),
4414            "{xml}"
4415        );
4416        assert!(!xml.contains("r:id"), "{xml}");
4417    }
4418
4419    #[test]
4420    fn two_runs_with_the_same_url_each_get_their_own_relationship() {
4421        // Each hyperlink run creates a fresh external relationship rather than reusing one for a
4422        // repeated URL — no deduplication.
4423        let url = "https://example.com";
4424        let document = Document::new().with_paragraph(
4425            Paragraph::new()
4426                .with_run(Run::new("first").with_hyperlink(Hyperlink::External(url.to_string())))
4427                .with_run(Run::new("second").with_hyperlink(Hyperlink::External(url.to_string()))),
4428        );
4429
4430        let mut state = PackageState::new();
4431        let mut relationships = PartRelationships::new();
4432        let xml = document
4433            .to_document_xml(
4434                &mut state,
4435                &mut relationships,
4436                SectionReferenceIds::default(),
4437            )
4438            .unwrap();
4439        let xml = String::from_utf8(xml).unwrap();
4440
4441        let hyperlink_relationships: Vec<_> = relationships
4442            .relationships
4443            .iter()
4444            .filter(|relationship| relationship.rel_type == HYPERLINK_RELATIONSHIP_TYPE)
4445            .collect();
4446        assert_eq!(hyperlink_relationships.len(), 2, "{xml}");
4447        assert_ne!(hyperlink_relationships[0].id, hyperlink_relationships[1].id);
4448    }
4449
4450    #[test]
4451    fn a_hyperlinked_run_still_carries_its_own_formatting() {
4452        let document = Document::new().with_paragraph(
4453            Paragraph::new().with_run(
4454                Run::new("Bold link")
4455                    .with_bold(true)
4456                    .with_hyperlink(Hyperlink::External("https://example.com".to_string())),
4457            ),
4458        );
4459
4460        let xml = document_xml(&document);
4461
4462        assert!(
4463            xml.contains(r#"<w:hyperlink r:id="rId1"><w:r><w:rPr><w:b/></w:rPr><w:t>Bold link</w:t></w:r></w:hyperlink>"#),
4464            "{xml}"
4465        );
4466    }
4467
4468    #[test]
4469    fn writes_numbering_xml_with_one_abstractnum_and_num_per_definition() {
4470        use crate::model::{ListLevel, NumberFormat};
4471
4472        let bullet = NumberingDefinition::new(
4473            1,
4474            vec![ListLevel::new(NumberFormat::Bullet, "\u{2022}", 720, 360)],
4475        );
4476        let decimal = NumberingDefinition::new(
4477            5,
4478            vec![ListLevel::new(NumberFormat::Decimal, "%1.", 360, 360)],
4479        );
4480
4481        let xml = String::from_utf8(to_numbering_xml(&[bullet, decimal]).unwrap()).unwrap();
4482
4483        assert!(xml.starts_with("<?xml"), "{xml}");
4484        // Every `w:abstractNum` before every `w:num` (`CT_Numbering`'s own sequence), and `w:num`
4485        // links back via the SAME id (this crate's simplification — see `NumberingDefinition`'s doc
4486        // comment).
4487        let last_abstract_num = xml.rfind("<w:abstractNum ").expect("missing w:abstractNum");
4488        let first_num = xml.find("<w:num ").expect("missing w:num");
4489        assert!(last_abstract_num < first_num, "{xml}");
4490
4491        assert!(
4492            xml.contains(
4493                r#"<w:abstractNum w:abstractNumId="1"><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val="•"/><w:lvlJc w:val="left"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum>"#
4494            ),
4495            "{xml}"
4496        );
4497        assert!(
4498            xml.contains(r#"<w:num w:numId="1"><w:abstractNumId w:val="1"/></w:num>"#),
4499            "{xml}"
4500        );
4501        assert!(
4502            xml.contains(r#"<w:num w:numId="5"><w:abstractNumId w:val="5"/></w:num>"#),
4503            "{xml}"
4504        );
4505    }
4506
4507    #[test]
4508    fn writes_pstyle_before_numpr_before_jc_in_ppr() {
4509        let document = Document::new().with_paragraph(
4510            Paragraph::new()
4511                .with_style_id("ListParagraph")
4512                .with_numbering(1, 2)
4513                .with_alignment(Alignment::Center)
4514                .with_run(Run::new("Item")),
4515        );
4516
4517        let xml = document_xml(&document);
4518
4519        assert!(
4520            xml.contains(
4521                r#"<w:pPr><w:pStyle w:val="ListParagraph"/><w:numPr><w:ilvl w:val="2"/><w:numId w:val="1"/></w:numPr><w:jc w:val="center"/></w:pPr>"#
4522            ),
4523            "{xml}"
4524        );
4525    }
4526
4527    #[test]
4528    fn a_document_with_no_numbering_definitions_does_not_write_a_numbering_part() {
4529        let document = Document::new().with_paragraph(Paragraph::with_text("No lists here"));
4530
4531        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4532        let package = Package::read_from(buffer).unwrap();
4533        assert!(
4534            package.part(NUMBERING_PART_NAME).is_none(),
4535            "word/numbering.xml should not be written"
4536        );
4537    }
4538
4539    #[test]
4540    fn write_to_declares_a_numbering_relationship_when_definitions_are_present() {
4541        use std::io::Cursor;
4542
4543        let document = Document::new()
4544            .with_paragraph(Paragraph::with_text("Item one").with_numbering(1, 0))
4545            .with_numbering_definition(NumberingDefinition::bullet(1));
4546
4547        let buffer = document.write_to(Cursor::new(Vec::new())).unwrap();
4548        let package = Package::read_from(buffer).unwrap();
4549
4550        let document_part = package
4551            .part(MAIN_DOCUMENT_PART_NAME)
4552            .expect("missing document.xml");
4553        let numbering_relationship = document_part
4554            .relationships
4555            .iter()
4556            .find(|relationship| relationship.rel_type == NUMBERING_RELATIONSHIP_TYPE)
4557            .expect("document.xml has no numbering relationship");
4558        assert_eq!(numbering_relationship.target, NUMBERING_ENTRY_NAME);
4559
4560        let numbering_part = package
4561            .part(NUMBERING_PART_NAME)
4562            .expect("missing word/numbering.xml");
4563        let numbering_xml = std::str::from_utf8(&numbering_part.data).unwrap();
4564        assert!(numbering_xml.contains(r#"w:numId="1""#), "{numbering_xml}");
4565
4566        let document_xml = std::str::from_utf8(&document_part.data).unwrap();
4567        assert!(
4568            document_xml.contains(r#"<w:numId w:val="1"/>"#),
4569            "{document_xml}"
4570        );
4571    }
4572
4573    #[test]
4574    fn bullet_and_decimal_convenience_definitions_have_three_levels() {
4575        let bullet = NumberingDefinition::bullet(1);
4576        let decimal = NumberingDefinition::decimal(2);
4577
4578        assert_eq!(bullet.levels.len(), 3);
4579        assert_eq!(decimal.levels.len(), 3);
4580        assert_eq!(decimal.levels[1].text, "%1.%2.");
4581    }
4582
4583    #[test]
4584    fn writes_a_note_reference_run_with_its_id_and_reference_style() {
4585        let document = Document::new().with_paragraph(
4586            Paragraph::with_text("See the note.")
4587                .with_run(Run::with_note_reference(NoteReference::Footnote(1))),
4588        );
4589
4590        let xml = document_xml(&document);
4591
4592        assert!(
4593            xml.contains(
4594                r#"<w:r><w:rPr><w:rStyle w:val="FootnoteReference"/></w:rPr><w:footnoteReference w:id="1"/></w:r>"#
4595            ),
4596            "{xml}"
4597        );
4598    }
4599
4600    #[test]
4601    fn writes_an_endnote_reference_with_the_endnote_element_and_style() {
4602        let document = Document::new().with_paragraph(
4603            Paragraph::new().with_run(Run::with_note_reference(NoteReference::Endnote(3))),
4604        );
4605
4606        let xml = document_xml(&document);
4607
4608        assert!(
4609            xml.contains(
4610                r#"<w:rPr><w:rStyle w:val="EndnoteReference"/></w:rPr><w:endnoteReference w:id="3"/>"#
4611            ),
4612            "{xml}"
4613        );
4614    }
4615
4616    #[test]
4617    fn writes_footnotes_xml_with_one_footnote_element_per_note() {
4618        let note = Note::footnote_with_text(1, "This is a footnote.");
4619
4620        let xml = String::from_utf8(
4621            to_notes_xml(
4622                "w:footnotes",
4623                "w:footnote",
4624                &[note],
4625                &mut PackageState::new(),
4626                &mut PartRelationships::new(),
4627            )
4628            .unwrap(),
4629        )
4630        .unwrap();
4631
4632        assert!(xml.starts_with("<?xml"), "{xml}");
4633        assert!(xml.contains(r#"<w:footnote w:id="1">"#), "{xml}");
4634        // The marker (footnoteRef, styled "FootnoteReference"), then a plain space run, then the
4635        // plain text run — the same shape a real Word-authored footnote uses.
4636        assert!(
4637            xml.contains(
4638                r#"<w:r><w:rPr><w:rStyle w:val="FootnoteReference"/></w:rPr><w:footnoteRef/></w:r><w:r><w:t xml:space="preserve"> </w:t></w:r><w:r><w:t>This is a footnote.</w:t></w:r>"#
4639            ),
4640            "{xml}"
4641        );
4642    }
4643
4644    #[test]
4645    fn writes_an_endnote_marker_with_the_endnote_ref_element() {
4646        let note = Note::endnote_with_text(1, "This is an endnote.");
4647
4648        let xml = String::from_utf8(
4649            to_notes_xml(
4650                "w:endnotes",
4651                "w:endnote",
4652                &[note],
4653                &mut PackageState::new(),
4654                &mut PartRelationships::new(),
4655            )
4656            .unwrap(),
4657        )
4658        .unwrap();
4659
4660        assert!(xml.contains(r#"<w:endnote w:id="1">"#), "{xml}");
4661        assert!(xml.contains("<w:endnoteRef/>"), "{xml}");
4662    }
4663
4664    #[test]
4665    fn a_document_with_no_footnotes_or_endnotes_does_not_write_those_parts() {
4666        let document = Document::new().with_paragraph(Paragraph::with_text("No notes here"));
4667
4668        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4669        let package = Package::read_from(buffer).unwrap();
4670        assert!(
4671            package.part(FOOTNOTES_PART_NAME).is_none(),
4672            "word/footnotes.xml should not be written"
4673        );
4674        assert!(
4675            package.part(ENDNOTES_PART_NAME).is_none(),
4676            "word/endnotes.xml should not be written"
4677        );
4678    }
4679
4680    #[test]
4681    fn write_to_declares_footnote_and_endnote_relationships_when_notes_are_present() {
4682        use std::io::Cursor;
4683
4684        let document = Document::new()
4685            .with_paragraph(
4686                Paragraph::with_text("See the footnote")
4687                    .with_run(Run::with_note_reference(NoteReference::Footnote(1))),
4688            )
4689            .with_footnote(Note::footnote_with_text(1, "Footnote text."))
4690            .with_endnote(Note::endnote_with_text(1, "Endnote text."));
4691
4692        let buffer = document.write_to(Cursor::new(Vec::new())).unwrap();
4693        let package = Package::read_from(buffer).unwrap();
4694
4695        let document_part = package
4696            .part(MAIN_DOCUMENT_PART_NAME)
4697            .expect("missing document.xml");
4698        assert!(
4699            document_part
4700                .relationships
4701                .iter()
4702                .any(|relationship| relationship.rel_type == FOOTNOTES_RELATIONSHIP_TYPE),
4703            "document.xml has no footnotes relationship"
4704        );
4705        assert!(
4706            document_part
4707                .relationships
4708                .iter()
4709                .any(|relationship| relationship.rel_type == ENDNOTES_RELATIONSHIP_TYPE),
4710            "document.xml has no endnotes relationship"
4711        );
4712
4713        let footnotes_part = package
4714            .part(FOOTNOTES_PART_NAME)
4715            .expect("missing word/footnotes.xml");
4716        let footnotes_xml = std::str::from_utf8(&footnotes_part.data).unwrap();
4717        assert!(footnotes_xml.contains("Footnote text."), "{footnotes_xml}");
4718
4719        let endnotes_part = package
4720            .part(ENDNOTES_PART_NAME)
4721            .expect("missing word/endnotes.xml");
4722        let endnotes_xml = std::str::from_utf8(&endnotes_part.data).unwrap();
4723        assert!(endnotes_xml.contains("Endnote text."), "{endnotes_xml}");
4724    }
4725
4726    #[test]
4727    fn footnote_and_endnote_with_text_convenience_builds_marker_space_and_text() {
4728        let footnote = Note::footnote_with_text(7, "Some detail.");
4729
4730        assert_eq!(footnote.id, 7);
4731        assert_eq!(footnote.blocks.len(), 1);
4732        let paragraph = footnote.paragraphs().next().expect("missing paragraph");
4733        assert_eq!(paragraph.runs.len(), 3);
4734        assert_eq!(
4735            paragraph.runs[0].content,
4736            RunContent::NoteMarker(NoteKind::Footnote)
4737        );
4738        assert_eq!(paragraph.runs[1].text(), Some(" "));
4739        assert_eq!(paragraph.runs[2].text(), Some("Some detail."));
4740    }
4741
4742    #[test]
4743    fn a_run_marked_with_a_comment_id_gets_a_range_start_end_and_reference() {
4744        // Matches ECMA-376's own `commentRangeStart` example fragment exactly: text, then the range
4745        // start, the covered run, the range end, then the reference run — see this project's own
4746        // copy of that example in `model.rs`'s `Run.comment_ids` doc comment.
4747        let document = Document::new().with_paragraph(
4748            Paragraph::new()
4749                .with_run(Run::new("Some "))
4750                .with_run(Run::new("text.").with_comment(0)),
4751        );
4752
4753        let xml = document_xml(&document);
4754
4755        assert!(
4756            xml.contains(
4757                r#"<w:r><w:t xml:space="preserve">Some </w:t></w:r><w:commentRangeStart w:id="0"/><w:r><w:t>text.</w:t></w:r><w:commentRangeEnd w:id="0"/><w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="0"/></w:r>"#
4758            ),
4759            "{xml}"
4760        );
4761    }
4762
4763    #[test]
4764    fn consecutive_runs_sharing_a_comment_id_get_a_single_start_end_pair() {
4765        // Two runs both marked with comment id 0 must NOT produce two separate
4766        // `w:commentRangeStart`/`w:commentRangeEnd` pairs (which would break the
4767        // one-start/one-end-per-id pairing `CT_MarkupRange` relies on) — just one pair spanning
4768        // both runs, with a single reference after the second.
4769        let document = Document::new().with_paragraph(
4770            Paragraph::new()
4771                .with_run(Run::new("first ").with_comment(0))
4772                .with_run(Run::new("second").with_comment(0)),
4773        );
4774
4775        let xml = document_xml(&document);
4776
4777        assert_eq!(xml.matches("w:commentRangeStart").count(), 1, "{xml}");
4778        assert_eq!(xml.matches("w:commentRangeEnd").count(), 1, "{xml}");
4779        assert_eq!(xml.matches("w:commentReference").count(), 1, "{xml}");
4780        assert!(
4781            xml.contains(r#"<w:commentRangeStart w:id="0"/><w:r>"#),
4782            "{xml}"
4783        );
4784        assert!(
4785            xml.contains(r#"</w:r><w:commentRangeEnd w:id="0"/>"#),
4786            "{xml}"
4787        );
4788    }
4789
4790    #[test]
4791    fn overlapping_comment_ranges_each_get_their_own_start_end_pair() {
4792        // A run inside two comments at once (comment 0 covers all three runs, comment 1 only the
4793        // middle one) must open/close each id independently, nested correctly around the middle
4794        // run.
4795        let document = Document::new().with_paragraph(
4796            Paragraph::new()
4797                .with_run(Run::new("a").with_comment(0))
4798                .with_run(Run::new("b").with_comment(0).with_comment(1))
4799                .with_run(Run::new("c").with_comment(0)),
4800        );
4801
4802        let xml = document_xml(&document);
4803
4804        assert_eq!(xml.matches("w:commentRangeStart").count(), 2, "{xml}");
4805        assert_eq!(xml.matches("w:commentRangeEnd").count(), 2, "{xml}");
4806        // Comment 1 opens right before "b" and closes right after it, nested inside comment 0's
4807        // still-open range.
4808        assert!(
4809            xml.contains(r#"w:id="1"/><w:r><w:t>b</w:t></w:r><w:commentRangeEnd w:id="1"/>"#),
4810            "{xml}"
4811        );
4812    }
4813
4814    #[test]
4815    fn a_comment_range_left_open_at_the_paragraph_s_end_still_closes() {
4816        let document = Document::new()
4817            .with_paragraph(Paragraph::new().with_run(Run::new("last run").with_comment(0)));
4818
4819        let xml = document_xml(&document);
4820
4821        assert!(xml.contains(r#"<w:commentRangeEnd w:id="0"/>"#), "{xml}");
4822        assert!(xml.contains("<w:commentReference"), "{xml}");
4823    }
4824
4825    #[test]
4826    fn a_run_marked_with_a_bookmark_gets_a_start_and_end_pair() {
4827        use crate::model::Bookmark;
4828
4829        let document = Document::new().with_paragraph(
4830            Paragraph::new()
4831                .with_run(Run::new("Some "))
4832                .with_run(Run::new("text.").with_bookmark(Bookmark::new(0, "MyBookmark"))),
4833        );
4834
4835        let xml = document_xml(&document);
4836
4837        assert!(
4838            xml.contains(
4839                r#"<w:r><w:t xml:space="preserve">Some </w:t></w:r><w:bookmarkStart w:id="0" w:name="MyBookmark"/><w:r><w:t>text.</w:t></w:r><w:bookmarkEnd w:id="0"/>"#
4840            ),
4841            "{xml}"
4842        );
4843    }
4844
4845    #[test]
4846    fn consecutive_runs_sharing_a_bookmark_get_a_single_start_end_pair() {
4847        use crate::model::Bookmark;
4848
4849        let document = Document::new().with_paragraph(
4850            Paragraph::new()
4851                .with_run(Run::new("first ").with_bookmark(Bookmark::new(0, "Target")))
4852                .with_run(Run::new("second").with_bookmark(Bookmark::new(0, "Target"))),
4853        );
4854
4855        let xml = document_xml(&document);
4856
4857        assert_eq!(xml.matches("w:bookmarkStart").count(), 1, "{xml}");
4858        assert_eq!(xml.matches("w:bookmarkEnd").count(), 1, "{xml}");
4859        assert!(
4860            xml.contains(r#"<w:bookmarkStart w:id="0" w:name="Target"/><w:r>"#),
4861            "{xml}"
4862        );
4863        assert!(xml.contains(r#"</w:r><w:bookmarkEnd w:id="0"/>"#), "{xml}");
4864    }
4865
4866    #[test]
4867    fn overlapping_bookmarks_each_get_their_own_start_end_pair() {
4868        use crate::model::Bookmark;
4869
4870        let document = Document::new().with_paragraph(
4871            Paragraph::new()
4872                .with_run(Run::new("a").with_bookmark(Bookmark::new(0, "Outer")))
4873                .with_run(
4874                    Run::new("b")
4875                        .with_bookmark(Bookmark::new(0, "Outer"))
4876                        .with_bookmark(Bookmark::new(1, "Inner")),
4877                )
4878                .with_run(Run::new("c").with_bookmark(Bookmark::new(0, "Outer"))),
4879        );
4880
4881        let xml = document_xml(&document);
4882
4883        assert_eq!(xml.matches("w:bookmarkStart").count(), 2, "{xml}");
4884        assert_eq!(xml.matches("w:bookmarkEnd").count(), 2, "{xml}");
4885        assert!(
4886            xml.contains(
4887                r#"w:id="1" w:name="Inner"/><w:r><w:t>b</w:t></w:r><w:bookmarkEnd w:id="1"/>"#
4888            ),
4889            "{xml}"
4890        );
4891    }
4892
4893    #[test]
4894    fn a_bookmark_left_open_at_the_paragraph_s_end_still_closes() {
4895        use crate::model::Bookmark;
4896
4897        let document = Document::new().with_paragraph(
4898            Paragraph::new().with_run(Run::new("last run").with_bookmark(Bookmark::new(0, "End"))),
4899        );
4900
4901        let xml = document_xml(&document);
4902
4903        assert!(xml.contains(r#"<w:bookmarkEnd w:id="0"/>"#), "{xml}");
4904    }
4905
4906    #[test]
4907    fn an_internal_hyperlink_can_point_to_a_real_bookmark_written_elsewhere() {
4908        use crate::model::Bookmark;
4909
4910        let document = Document::new()
4911            .with_paragraph(Paragraph::new().with_run(
4912                Run::new("See below").with_hyperlink(Hyperlink::Internal("Target".to_string())),
4913            ))
4914            .with_paragraph(
4915                Paragraph::new()
4916                    .with_run(Run::new("Here").with_bookmark(Bookmark::new(0, "Target"))),
4917            );
4918
4919        let xml = document_xml(&document);
4920
4921        assert!(xml.contains(r#"<w:hyperlink w:anchor="Target">"#), "{xml}");
4922        assert!(
4923            xml.contains(r#"<w:bookmarkStart w:id="0" w:name="Target"/>"#),
4924            "{xml}"
4925        );
4926    }
4927
4928    #[test]
4929    fn writes_comments_xml_with_author_date_and_initials() {
4930        let comment = Comment::with_text(0, "Please rephrase.")
4931            .with_author("Morgan")
4932            .with_initials("MR")
4933            .with_date("2026-07-16T12:00:00Z");
4934
4935        let xml = String::from_utf8(
4936            to_comments_xml(
4937                &[comment],
4938                &mut PackageState::new(),
4939                &mut PartRelationships::new(),
4940            )
4941            .unwrap(),
4942        )
4943        .unwrap();
4944
4945        assert!(xml.starts_with("<?xml"), "{xml}");
4946        assert!(
4947            xml.contains(r#"<w:comment w:id="0" w:author="Morgan" w:date="2026-07-16T12:00:00Z" w:initials="MR">"#),
4948            "{xml}"
4949        );
4950        assert!(xml.contains("Please rephrase."), "{xml}");
4951    }
4952
4953    #[test]
4954    fn a_document_with_no_comments_does_not_write_that_part() {
4955        let document = Document::new().with_paragraph(Paragraph::with_text("No comments here"));
4956
4957        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
4958        let package = Package::read_from(buffer).unwrap();
4959        assert!(
4960            package.part(COMMENTS_PART_NAME).is_none(),
4961            "word/comments.xml should not be written"
4962        );
4963    }
4964
4965    #[test]
4966    fn write_to_declares_a_comments_relationship_when_comments_are_present() {
4967        use std::io::Cursor;
4968
4969        let document = Document::new()
4970            .with_paragraph(
4971                Paragraph::with_text("Reviewed text").with_run(Run::new("").with_comment(0)),
4972            )
4973            .with_comment(Comment::with_text(0, "A remark."));
4974
4975        let buffer = document.write_to(Cursor::new(Vec::new())).unwrap();
4976        let package = Package::read_from(buffer).unwrap();
4977
4978        let document_part = package
4979            .part(MAIN_DOCUMENT_PART_NAME)
4980            .expect("missing document.xml");
4981        assert!(
4982            document_part
4983                .relationships
4984                .iter()
4985                .any(|relationship| relationship.rel_type == COMMENTS_RELATIONSHIP_TYPE),
4986            "document.xml has no comments relationship"
4987        );
4988
4989        let comments_part = package
4990            .part(COMMENTS_PART_NAME)
4991            .expect("missing word/comments.xml");
4992        let comments_xml = std::str::from_utf8(&comments_part.data).unwrap();
4993        assert!(comments_xml.contains("A remark."), "{comments_xml}");
4994    }
4995
4996    #[test]
4997    fn writes_a_structured_document_tag_with_its_properties_and_content() {
4998        let document = Document::new().with_structured_document_tag(
4999            StructuredDocumentTag::new()
5000                .with_id(1)
5001                .with_tag("CustomerName")
5002                .with_alias("Customer Name")
5003                .with_paragraph(Paragraph::with_text("Acme Corp")),
5004        );
5005
5006        let xml = document_xml(&document);
5007
5008        assert!(
5009            xml.contains(
5010                r#"<w:sdt><w:sdtPr><w:alias w:val="Customer Name"/><w:tag w:val="CustomerName"/><w:id w:val="1"/></w:sdtPr><w:sdtContent><w:p><w:r><w:t>Acme Corp</w:t></w:r></w:p></w:sdtContent></w:sdt>"#
5011            ),
5012            "{xml}"
5013        );
5014    }
5015
5016    #[test]
5017    fn a_structured_document_tag_with_no_properties_omits_sdtpr() {
5018        let document = Document::new().with_structured_document_tag(
5019            StructuredDocumentTag::new().with_paragraph(Paragraph::with_text("Plain")),
5020        );
5021
5022        let xml = document_xml(&document);
5023
5024        assert!(!xml.contains("w:sdtPr"), "{xml}");
5025        assert!(
5026            xml.contains(r#"<w:sdt><w:sdtContent><w:p><w:r><w:t>Plain</w:t></w:r></w:p></w:sdtContent></w:sdt>"#),
5027            "{xml}"
5028        );
5029    }
5030
5031    #[test]
5032    fn a_nested_structured_document_tag_writes_correctly() {
5033        // `with_paragraph`/`with_table` don't offer a nesting convenience (see
5034        // `StructuredDocumentTag`'s doc comment on why creation support stays minimal here), but
5035        // `Block:: StructuredDocumentTag` is a plain enum variant, so nesting an inner control
5036        // inside an outer one's `blocks` is still directly possible, and schema-legal (`w:sdt` is
5037        // itself a valid child of `w:sdtContent`).
5038        let mut outer = StructuredDocumentTag::new().with_tag("outer");
5039        outer.blocks.push(Block::StructuredDocumentTag(
5040            StructuredDocumentTag::new()
5041                .with_tag("inner")
5042                .with_paragraph(Paragraph::with_text("nested")),
5043        ));
5044        let document = Document::new().with_structured_document_tag(outer);
5045
5046        let xml = document_xml(&document);
5047
5048        assert!(xml.contains(r#"<w:tag w:val="outer"/>"#), "{xml}");
5049        assert!(xml.contains(r#"<w:tag w:val="inner"/>"#), "{xml}");
5050        assert!(xml.contains("nested"), "{xml}");
5051    }
5052
5053    #[test]
5054    fn a_document_with_a_structured_document_tag_interleaved_with_paragraphs_writes_all_blocks() {
5055        let document = Document::new()
5056            .with_paragraph(Paragraph::with_text("Before"))
5057            .with_structured_document_tag(
5058                StructuredDocumentTag::new().with_paragraph(Paragraph::with_text("Inside")),
5059            )
5060            .with_paragraph(Paragraph::with_text("After"));
5061
5062        let xml = document_xml(&document);
5063
5064        let before = xml.find("Before").expect("missing Before");
5065        let inside = xml.find("Inside").expect("missing Inside");
5066        let after = xml.find("After").expect("missing After");
5067        assert!(before < inside && inside < after, "{xml}");
5068    }
5069
5070    #[test]
5071    fn writes_a_theme_part_with_colors_and_fonts() {
5072        use std::io::Cursor;
5073
5074        let theme = Theme::new(
5075            "Custom",
5076            ColorScheme::new(
5077                "111111", "EEEEEE", "222222", "DDDDDD", "AA0000", "BB1100", "CC2200", "DD3300",
5078                "EE4400", "FF5500", "0000AA", "AA00AA",
5079            ),
5080            FontScheme::new("Georgia", "Verdana"),
5081        );
5082        let document = Document::new()
5083            .with_paragraph(Paragraph::with_text("Hello"))
5084            .with_theme(theme);
5085
5086        let buffer = document.write_to(Cursor::new(Vec::new())).unwrap();
5087        let package = Package::read_from(buffer).unwrap();
5088
5089        let theme_part = package
5090            .part(THEME_PART_NAME)
5091            .expect("missing word/theme/theme1.xml");
5092        let theme_xml = std::str::from_utf8(&theme_part.data).unwrap();
5093
5094        assert!(theme_xml.contains(r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Custom">"#), "{theme_xml}");
5095        assert!(
5096            theme_xml.contains(r#"<a:dk1><a:srgbClr val="111111"/></a:dk1>"#),
5097            "{theme_xml}"
5098        );
5099        assert!(
5100            theme_xml.contains(r#"<a:accent6><a:srgbClr val="FF5500"/></a:accent6>"#),
5101            "{theme_xml}"
5102        );
5103        assert!(
5104            theme_xml.contains(r#"<a:folHlink><a:srgbClr val="AA00AA"/></a:folHlink>"#),
5105            "{theme_xml}"
5106        );
5107        assert!(
5108            theme_xml.contains(r#"<a:latin typeface="Georgia"/>"#),
5109            "{theme_xml}"
5110        );
5111        assert!(
5112            theme_xml.contains(r#"<a:latin typeface="Verdana"/>"#),
5113            "{theme_xml}"
5114        );
5115        assert!(
5116            theme_xml.contains("<a:fmtScheme"),
5117            "fmtScheme boilerplate missing: {theme_xml}"
5118        );
5119    }
5120
5121    #[test]
5122    fn a_document_with_no_theme_does_not_write_that_part() {
5123        let document = Document::new().with_paragraph(Paragraph::with_text("No theme here"));
5124
5125        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
5126        let package = Package::read_from(buffer).unwrap();
5127        assert!(
5128            package.part(THEME_PART_NAME).is_none(),
5129            "word/theme/theme1.xml should not be written"
5130        );
5131    }
5132
5133    #[test]
5134    fn write_to_declares_a_theme_relationship_when_a_theme_is_present() {
5135        use std::io::Cursor;
5136
5137        let document = Document::new().with_theme(Theme::office_default());
5138
5139        let buffer = document.write_to(Cursor::new(Vec::new())).unwrap();
5140        let package = Package::read_from(buffer).unwrap();
5141
5142        let document_part = package
5143            .part(MAIN_DOCUMENT_PART_NAME)
5144            .expect("missing document.xml");
5145        assert!(
5146            document_part
5147                .relationships
5148                .iter()
5149                .any(|relationship| relationship.rel_type == THEME_RELATIONSHIP_TYPE),
5150            "document.xml has no theme relationship"
5151        );
5152    }
5153
5154    #[test]
5155    fn a_document_with_no_explicit_page_setup_matches_the_old_fixed_a4_values() {
5156        // Regression test: `PageSetup::default()` must reproduce exactly what the old hardcoded
5157        // `write_default_section_properties` used to write, so existing documents don't change
5158        // appearance.
5159        let document = Document::new().with_paragraph(Paragraph::with_text("Body"));
5160
5161        let xml = document_xml(&document);
5162
5163        assert!(
5164            xml.contains(r#"<w:pgSz w:w="11906" w:h="16838"/>"#),
5165            "{xml}"
5166        );
5167        assert!(
5168            xml.contains(r#"<w:pgMar w:top="1417" w:right="1417" w:bottom="1417" w:left="1417" w:header="708" w:footer="708" w:gutter="0"/>"#),
5169            "{xml}"
5170        );
5171        // Portrait is the schema default: no `w:orient` attribute written.
5172        assert!(!xml.contains("w:orient"), "{xml}");
5173        assert!(!xml.contains("<w:cols"), "{xml}");
5174        assert!(!xml.contains("<w:titlePg"), "{xml}");
5175    }
5176
5177    #[test]
5178    fn write_to_uses_a_configurable_landscape_page_setup_with_columns() {
5179        use crate::model::PageSetup;
5180
5181        let page_setup = PageSetup::new()
5182            .with_size_twips(16_838, 11_906)
5183            .with_orientation(Orientation::Landscape)
5184            .with_margins_twips(1000, 1000, 1000, 1000)
5185            .with_columns(2);
5186
5187        let document = Document::new()
5188            .with_paragraph(Paragraph::with_text("Body"))
5189            .with_page_setup(page_setup);
5190
5191        let xml = document_xml(&document);
5192
5193        assert!(
5194            xml.contains(r#"<w:pgSz w:w="16838" w:h="11906" w:orient="landscape"/>"#),
5195            "{xml}"
5196        );
5197        assert!(
5198            xml.contains(r#"w:top="1000" w:right="1000" w:bottom="1000" w:left="1000""#),
5199            "{xml}"
5200        );
5201        assert!(
5202            xml.contains(r#"<w:cols w:num="2" w:space="720" w:equalWidth="true"/>"#),
5203            "{xml}"
5204        );
5205    }
5206
5207    #[test]
5208    fn write_to_writes_an_a3_page_size_distinct_from_the_default_a4() {
5209        // Regression/coverage test distinct from the landscape one above: that test only swaps A4's
5210        // own width/height (16838 x 11906), which proves orientation works but not that
5211        // `width_twips`/`height_twips` accept genuinely arbitrary values. A3 portrait (297 x 420 mm
5212        // = 16838 x 23811 twips) is a different physical page format entirely, not a rotation of
5213        // the default A4 (11906 x 16838).
5214        use crate::model::PageSetup;
5215
5216        let page_setup = PageSetup::new().with_size_twips(16_838, 23_811);
5217        let document = Document::new()
5218            .with_paragraph(Paragraph::with_text("Body"))
5219            .with_page_setup(page_setup);
5220
5221        let xml = document_xml(&document);
5222
5223        assert!(
5224            xml.contains(r#"<w:pgSz w:w="16838" w:h="23811"/>"#),
5225            "{xml}"
5226        );
5227        // Portrait (the default orientation) never writes `w:orient`.
5228        assert!(!xml.contains("w:orient"), "{xml}");
5229    }
5230
5231    #[test]
5232    fn writes_first_and_even_header_footer_references_titlepg_and_evenandoddheaders() {
5233        use std::io::Cursor;
5234
5235        let document = Document::new()
5236            .with_paragraph(Paragraph::with_text("Body"))
5237            .with_header(HeaderFooter::new().with_paragraph(Paragraph::with_text("Default header")))
5238            .with_header_first(
5239                HeaderFooter::new().with_paragraph(Paragraph::with_text("First page header")),
5240            )
5241            .with_footer_first(
5242                HeaderFooter::new().with_paragraph(Paragraph::with_text("First page footer")),
5243            )
5244            .with_header_even(
5245                HeaderFooter::new().with_paragraph(Paragraph::with_text("Even page header")),
5246            )
5247            .with_footer_even(
5248                HeaderFooter::new().with_paragraph(Paragraph::with_text("Even page footer")),
5249            );
5250
5251        let buffer = document.write_to(Cursor::new(Vec::new())).unwrap();
5252        let package = Package::read_from(buffer).unwrap();
5253
5254        let header_first_part = package
5255            .part(HEADER_FIRST_PART_NAME)
5256            .expect("missing header2.xml");
5257        assert!(
5258            std::str::from_utf8(&header_first_part.data)
5259                .unwrap()
5260                .contains("First page header")
5261        );
5262        let footer_first_part = package
5263            .part(FOOTER_FIRST_PART_NAME)
5264            .expect("missing footer2.xml");
5265        assert!(
5266            std::str::from_utf8(&footer_first_part.data)
5267                .unwrap()
5268                .contains("First page footer")
5269        );
5270        let header_even_part = package
5271            .part(HEADER_EVEN_PART_NAME)
5272            .expect("missing header3.xml");
5273        assert!(
5274            std::str::from_utf8(&header_even_part.data)
5275                .unwrap()
5276                .contains("Even page header")
5277        );
5278        let footer_even_part = package
5279            .part(FOOTER_EVEN_PART_NAME)
5280            .expect("missing footer3.xml");
5281        assert!(
5282            std::str::from_utf8(&footer_even_part.data)
5283                .unwrap()
5284                .contains("Even page footer")
5285        );
5286
5287        let document_part = package
5288            .part(MAIN_DOCUMENT_PART_NAME)
5289            .expect("missing document.xml");
5290        let document_xml = std::str::from_utf8(&document_part.data).unwrap();
5291        assert!(
5292            document_xml.contains(r#"w:headerReference w:type="first""#),
5293            "{document_xml}"
5294        );
5295        assert!(
5296            document_xml.contains(r#"w:footerReference w:type="first""#),
5297            "{document_xml}"
5298        );
5299        assert!(
5300            document_xml.contains(r#"w:headerReference w:type="even""#),
5301            "{document_xml}"
5302        );
5303        assert!(
5304            document_xml.contains(r#"w:footerReference w:type="even""#),
5305            "{document_xml}"
5306        );
5307        assert!(document_xml.contains("<w:titlePg/>"), "{document_xml}");
5308
5309        let settings_part = package
5310            .part(SETTINGS_PART_NAME)
5311            .expect("missing word/settings.xml");
5312        let settings_xml = std::str::from_utf8(&settings_part.data).unwrap();
5313        assert!(
5314            settings_xml.contains("<w:evenAndOddHeaders/>"),
5315            "{settings_xml}"
5316        );
5317    }
5318
5319    #[test]
5320    fn omits_titlepg_and_evenandoddheaders_when_only_the_default_header_is_set() {
5321        let document = Document::new()
5322            .with_paragraph(Paragraph::with_text("Body"))
5323            .with_header(
5324                HeaderFooter::new().with_paragraph(Paragraph::with_text("Default header")),
5325            );
5326
5327        let xml = document_xml(&document);
5328
5329        assert!(!xml.contains("titlePg"), "{xml}");
5330    }
5331
5332    #[test]
5333    fn writes_trackrevisions_when_track_changes_is_set() {
5334        let document = Document::new()
5335            .with_paragraph(Paragraph::with_text("Body"))
5336            .with_track_changes(true);
5337
5338        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
5339        let package = Package::read_from(buffer).unwrap();
5340
5341        let settings_part = package
5342            .part(SETTINGS_PART_NAME)
5343            .expect("missing word/settings.xml");
5344        let settings_xml = std::str::from_utf8(&settings_part.data).unwrap();
5345        assert!(
5346            settings_xml.contains("<w:trackRevisions/>"),
5347            "{settings_xml}"
5348        );
5349    }
5350
5351    #[test]
5352    fn a_document_with_track_changes_unset_omits_trackrevisions() {
5353        let document = Document::new().with_paragraph(Paragraph::with_text("Body"));
5354
5355        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
5356        let package = Package::read_from(buffer).unwrap();
5357
5358        let settings_part = package
5359            .part(SETTINGS_PART_NAME)
5360            .expect("missing word/settings.xml");
5361        let settings_xml = std::str::from_utf8(&settings_part.data).unwrap();
5362        assert!(!settings_xml.contains("trackRevisions"), "{settings_xml}");
5363    }
5364
5365    #[test]
5366    fn writes_trackrevisions_before_evenandoddheaders_when_both_are_set() {
5367        let document = Document::new()
5368            .with_paragraph(Paragraph::with_text("Body"))
5369            .with_track_changes(true)
5370            .with_header_even(
5371                HeaderFooter::new().with_paragraph(Paragraph::with_text("Even header")),
5372            );
5373
5374        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
5375        let package = Package::read_from(buffer).unwrap();
5376
5377        let settings_part = package
5378            .part(SETTINGS_PART_NAME)
5379            .expect("missing word/settings.xml");
5380        let settings_xml = std::str::from_utf8(&settings_part.data).unwrap();
5381        assert!(
5382            settings_xml.contains("<w:trackRevisions/><w:evenAndOddHeaders/>"),
5383            "{settings_xml}"
5384        );
5385    }
5386
5387    #[test]
5388    fn writes_documentprotection_with_the_given_edit_value_and_enforcement() {
5389        let document = Document::new()
5390            .with_paragraph(Paragraph::with_text("Body"))
5391            .with_protection(ProtectionKind::ReadOnly);
5392
5393        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
5394        let package = Package::read_from(buffer).unwrap();
5395
5396        let settings_part = package
5397            .part(SETTINGS_PART_NAME)
5398            .expect("missing word/settings.xml");
5399        let settings_xml = std::str::from_utf8(&settings_part.data).unwrap();
5400        assert!(
5401            settings_xml.contains(r#"<w:documentProtection w:edit="readOnly" w:enforcement="1"/>"#),
5402            "{settings_xml}"
5403        );
5404    }
5405
5406    #[test]
5407    fn a_document_with_no_protection_set_omits_documentprotection() {
5408        let document = Document::new().with_paragraph(Paragraph::with_text("Body"));
5409
5410        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
5411        let package = Package::read_from(buffer).unwrap();
5412
5413        let settings_part = package
5414            .part(SETTINGS_PART_NAME)
5415            .expect("missing word/settings.xml");
5416        let settings_xml = std::str::from_utf8(&settings_part.data).unwrap();
5417        assert!(
5418            !settings_xml.contains("documentProtection"),
5419            "{settings_xml}"
5420        );
5421    }
5422
5423    #[test]
5424    fn writes_documentprotection_between_trackrevisions_and_evenandoddheaders() {
5425        let document = Document::new()
5426            .with_paragraph(Paragraph::with_text("Body"))
5427            .with_track_changes(true)
5428            .with_protection(ProtectionKind::TrackedChanges)
5429            .with_header_even(
5430                HeaderFooter::new().with_paragraph(Paragraph::with_text("Even header")),
5431            );
5432
5433        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
5434        let package = Package::read_from(buffer).unwrap();
5435
5436        let settings_part = package
5437            .part(SETTINGS_PART_NAME)
5438            .expect("missing word/settings.xml");
5439        let settings_xml = std::str::from_utf8(&settings_part.data).unwrap();
5440        assert!(
5441            settings_xml.contains(
5442                r#"<w:trackRevisions/><w:documentProtection w:edit="trackedChanges" w:enforcement="1"/><w:evenAndOddHeaders/>"#
5443            ),
5444            "{settings_xml}"
5445        );
5446    }
5447
5448    #[test]
5449    fn writes_core_properties_with_every_field_set() {
5450        let properties = DocumentProperties::new()
5451            .with_title("Rapport trimestriel")
5452            .with_subject("Finances")
5453            .with_creator("Morgan")
5454            .with_keywords("rapport, finances, Q2")
5455            .with_description("Notes internes")
5456            .with_last_modified_by("Morgan")
5457            .with_revision("3")
5458            .with_created("2026-07-17T10:00:00Z")
5459            .with_modified("2026-07-17T11:30:00Z")
5460            .with_category("Interne")
5461            .with_content_status("Draft");
5462        let document = Document::new()
5463            .with_paragraph(Paragraph::with_text("Body"))
5464            .with_properties(properties);
5465
5466        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
5467        let package = Package::read_from(buffer).unwrap();
5468
5469        let core_properties_part = package
5470            .part(CORE_PROPERTIES_PART_NAME)
5471            .expect("missing docProps/core.xml");
5472        let core_properties_xml = std::str::from_utf8(&core_properties_part.data).unwrap();
5473        assert!(
5474            core_properties_xml.contains("<dc:title>Rapport trimestriel</dc:title>"),
5475            "{core_properties_xml}"
5476        );
5477        assert!(
5478            core_properties_xml.contains("<dc:subject>Finances</dc:subject>"),
5479            "{core_properties_xml}"
5480        );
5481        assert!(
5482            core_properties_xml.contains("<dc:creator>Morgan</dc:creator>"),
5483            "{core_properties_xml}"
5484        );
5485        assert!(
5486            core_properties_xml.contains("<cp:keywords>rapport, finances, Q2</cp:keywords>"),
5487            "{core_properties_xml}"
5488        );
5489        assert!(
5490            core_properties_xml.contains("<dc:description>Notes internes</dc:description>"),
5491            "{core_properties_xml}"
5492        );
5493        assert!(
5494            core_properties_xml.contains("<cp:lastModifiedBy>Morgan</cp:lastModifiedBy>"),
5495            "{core_properties_xml}"
5496        );
5497        assert!(
5498            core_properties_xml.contains("<cp:revision>3</cp:revision>"),
5499            "{core_properties_xml}"
5500        );
5501        assert!(
5502            core_properties_xml.contains(r#"<dcterms:created xsi:type="dcterms:W3CDTF">2026-07-17T10:00:00Z</dcterms:created>"#),
5503            "{core_properties_xml}"
5504        );
5505        assert!(
5506            core_properties_xml.contains(r#"<dcterms:modified xsi:type="dcterms:W3CDTF">2026-07-17T11:30:00Z</dcterms:modified>"#),
5507            "{core_properties_xml}"
5508        );
5509        assert!(
5510            core_properties_xml.contains("<cp:category>Interne</cp:category>"),
5511            "{core_properties_xml}"
5512        );
5513        assert!(
5514            core_properties_xml.contains("<cp:contentStatus>Draft</cp:contentStatus>"),
5515            "{core_properties_xml}"
5516        );
5517    }
5518
5519    #[test]
5520    fn a_document_with_no_properties_set_writes_an_empty_coreproperties_element() {
5521        let document = Document::new().with_paragraph(Paragraph::with_text("Body"));
5522
5523        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
5524        let package = Package::read_from(buffer).unwrap();
5525
5526        let core_properties_part = package
5527            .part(CORE_PROPERTIES_PART_NAME)
5528            .expect("missing docProps/core.xml");
5529        let core_properties_xml = std::str::from_utf8(&core_properties_part.data).unwrap();
5530        assert!(
5531            core_properties_xml.contains("<cp:coreProperties "),
5532            "{core_properties_xml}"
5533        );
5534        assert!(
5535            !core_properties_xml.contains("<dc:title>"),
5536            "{core_properties_xml}"
5537        );
5538    }
5539
5540    #[test]
5541    fn writes_custom_tab_stops_in_the_order_they_were_added() {
5542        use crate::model::{TabLeader, TabStop, TabStopAlignment};
5543
5544        let paragraph = Paragraph::with_text("Tabbed")
5545            .with_tab(TabStop::new(720))
5546            .with_tab(
5547                TabStop::new(-360)
5548                    .with_alignment(TabStopAlignment::Decimal)
5549                    .with_leader(TabLeader::Dot),
5550            );
5551        let document = Document::new().with_paragraph(paragraph);
5552
5553        let xml = document_xml(&document);
5554
5555        let tabs_start = xml.find("<w:tabs>").expect("missing w:tabs");
5556        let first_tab = xml
5557            .find(r#"<w:tab w:val="left" w:pos="720"/>"#)
5558            .expect("missing first tab");
5559        let second_tab = xml
5560            .find(r#"<w:tab w:val="decimal" w:leader="dot" w:pos="-360"/>"#)
5561            .expect("missing second tab");
5562        assert!(tabs_start < first_tab && first_tab < second_tab, "{xml}");
5563    }
5564
5565    #[test]
5566    fn writes_keep_next_keep_lines_page_break_before_and_contextual_spacing() {
5567        let paragraph = Paragraph::with_text("Heading")
5568            .with_keep_with_next(true)
5569            .with_keep_lines_together(true)
5570            .with_page_break_before(true)
5571            .with_contextual_spacing(true);
5572        let document = Document::new().with_paragraph(paragraph);
5573
5574        let xml = document_xml(&document);
5575
5576        assert!(xml.contains("<w:keepNext/>"), "{xml}");
5577        assert!(xml.contains("<w:keepLines/>"), "{xml}");
5578        assert!(xml.contains("<w:pageBreakBefore/>"), "{xml}");
5579        assert!(xml.contains("<w:contextualSpacing/>"), "{xml}");
5580        // Confirmed `CT_PPrBase` order: keepNext/keepLines/pageBreakBefore right after pStyle
5581        // (there is none here, so right after `w:pPr` starts), contextualSpacing right after
5582        // spacing (there is none here either).
5583        let p_pr = xml.find("<w:pPr>").expect("missing w:pPr");
5584        let keep_next = xml.find("<w:keepNext/>").unwrap();
5585        assert!(p_pr < keep_next, "{xml}");
5586    }
5587
5588    #[test]
5589    fn writes_page_and_column_breaks_and_a_carriage_return_in_runs() {
5590        use crate::model::BreakKind;
5591
5592        let document = Document::new().with_paragraph(
5593            Paragraph::new()
5594                .with_run(Run::with_break(BreakKind::TextWrapping))
5595                .with_run(Run::with_break(BreakKind::Page))
5596                .with_run(Run::with_break(BreakKind::Column))
5597                .with_run(Run::with_carriage_return()),
5598        );
5599
5600        let xml = document_xml(&document);
5601
5602        // The common line-break case (`TextWrapping`) omits `w:type` entirely, since it's
5603        // `ST_BrType`'s own default value.
5604        assert!(xml.contains("<w:br/>"), "{xml}");
5605        assert!(xml.contains(r#"<w:br w:type="page"/>"#), "{xml}");
5606        assert!(xml.contains(r#"<w:br w:type="column"/>"#), "{xml}");
5607        assert!(xml.contains("<w:cr/>"), "{xml}");
5608    }
5609
5610    #[test]
5611    fn writes_company_and_manager_in_extended_properties_when_set() {
5612        use crate::model::ExtendedProperties;
5613
5614        let extended_properties = ExtendedProperties::new()
5615            .with_company("Acme Corp")
5616            .with_manager("Morgan");
5617        let document = Document::new()
5618            .with_paragraph(Paragraph::with_text("Body"))
5619            .with_extended_properties(extended_properties);
5620
5621        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
5622        let package = Package::read_from(buffer).unwrap();
5623
5624        let extended_properties_part = package
5625            .part(EXTENDED_PROPERTIES_PART_NAME)
5626            .expect("missing docProps/app.xml");
5627        let extended_properties_xml = std::str::from_utf8(&extended_properties_part.data).unwrap();
5628        assert!(
5629            extended_properties_xml.contains("<Application>office-toolkit</Application>"),
5630            "{extended_properties_xml}"
5631        );
5632        assert!(
5633            extended_properties_xml.contains("<Manager>Morgan</Manager>"),
5634            "{extended_properties_xml}"
5635        );
5636        assert!(
5637            extended_properties_xml.contains("<Company>Acme Corp</Company>"),
5638            "{extended_properties_xml}"
5639        );
5640    }
5641
5642    #[test]
5643    fn a_document_with_no_extended_properties_set_only_writes_application() {
5644        let document = Document::new().with_paragraph(Paragraph::with_text("Body"));
5645
5646        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
5647        let package = Package::read_from(buffer).unwrap();
5648
5649        let extended_properties_part = package
5650            .part(EXTENDED_PROPERTIES_PART_NAME)
5651            .expect("missing docProps/app.xml");
5652        let extended_properties_xml = std::str::from_utf8(&extended_properties_part.data).unwrap();
5653        assert!(
5654            extended_properties_xml.contains("<Application>office-toolkit</Application>"),
5655            "{extended_properties_xml}"
5656        );
5657        assert!(
5658            !extended_properties_xml.contains("<Company>"),
5659            "{extended_properties_xml}"
5660        );
5661        assert!(
5662            !extended_properties_xml.contains("<Manager>"),
5663            "{extended_properties_xml}"
5664        );
5665    }
5666
5667    #[test]
5668    fn writes_custom_properties_with_sequential_pids_and_the_right_variant_types() {
5669        use crate::model::CustomPropertyValue;
5670
5671        let document = Document::new()
5672            .with_paragraph(Paragraph::with_text("Body"))
5673            .with_custom_property(
5674                "Client",
5675                CustomPropertyValue::Text("Northwind Traders".to_string()),
5676            )
5677            .with_custom_property("Confidentiel", CustomPropertyValue::Bool(true))
5678            .with_custom_property("Version interne", CustomPropertyValue::Int(7))
5679            .with_custom_property("Taux de remise", CustomPropertyValue::Number(0.15));
5680
5681        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
5682        let package = Package::read_from(buffer).unwrap();
5683
5684        let custom_properties_part = package
5685            .part(CUSTOM_PROPERTIES_PART_NAME)
5686            .expect("missing docProps/custom.xml");
5687        let custom_properties_xml = std::str::from_utf8(&custom_properties_part.data).unwrap();
5688        assert!(
5689            custom_properties_xml.contains(
5690                r#"<property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="2" name="Client"><vt:lpwstr>Northwind Traders</vt:lpwstr></property>"#
5691            ),
5692            "{custom_properties_xml}"
5693        );
5694        assert!(
5695            custom_properties_xml.contains(
5696                r#"<property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="3" name="Confidentiel"><vt:bool>true</vt:bool></property>"#
5697            ),
5698            "{custom_properties_xml}"
5699        );
5700        assert!(
5701            custom_properties_xml.contains(
5702                r#"<property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="4" name="Version interne"><vt:i4>7</vt:i4></property>"#
5703            ),
5704            "{custom_properties_xml}"
5705        );
5706        assert!(
5707            custom_properties_xml.contains(
5708                r#"<property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="5" name="Taux de remise"><vt:r8>0.15</vt:r8></property>"#
5709            ),
5710            "{custom_properties_xml}"
5711        );
5712    }
5713
5714    #[test]
5715    fn a_document_with_no_custom_properties_omits_the_custom_properties_part() {
5716        let document = Document::new().with_paragraph(Paragraph::with_text("Body"));
5717
5718        let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
5719        let package = Package::read_from(buffer).unwrap();
5720
5721        assert!(package.part(CUSTOM_PROPERTIES_PART_NAME).is_none());
5722    }
5723}