Skip to main content

powerpoint_ooxml/
writer.rs

1//! Writing our [`Presentation`] model out to a valid `.pptx` package.
2//!
3//! Real PowerPoint packages always carry a slide master, at least one slide layout, and a theme,
4//! even for the simplest deck — unlike Word/Excel, which can get away with a much smaller minimum
5//! part set. This crate has no customizable model for the slide master/layout, so `write_to` always
6//! emits one fixed, minimal master/layout pair alongside the slides the caller actually populated —
7//! the theme, though, follows [`Presentation::theme`], still always written (mandatory per
8//! ECMA-376), just with caller-supplied content when set. A notes master/notes slide pair is
9//! emitted the same way as the master/layout, but only when
10//! at least one slide actually has notes (see [`Slide::notes`]'s doc comment) — genuinely optional,
11//! unlike the master/layout/theme triplet. Per-slide comments (`ppt/comments/`,
12//! `ppt/commentAuthors.xml`) follow the same "only when actually present" posture as notes.
13
14use std::io::{Seek, Write};
15
16use drawing::{ShapeProperties, TextAnchor};
17use opc::{Package, Part, Relationship, Relationships, TargetMode};
18use xml_core::{BytesDecl, BytesEnd, BytesStart, Event, Writer};
19
20use crate::error::Result;
21use crate::model::{
22    AutoShape, ColorScheme, Connector, CustomPropertyValue, DEFAULT_NOTES_HEIGHT_EMU,
23    DEFAULT_NOTES_WIDTH_EMU, DocumentProperties, FontScheme, Placeholder, PlaceholderKind,
24    Presentation, Shape, ShapeGroup, Slide, SlideComment, SlideHyperlinkTarget, SlideMedia,
25    SlideTable, SlideTableStyle, TableCell, TableCellProperties, TableRow, TableStylePart, Theme,
26};
27
28const P_NAMESPACE: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
29const A_NAMESPACE: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
30const R_NAMESPACE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
31const CHART_NAMESPACE: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart";
32const TABLE_NAMESPACE: &str = "http://schemas.openxmlformats.org/drawingml/2006/table";
33
34/// Validates a table style id against `ST_Guid`
35/// (`\{[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}\}`, confirmed against the
36/// ECMA-376/ISO-IEC 29500-4 `dml-main.xsd` schema — both `CT_TableStyle@styleId` and
37/// `<a:tableStyleId>`'s content are typed `s:ST_Guid`). Called before writing either, so a
38/// caller-supplied id like `"CustomTableStyle"` fails loudly here instead of silently producing a
39/// `.pptx` PowerPoint reports as damaged.
40fn validate_table_style_id(id: &str) -> Result<()> {
41    let bytes = id.as_bytes();
42    let is_hex_run = |s: &[u8], len: usize| s.len() == len && s.iter().all(u8::is_ascii_hexdigit);
43    let valid = bytes.len() == 38
44        && bytes[0] == b'{'
45        && bytes[37] == b'}'
46        && bytes[9] == b'-'
47        && bytes[14] == b'-'
48        && bytes[19] == b'-'
49        && bytes[24] == b'-'
50        && is_hex_run(&bytes[1..9], 8)
51        && is_hex_run(&bytes[10..14], 4)
52        && is_hex_run(&bytes[15..19], 4)
53        && is_hex_run(&bytes[20..24], 4)
54        && is_hex_run(&bytes[25..37], 12)
55        // ST_Guid's pattern is `[0-9A-F]`, not `[0-9A-Fa-f]` — real Office output is always
56        // uppercase, so reject lowercase too rather than silently accepting XML a strict validator
57        // would still flag.
58        && id.chars().all(|c| !c.is_ascii_hexdigit() || c.is_ascii_digit() || c.is_ascii_uppercase());
59    if valid {
60        Ok(())
61    } else {
62        Err(crate::error::Error::InvalidTableStyleId(id.to_string()))
63    }
64}
65
66const OFFICE_DOCUMENT_RELATIONSHIP_TYPE: &str =
67    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument";
68const SLIDE_MASTER_RELATIONSHIP_TYPE: &str =
69    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster";
70const SLIDE_LAYOUT_RELATIONSHIP_TYPE: &str =
71    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout";
72const SLIDE_RELATIONSHIP_TYPE: &str =
73    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide";
74const THEME_RELATIONSHIP_TYPE: &str =
75    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme";
76const PRES_PROPS_RELATIONSHIP_TYPE: &str =
77    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/presProps";
78const VIEW_PROPS_RELATIONSHIP_TYPE: &str =
79    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/viewProps";
80const TABLE_STYLES_RELATIONSHIP_TYPE: &str =
81    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableStyles";
82const CORE_PROPERTIES_RELATIONSHIP_TYPE: &str =
83    "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties";
84const EXTENDED_PROPERTIES_RELATIONSHIP_TYPE: &str =
85    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties";
86const IMAGE_RELATIONSHIP_TYPE: &str =
87    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
88const CHART_RELATIONSHIP_TYPE: &str =
89    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart";
90const NOTES_MASTER_RELATIONSHIP_TYPE: &str =
91    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster";
92const NOTES_SLIDE_RELATIONSHIP_TYPE: &str =
93    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide";
94// Legacy (`CT_Comment`, ECMA-376 §19.4) presentation comments — not grounded on a real fixture, see
95// `model.rs`'s top-level doc comment and `SlideComment`'s own doc comment.
96const COMMENT_AUTHORS_RELATIONSHIP_TYPE: &str =
97    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/commentAuthors";
98const COMMENT_RELATIONSHIP_TYPE: &str =
99    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
100const HYPERLINK_RELATIONSHIP_TYPE: &str =
101    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
102const VIDEO_RELATIONSHIP_TYPE: &str =
103    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/video";
104const AUDIO_RELATIONSHIP_TYPE: &str =
105    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio";
106const FONT_RELATIONSHIP_TYPE: &str =
107    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font";
108const CUSTOM_PROPERTIES_RELATIONSHIP_TYPE: &str =
109    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties";
110
111const PRESENTATION_CONTENT_TYPE: &str =
112    "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml";
113const SLIDE_MASTER_CONTENT_TYPE: &str =
114    "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml";
115const SLIDE_LAYOUT_CONTENT_TYPE: &str =
116    "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml";
117const SLIDE_CONTENT_TYPE: &str =
118    "application/vnd.openxmlformats-officedocument.presentationml.slide+xml";
119const THEME_CONTENT_TYPE: &str = "application/vnd.openxmlformats-officedocument.theme+xml";
120const PRES_PROPS_CONTENT_TYPE: &str =
121    "application/vnd.openxmlformats-officedocument.presentationml.presProps+xml";
122const VIEW_PROPS_CONTENT_TYPE: &str =
123    "application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml";
124const TABLE_STYLES_CONTENT_TYPE: &str =
125    "application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml";
126const CORE_PROPERTIES_CONTENT_TYPE: &str =
127    "application/vnd.openxmlformats-package.core-properties+xml";
128const EXTENDED_PROPERTIES_CONTENT_TYPE: &str =
129    "application/vnd.openxmlformats-officedocument.extended-properties+xml";
130const CHART_CONTENT_TYPE: &str =
131    "application/vnd.openxmlformats-officedocument.drawingml.chart+xml";
132const NOTES_MASTER_CONTENT_TYPE: &str =
133    "application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml";
134const NOTES_SLIDE_CONTENT_TYPE: &str =
135    "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml";
136const COMMENT_AUTHORS_CONTENT_TYPE: &str =
137    "application/vnd.openxmlformats-officedocument.presentationml.commentAuthors+xml";
138const COMMENT_CONTENT_TYPE: &str =
139    "application/vnd.openxmlformats-officedocument.presentationml.comments+xml";
140const FONT_CONTENT_TYPE: &str = "application/x-fontdata";
141const CUSTOM_PROPERTIES_CONTENT_TYPE: &str =
142    "application/vnd.openxmlformats-officedocument.custom-properties+xml";
143const CUSTOM_PROPERTIES_NAMESPACE: &str =
144    "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties";
145const CUSTOM_PROPERTIES_VT_NAMESPACE: &str =
146    "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes";
147// The fixed custom-properties `fmtid` (`CT_Property`'s own attribute) every OOXML application uses,
148// and the first `pid` real Office assigns a custom property (`0`/`1` are reserved by the format) —
149// same values `excel-ooxml`/`word-ooxml` already use for their own custom properties.
150const CUSTOM_PROPERTY_FMTID: &str = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}";
151const FIRST_CUSTOM_PROPERTY_PID: i32 = 2;
152// The "No Style, No Grid" built-in table style GUID (part of the OOXML specification's own
153// reference material, not implementation-specific) — confirmed as a genuine value real PowerPoint
154// writes for `<a:tableStyleId>` via a real fixture, and already used unchanged as
155// `to_table_styles_xml`'s own `def` attribute.
156const TABLE_STYLE_GUID: &str = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}";
157
158const PRESENTATION_PART_NAME: &str = "/ppt/presentation.xml";
159const PRESENTATION_ENTRY_NAME: &str = "ppt/presentation.xml";
160const SLIDE_MASTER_PART_NAME: &str = "/ppt/slideMasters/slideMaster1.xml";
161const SLIDE_MASTER_ENTRY_NAME: &str = "slideMasters/slideMaster1.xml";
162const SLIDE_LAYOUT_PART_NAME: &str = "/ppt/slideLayouts/slideLayout1.xml";
163const THEME_PART_NAME: &str = "/ppt/theme/theme1.xml";
164const THEME_ENTRY_NAME: &str = "theme/theme1.xml";
165// The notes master gets its own dedicated theme part (`theme2.xml`) rather than sharing
166// `theme1.xml` with the slide master. Real PowerPoint files always give the notes master its own
167// theme relationship (confirmed via a real multi-master fixture, and via PowerPoint's own repair of
168// a file that shared theme1.xml between slideMaster and notesMaster: repair split them apart by
169// synthesizing a brand-new theme2.xml and repointing notesMaster1.xml's theme relationship at it).
170// Sharing the same theme part between the two is apparently what real PowerPoint's loader rejects
171// as corrupt, even though nothing in the schema text forbids two relationships targeting the same
172// part.
173const NOTES_MASTER_THEME_PART_NAME: &str = "/ppt/theme/theme2.xml";
174const NOTES_MASTER_THEME_ENTRY_NAME: &str = "../theme/theme2.xml";
175const PRES_PROPS_PART_NAME: &str = "/ppt/presProps.xml";
176const PRES_PROPS_ENTRY_NAME: &str = "presProps.xml";
177const VIEW_PROPS_PART_NAME: &str = "/ppt/viewProps.xml";
178const VIEW_PROPS_ENTRY_NAME: &str = "viewProps.xml";
179const TABLE_STYLES_PART_NAME: &str = "/ppt/tableStyles.xml";
180const TABLE_STYLES_ENTRY_NAME: &str = "tableStyles.xml";
181const CORE_PROPERTIES_PART_NAME: &str = "/docProps/core.xml";
182const CORE_PROPERTIES_ENTRY_NAME: &str = "docProps/core.xml";
183const EXTENDED_PROPERTIES_PART_NAME: &str = "/docProps/app.xml";
184const EXTENDED_PROPERTIES_ENTRY_NAME: &str = "docProps/app.xml";
185const NOTES_MASTER_PART_NAME: &str = "/ppt/notesMasters/notesMaster1.xml";
186const NOTES_MASTER_ENTRY_NAME: &str = "notesMasters/notesMaster1.xml";
187const COMMENT_AUTHORS_PART_NAME: &str = "/ppt/commentAuthors.xml";
188const COMMENT_AUTHORS_ENTRY_NAME: &str = "commentAuthors.xml";
189const CUSTOM_PROPERTIES_PART_NAME: &str = "/docProps/custom.xml";
190const CUSTOM_PROPERTIES_ENTRY_NAME: &str = "docProps/custom.xml";
191
192// The numeric id PowerPoint expects the (single, fixed) slide master to have in
193// `<p:sldMasterIdLst>` — real PowerPoint starts slide master ids at this exact value.
194const SLIDE_MASTER_ID: u32 = 2_147_483_648;
195// Real slide ids start at 256; not otherwise meaningful, just needs to be unique per `<p:sldId>`.
196const FIRST_SLIDE_ID: u32 = 256;
197
198impl Presentation {
199    /// Writes this presentation to a seekable byte sink as a valid `.pptx` package:
200    /// `[Content_Types].xml`, `_rels/.rels`, `ppt/presentation.xml` (+ rels), one
201    /// `ppt/slides/slideN.xml` (+ rels) per slide, a single fixed
202    /// `ppt/slideMasters/slideMaster1.xml` (+ rels), `ppt/slideLayouts/slideLayout1.xml` (+ rels),
203    /// `ppt/theme/theme1.xml`, `ppt/presProps.xml`, `ppt/viewProps.xml`, `ppt/tableStyles.xml`,
204    /// `docProps/core.xml`, `docProps/app.xml`, plus `ppt/media/imageN.*`/ `ppt/charts/chartN.xml`
205    /// for any picture/chart shape, plus (only if at least one slide has notes) a single fixed
206    /// `ppt/notesMasters/notesMaster1.xml` and one `ppt/notesSlides/notesSlideN.xml` per slide that
207    /// has notes, plus (only if at least one slide has comments) a single `ppt/commentAuthors.xml`
208    /// and one `ppt/comments/commentN.xml` per slide that has comments. `ppt/theme/theme1.xml`'s
209    /// content follows `self.theme` (Office's own built-in default when unset) — see this module's
210    /// own doc comment for why the slide master/layout pair itself stays fixed.
211    pub fn write_to<W: Write + Seek>(&self, writer: W) -> Result<W> {
212        let mut package = Package::new();
213
214        package.add_relationship(Relationship {
215            id: "rId1".to_string(),
216            rel_type: OFFICE_DOCUMENT_RELATIONSHIP_TYPE.to_string(),
217            target: PRESENTATION_ENTRY_NAME.to_string(),
218            target_mode: TargetMode::Internal,
219        });
220        package.add_relationship(Relationship {
221            id: "rId2".to_string(),
222            rel_type: CORE_PROPERTIES_RELATIONSHIP_TYPE.to_string(),
223            target: CORE_PROPERTIES_ENTRY_NAME.to_string(),
224            target_mode: TargetMode::Internal,
225        });
226        package.add_relationship(Relationship {
227            id: "rId3".to_string(),
228            rel_type: EXTENDED_PROPERTIES_RELATIONSHIP_TYPE.to_string(),
229            target: EXTENDED_PROPERTIES_ENTRY_NAME.to_string(),
230            target_mode: TargetMode::Internal,
231        });
232        // `docProps/custom.xml`. Genuinely optional, same "only written when actually set"
233        // convention as notes/comments, so its package-root relationship is only added when there's
234        // actually a custom property to write.
235        if !self.properties.custom_properties.is_empty() {
236            package.add_relationship(Relationship {
237                id: "rId4".to_string(),
238                rel_type: CUSTOM_PROPERTIES_RELATIONSHIP_TYPE.to_string(),
239                target: CUSTOM_PROPERTIES_ENTRY_NAME.to_string(),
240                target_mode: TargetMode::Internal,
241            });
242        }
243
244        // `ppt/presentation.xml`'s own relationships: the slide master first (rId1), then one
245        // relationship per slide, then (if needed) the notes master, then
246        // theme/presProps/viewProps/tableStyles — order doesn't matter to the schema, but mirrors
247        // the real fixture's own numbering closely enough to stay easy to cross-check by hand.
248        let mut presentation_relationships = Relationships::new();
249        presentation_relationships.add(Relationship {
250            id: "rId1".to_string(),
251            rel_type: SLIDE_MASTER_RELATIONSHIP_TYPE.to_string(),
252            target: SLIDE_MASTER_ENTRY_NAME.to_string(),
253            target_mode: TargetMode::Internal,
254        });
255
256        // Package-wide state, threaded through every slide: pictures/ charts encountered anywhere
257        // are collected as package-wide-unique media/chart parts, added to the package once every
258        // slide has been walked — mirrors `word-ooxml`/`excel-ooxml`'s own `PackageState`.
259        let mut state = PackageState::new();
260        let has_notes = self.slides.iter().any(|slide| slide.notes.is_some());
261
262        let mut next_relationship_id = 2u32;
263        let mut slide_ids: Vec<(u32, String)> = Vec::new();
264        for (index, slide) in self.slides.iter().enumerate() {
265            let slide_number = index + 1;
266            let relationship_id = format!("rId{next_relationship_id}");
267            next_relationship_id += 1;
268
269            presentation_relationships.add(Relationship {
270                id: relationship_id.clone(),
271                rel_type: SLIDE_RELATIONSHIP_TYPE.to_string(),
272                target: format!("slides/slide{slide_number}.xml"),
273                target_mode: TargetMode::Internal,
274            });
275            slide_ids.push((FIRST_SLIDE_ID + index as u32, relationship_id));
276
277            // Every slide reserves rId1 for its slide layout relationship; pictures/charts
278            // encountered while writing the shape tree below get rId2 onward, assigned as they're
279            // written.
280            let mut slide_relationships = PartRelationships::new();
281            slide_relationships.add(
282                SLIDE_LAYOUT_RELATIONSHIP_TYPE,
283                "../slideLayouts/slideLayout1.xml".to_string(),
284            );
285
286            let slide_xml = to_slide_xml(slide, &mut state, &mut slide_relationships)?;
287
288            if let Some(notes) = &slide.notes {
289                let notes_entry_name = format!("notesSlides/notesSlide{slide_number}.xml");
290                slide_relationships.add(
291                    NOTES_SLIDE_RELATIONSHIP_TYPE,
292                    format!("../{notes_entry_name}"),
293                );
294
295                let mut notes_part_relationships = Relationships::new();
296                notes_part_relationships.add(Relationship {
297                    id: "rId1".to_string(),
298                    rel_type: NOTES_MASTER_RELATIONSHIP_TYPE.to_string(),
299                    target: "../notesMasters/notesMaster1.xml".to_string(),
300                    target_mode: TargetMode::Internal,
301                });
302                notes_part_relationships.add(Relationship {
303                    id: "rId2".to_string(),
304                    rel_type: SLIDE_RELATIONSHIP_TYPE.to_string(),
305                    target: format!("../slides/slide{slide_number}.xml"),
306                    target_mode: TargetMode::Internal,
307                });
308                let mut notes_part = Part::new(
309                    format!("/ppt/{notes_entry_name}"),
310                    NOTES_SLIDE_CONTENT_TYPE,
311                    to_notes_slide_xml(notes)?,
312                );
313                notes_part.relationships = notes_part_relationships;
314                package.add_part(notes_part);
315            }
316
317            // A slide's comments live in their own part (`ppt/comments/commentN.xml`), reached via
318            // a `comments`- typed relationship on the slide's own `.rels` — same "only written when
319            // actually set" convention as notes. **Not grounded on a real fixture**, see
320            // `model.rs`'s top-level doc comment and `SlideComment`'s own doc comment.
321            if !slide.comments.is_empty() {
322                let comments_entry_name = format!("comments/comment{slide_number}.xml");
323                slide_relationships.add(
324                    COMMENT_RELATIONSHIP_TYPE,
325                    format!("../{comments_entry_name}"),
326                );
327                let comments_xml = to_comments_xml(&slide.comments, &mut state.comment_authors);
328                package.add_part(Part::new(
329                    format!("/ppt/{comments_entry_name}"),
330                    COMMENT_CONTENT_TYPE,
331                    comments_xml,
332                ));
333            }
334
335            let mut slide_part = Part::new(
336                format!("/ppt/slides/slide{slide_number}.xml"),
337                SLIDE_CONTENT_TYPE,
338                slide_xml,
339            );
340            slide_part.relationships = slide_relationships.relationships;
341            package.add_part(slide_part);
342        }
343
344        // `ppt/commentAuthors.xml` is package-wide (every comment on every slide references one
345        // shared author list by numeric id, see `state.comment_authors`'s own doc comment) —
346        // written once, after every slide has been walked and every author it uses collected, same
347        // "collect while walking slides, write once at the end" posture as
348        // `state.media`/`state.charts`.
349        if !state.comment_authors.authors.is_empty() {
350            let relationship_id = format!("rId{next_relationship_id}");
351            next_relationship_id += 1;
352            presentation_relationships.add(Relationship {
353                id: relationship_id,
354                rel_type: COMMENT_AUTHORS_RELATIONSHIP_TYPE.to_string(),
355                target: COMMENT_AUTHORS_ENTRY_NAME.to_string(),
356                target_mode: TargetMode::Internal,
357            });
358            package.add_part(Part::new(
359                COMMENT_AUTHORS_PART_NAME,
360                COMMENT_AUTHORS_CONTENT_TYPE,
361                to_comment_authors_xml(&state.comment_authors),
362            ));
363        }
364
365        let notes_master_relationship_id = if has_notes {
366            let relationship_id = format!("rId{next_relationship_id}");
367            next_relationship_id += 1;
368            presentation_relationships.add(Relationship {
369                id: relationship_id.clone(),
370                rel_type: NOTES_MASTER_RELATIONSHIP_TYPE.to_string(),
371                target: NOTES_MASTER_ENTRY_NAME.to_string(),
372                target_mode: TargetMode::Internal,
373            });
374            Some(relationship_id)
375        } else {
376            None
377        };
378
379        let theme_relationship_id = format!("rId{next_relationship_id}");
380        next_relationship_id += 1;
381        presentation_relationships.add(Relationship {
382            id: theme_relationship_id,
383            rel_type: THEME_RELATIONSHIP_TYPE.to_string(),
384            target: THEME_ENTRY_NAME.to_string(),
385            target_mode: TargetMode::Internal,
386        });
387        let pres_props_relationship_id = format!("rId{next_relationship_id}");
388        next_relationship_id += 1;
389        presentation_relationships.add(Relationship {
390            id: pres_props_relationship_id,
391            rel_type: PRES_PROPS_RELATIONSHIP_TYPE.to_string(),
392            target: PRES_PROPS_ENTRY_NAME.to_string(),
393            target_mode: TargetMode::Internal,
394        });
395        let view_props_relationship_id = format!("rId{next_relationship_id}");
396        next_relationship_id += 1;
397        presentation_relationships.add(Relationship {
398            id: view_props_relationship_id,
399            rel_type: VIEW_PROPS_RELATIONSHIP_TYPE.to_string(),
400            target: VIEW_PROPS_ENTRY_NAME.to_string(),
401            target_mode: TargetMode::Internal,
402        });
403        let table_styles_relationship_id = format!("rId{next_relationship_id}");
404        next_relationship_id += 1;
405        presentation_relationships.add(Relationship {
406            id: table_styles_relationship_id,
407            rel_type: TABLE_STYLES_RELATIONSHIP_TYPE.to_string(),
408            target: TABLE_STYLES_ENTRY_NAME.to_string(),
409            target_mode: TargetMode::Internal,
410        });
411
412        // Embedded fonts — each font's own variant (regular/bold/italic/boldItalic) present becomes
413        // its own `ppt/fonts/fontN.fntdata` part plus a presentation-level `font`- typed
414        // relationship, referenced by `<p:embeddedFont>`'s own `r:id` attributes (resolved up front
415        // here, since `to_presentation_xml` itself has no relationship accumulator of its own to
416        // register against).
417        let mut next_font_index = 1u32;
418        let mut resolved_fonts: Vec<ResolvedEmbeddedFont> = Vec::new();
419        for font in &self.embedded_fonts {
420            resolved_fonts.push(ResolvedEmbeddedFont {
421                typeface: font.typeface.clone(),
422                regular: register_embedded_font_variant(
423                    &mut package,
424                    &mut presentation_relationships,
425                    &mut next_relationship_id,
426                    &mut next_font_index,
427                    &font.regular,
428                ),
429                bold: register_embedded_font_variant(
430                    &mut package,
431                    &mut presentation_relationships,
432                    &mut next_relationship_id,
433                    &mut next_font_index,
434                    &font.bold,
435                ),
436                italic: register_embedded_font_variant(
437                    &mut package,
438                    &mut presentation_relationships,
439                    &mut next_relationship_id,
440                    &mut next_font_index,
441                    &font.italic,
442                ),
443                bold_italic: register_embedded_font_variant(
444                    &mut package,
445                    &mut presentation_relationships,
446                    &mut next_relationship_id,
447                    &mut next_font_index,
448                    &font.bold_italic,
449                ),
450            });
451        }
452
453        let mut presentation_part = Part::new(
454            PRESENTATION_PART_NAME,
455            PRESENTATION_CONTENT_TYPE,
456            to_presentation_xml(
457                self,
458                &slide_ids,
459                notes_master_relationship_id.as_deref(),
460                &resolved_fonts,
461            )?,
462        );
463        presentation_part.relationships = presentation_relationships;
464        package.add_part(presentation_part);
465
466        let mut slide_master_part = Part::new(
467            SLIDE_MASTER_PART_NAME,
468            SLIDE_MASTER_CONTENT_TYPE,
469            to_slide_master_xml()?,
470        );
471        slide_master_part.relationships.add(Relationship {
472            id: "rId1".to_string(),
473            rel_type: SLIDE_LAYOUT_RELATIONSHIP_TYPE.to_string(),
474            target: "../slideLayouts/slideLayout1.xml".to_string(),
475            target_mode: TargetMode::Internal,
476        });
477        slide_master_part.relationships.add(Relationship {
478            id: "rId2".to_string(),
479            rel_type: THEME_RELATIONSHIP_TYPE.to_string(),
480            target: "../theme/theme1.xml".to_string(),
481            target_mode: TargetMode::Internal,
482        });
483        package.add_part(slide_master_part);
484
485        let mut slide_layout_part = Part::new(
486            SLIDE_LAYOUT_PART_NAME,
487            SLIDE_LAYOUT_CONTENT_TYPE,
488            to_slide_layout_xml()?,
489        );
490        slide_layout_part.relationships.add(Relationship {
491            id: "rId1".to_string(),
492            rel_type: SLIDE_MASTER_RELATIONSHIP_TYPE.to_string(),
493            target: "../slideMasters/slideMaster1.xml".to_string(),
494            target_mode: TargetMode::Internal,
495        });
496        package.add_part(slide_layout_part);
497
498        package.add_part(Part::new(
499            THEME_PART_NAME,
500            THEME_CONTENT_TYPE,
501            to_theme_xml(self.theme.as_ref()),
502        ));
503        package.add_part(Part::new(
504            PRES_PROPS_PART_NAME,
505            PRES_PROPS_CONTENT_TYPE,
506            to_pres_props_xml(),
507        ));
508        package.add_part(Part::new(
509            VIEW_PROPS_PART_NAME,
510            VIEW_PROPS_CONTENT_TYPE,
511            to_view_props_xml(),
512        ));
513        package.add_part(Part::new(
514            TABLE_STYLES_PART_NAME,
515            TABLE_STYLES_CONTENT_TYPE,
516            to_table_styles_xml(&self.table_styles)?,
517        ));
518
519        if has_notes {
520            let mut notes_master_part = Part::new(
521                NOTES_MASTER_PART_NAME,
522                NOTES_MASTER_CONTENT_TYPE,
523                to_notes_master_xml()?,
524            );
525            notes_master_part.relationships.add(Relationship {
526                id: "rId1".to_string(),
527                rel_type: THEME_RELATIONSHIP_TYPE.to_string(),
528                target: NOTES_MASTER_THEME_ENTRY_NAME.to_string(),
529                target_mode: TargetMode::Internal,
530            });
531            package.add_part(notes_master_part);
532            // Dedicated theme part for the notes master (see `NOTES_MASTER_THEME_PART_NAME`'s doc
533            // comment for why this can't just reuse `theme1.xml`). Content is the same fixed theme
534            // as `theme1.xml`; only the part identity needs to differ.
535            package.add_part(Part::new(
536                NOTES_MASTER_THEME_PART_NAME,
537                THEME_CONTENT_TYPE,
538                to_theme_xml(None),
539            ));
540        }
541
542        for media_part in state.media.parts {
543            package.add_part(Part::new(
544                format!("/ppt/{}", media_part.entry_name),
545                media_part.content_type,
546                media_part.data,
547            ));
548        }
549        for chart_part in state.charts.parts {
550            package.add_part(Part::new(
551                format!("/ppt/{}", chart_part.entry_name),
552                CHART_CONTENT_TYPE,
553                chart_part.data,
554            ));
555        }
556
557        package.add_part(Part::new(
558            CORE_PROPERTIES_PART_NAME,
559            CORE_PROPERTIES_CONTENT_TYPE,
560            to_core_properties_xml(&self.properties)?,
561        ));
562        package.add_part(Part::new(
563            EXTENDED_PROPERTIES_PART_NAME,
564            EXTENDED_PROPERTIES_CONTENT_TYPE,
565            to_extended_properties_xml(self.slides.len(), &self.properties)?,
566        ));
567        // `docProps/custom.xml`. Only written when there's actually a custom property (its
568        // package-root relationship above is conditional on the same check).
569        if !self.properties.custom_properties.is_empty() {
570            package.add_part(Part::new(
571                CUSTOM_PROPERTIES_PART_NAME,
572                CUSTOM_PROPERTIES_CONTENT_TYPE,
573                to_custom_properties_xml(&self.properties.custom_properties)?,
574            ));
575        }
576
577        Ok(package.write_to(writer)?)
578    }
579}
580
581/// One [`EmbeddedFont`]'s variants, resolved to already-registered relationship ids — built by
582/// `write_to` before calling `to_presentation_xml` (which has no relationship accumulator of its
583/// own to register a font part against).
584struct ResolvedEmbeddedFont {
585    typeface: String,
586    regular: Option<String>,
587    bold: Option<String>,
588    italic: Option<String>,
589    bold_italic: Option<String>,
590}
591
592/// Registers one embedded-font variant's bytes, if present: a new `ppt/fonts/fontN.fntdata` part
593/// (package-wide-unique index, shared across every font/variant) plus a `font`-typed relationship
594/// on `ppt/presentation.xml`'s own relationships, returning that relationship's id. Returns `None`
595/// without touching either accumulator when `data` itself is `None` (no part/relationship for a
596/// variant that was never set).
597fn register_embedded_font_variant(
598    package: &mut Package,
599    presentation_relationships: &mut Relationships,
600    next_relationship_id: &mut u32,
601    next_font_index: &mut u32,
602    data: &Option<Vec<u8>>,
603) -> Option<String> {
604    let data = data.as_ref()?;
605    let entry_name = format!("fonts/font{next_font_index}.fntdata");
606    *next_font_index += 1;
607    let relationship_id = format!("rId{next_relationship_id}");
608    *next_relationship_id += 1;
609    presentation_relationships.add(Relationship {
610        id: relationship_id.clone(),
611        rel_type: FONT_RELATIONSHIP_TYPE.to_string(),
612        target: entry_name.clone(),
613        target_mode: TargetMode::Internal,
614    });
615    package.add_part(Part::new(
616        format!("/ppt/{entry_name}"),
617        FONT_CONTENT_TYPE,
618        data.clone(),
619    ));
620    Some(relationship_id)
621}
622
623/// Package-wide state threaded through every slide while writing: pictures ([`MediaRegistry`]) and
624/// charts ([`ChartRegistry`]) encountered on any slide. Mirrors `word-ooxml`/`excel-ooxml`'s own
625/// `PackageState` — media/ chart part names must be unique across the whole package, not just
626/// within one slide.
627struct PackageState {
628    media: MediaRegistry,
629    charts: ChartRegistry,
630    comment_authors: CommentAuthorRegistry,
631}
632
633impl PackageState {
634    fn new() -> Self {
635        Self {
636            media: MediaRegistry::new(),
637            charts: ChartRegistry::new(),
638            comment_authors: CommentAuthorRegistry::new(),
639        }
640    }
641}
642
643/// The package-wide, deduplicated list of comment authors every [`SlideComment`] across every slide
644/// references by numeric id (`<p:cm authorId="..">`) — real PowerPoint avoids a duplicate
645/// `<p:cmAuthor>` entry for the same author appearing on several comments, so this crate does too,
646/// matching each comment's `author`/`initials` against what's already registered (by name) before
647/// adding a new entry.
648struct CommentAuthorRegistry {
649    authors: Vec<(String, String)>,
650}
651
652impl CommentAuthorRegistry {
653    fn new() -> Self {
654        Self {
655            authors: Vec::new(),
656        }
657    }
658
659    /// Returns this author's numeric id, registering a new `(name, initials)` entry (id = its
660    /// index) the first time this name is seen.
661    fn id_for(&mut self, name: &str, initials: &str) -> u32 {
662        if let Some(index) = self
663            .authors
664            .iter()
665            .position(|(existing_name, _)| existing_name == name)
666        {
667            return index as u32;
668        }
669        let id = self.authors.len() as u32;
670        self.authors.push((name.to_string(), initials.to_string()));
671        id
672    }
673}
674
675struct MediaRegistry {
676    next_index: usize,
677    parts: Vec<MediaPart>,
678}
679
680struct MediaPart {
681    /// Relative to `ppt/`, e.g. `media/image1.png`.
682    entry_name: String,
683    content_type: &'static str,
684    data: Vec<u8>,
685}
686
687impl MediaRegistry {
688    fn new() -> Self {
689        Self {
690            next_index: 1,
691            parts: Vec::new(),
692        }
693    }
694
695    fn register(&mut self, picture: &crate::model::Picture) -> String {
696        self.register_bytes(
697            "image",
698            &picture.data,
699            picture.format.extension(),
700            picture.format.content_type(),
701        )
702    }
703
704    /// Registers any package-wide-unique `ppt/media/*` part — generalizes
705    /// [`MediaRegistry::register`] (pictures) for [`SlideMedia`]'s own video/audio bytes and
706    /// poster-frame image, which don't have a [`crate::model::Picture`] to read a format off of.
707    /// `base_name` picks the part's own file-name prefix (`"image"` for
708    /// [`MediaRegistry::register`]'s own pictures, continuing this crate's earlier `image{N}`
709    /// convention unchanged; `"media"` for video/audio clips) — cosmetic only, real PowerPoint
710    /// itself is inconsistent about it across media kinds, but keeping pictures on their existing
711    /// convention avoids any risk of colliding with `image{N}` indices a caller might already be
712    /// relying on indirectly (e.g. inspecting a generated file by hand).
713    fn register_bytes(
714        &mut self,
715        base_name: &str,
716        data: &[u8],
717        extension: &str,
718        content_type: &'static str,
719    ) -> String {
720        let index = self.next_index;
721        self.next_index += 1;
722        let entry_name = format!("media/{base_name}{index}.{extension}");
723        self.parts.push(MediaPart {
724            entry_name: entry_name.clone(),
725            content_type,
726            data: data.to_vec(),
727        });
728        entry_name
729    }
730}
731
732struct ChartRegistry {
733    next_index: usize,
734    parts: Vec<ChartPart>,
735}
736
737struct ChartPart {
738    /// Relative to `ppt/`, e.g. `charts/chart1.xml`.
739    entry_name: String,
740    data: Vec<u8>,
741}
742
743impl ChartRegistry {
744    fn new() -> Self {
745        Self {
746            next_index: 1,
747            parts: Vec::new(),
748        }
749    }
750
751    fn register(&mut self, chart_space: &chart::ChartSpace) -> Result<String> {
752        let index = self.next_index;
753        self.next_index += 1;
754        let entry_name = format!("charts/chart{index}.xml");
755        let mut chart_writer = Writer::new(Vec::new());
756        chart::write_chart_space(&mut chart_writer, chart_space)?;
757        self.parts.push(ChartPart {
758            entry_name: entry_name.clone(),
759            data: chart_writer.into_inner(),
760        });
761        Ok(entry_name)
762    }
763}
764
765/// The relationships being accumulated for a single slide (or notes slide) part while it's being
766/// written — each part has its own independently- scoped `rIdN` numbering. Mirrors `word-ooxml`'s
767/// own `PartRelationships`.
768struct PartRelationships {
769    next_id: usize,
770    relationships: Relationships,
771}
772
773impl PartRelationships {
774    fn new() -> Self {
775        Self {
776            next_id: 1,
777            relationships: Relationships::new(),
778        }
779    }
780
781    fn add(&mut self, rel_type: &str, target: impl Into<String>) -> String {
782        let id = format!("rId{}", self.next_id);
783        self.next_id += 1;
784        self.relationships.add(Relationship {
785            id: id.clone(),
786            rel_type: rel_type.to_string(),
787            target: target.into(),
788            target_mode: TargetMode::Internal,
789        });
790        id
791    }
792
793    /// Same as [`PartRelationships::add`], but for a relationship pointing outside the package
794    /// (`TargetMode::External`) — e.g. a hyperlink's own target URL.
795    fn add_external(&mut self, rel_type: &str, target: impl Into<String>) -> String {
796        let id = format!("rId{}", self.next_id);
797        self.next_id += 1;
798        self.relationships.add(Relationship {
799            id: id.clone(),
800            rel_type: rel_type.to_string(),
801            target: target.into(),
802            target_mode: TargetMode::External,
803        });
804        id
805    }
806}
807
808/// Serializes `ppt/presentation.xml` (`CT_Presentation`).
809fn to_presentation_xml(
810    presentation: &Presentation,
811    slide_ids: &[(u32, String)],
812    notes_master_relationship_id: Option<&str>,
813    embedded_fonts: &[ResolvedEmbeddedFont],
814) -> Result<Vec<u8>> {
815    let mut writer = Writer::new(Vec::new());
816    writer.write_event(Event::Decl(BytesDecl::new(
817        "1.0",
818        Some("UTF-8"),
819        Some("yes"),
820    )))?;
821
822    let mut root = BytesStart::new("p:presentation");
823    root.push_attribute(("xmlns:a", A_NAMESPACE));
824    root.push_attribute(("xmlns:r", R_NAMESPACE));
825    root.push_attribute(("xmlns:p", P_NAMESPACE));
826    // `embedTrueTypeFonts="1"` tells real PowerPoint the deck carries its own embedded fonts — only
827    // set when it actually does, same "only write what was actually set" convention as everywhere
828    // else in this crate.
829    if !embedded_fonts.is_empty() {
830        root.push_attribute(("embedTrueTypeFonts", "1"));
831    }
832    writer.write_event(Event::Start(root))?;
833
834    writer.write_event(Event::Start(BytesStart::new("p:sldMasterIdLst")))?;
835    let mut master_id = BytesStart::new("p:sldMasterId");
836    master_id.push_attribute(("id", SLIDE_MASTER_ID.to_string().as_str()));
837    master_id.push_attribute(("r:id", "rId1"));
838    writer.write_event(Event::Empty(master_id))?;
839    writer.write_event(Event::End(BytesEnd::new("p:sldMasterIdLst")))?;
840
841    // `<p:notesMasterIdLst>` sits right after `<p:sldMasterIdLst>` in `CT_Presentation`'s own child
842    // sequence, before `<p:sldIdLst>` — only written when a notes master actually exists (see
843    // `write_to`). Unlike `<p:sldMasterId>`/`<p:sldLayoutId>`, `<p:notesMasterId>` has no `id`
844    // attribute at all, only `r:id` (confirmed against a real notes-bearing `.pptx` fixture).
845    if let Some(relationship_id) = notes_master_relationship_id {
846        writer.write_event(Event::Start(BytesStart::new("p:notesMasterIdLst")))?;
847        let mut notes_master_id = BytesStart::new("p:notesMasterId");
848        notes_master_id.push_attribute(("r:id", relationship_id));
849        writer.write_event(Event::Empty(notes_master_id))?;
850        writer.write_event(Event::End(BytesEnd::new("p:notesMasterIdLst")))?;
851    }
852
853    writer.write_event(Event::Start(BytesStart::new("p:sldIdLst")))?;
854    for (numeric_id, relationship_id) in slide_ids {
855        let mut slide_id = BytesStart::new("p:sldId");
856        slide_id.push_attribute(("id", numeric_id.to_string().as_str()));
857        slide_id.push_attribute(("r:id", relationship_id.as_str()));
858        writer.write_event(Event::Empty(slide_id))?;
859    }
860    writer.write_event(Event::End(BytesEnd::new("p:sldIdLst")))?;
861
862    let mut slide_size = BytesStart::new("p:sldSz");
863    slide_size.push_attribute(("cx", presentation.slide_width_emu.to_string().as_str()));
864    slide_size.push_attribute(("cy", presentation.slide_height_emu.to_string().as_str()));
865    writer.write_event(Event::Empty(slide_size))?;
866
867    let mut notes_size = BytesStart::new("p:notesSz");
868    notes_size.push_attribute(("cx", DEFAULT_NOTES_WIDTH_EMU.to_string().as_str()));
869    notes_size.push_attribute(("cy", DEFAULT_NOTES_HEIGHT_EMU.to_string().as_str()));
870    writer.write_event(Event::Empty(notes_size))?;
871
872    // `<p:embeddedFontLst>`. Sits after `<p:notesSz>` in `CT_Presentation`'s own child sequence
873    // (before `<p:custShowLst>`/`<p:photoAlbum>`/etc., none of which this crate models); only
874    // written when at least one font was actually embedded, same "only write what was actually set"
875    // convention as everywhere else in this crate. **Not grounded on a real fixture** — see
876    // [`crate::model::EmbeddedFont`]'s own doc comment.
877    if !embedded_fonts.is_empty() {
878        writer.write_event(Event::Start(BytesStart::new("p:embeddedFontLst")))?;
879        for font in embedded_fonts {
880            writer.write_event(Event::Start(BytesStart::new("p:embeddedFont")))?;
881
882            let mut font_element = BytesStart::new("p:font");
883            font_element.push_attribute(("typeface", font.typeface.as_str()));
884            writer.write_event(Event::Empty(font_element))?;
885
886            let variants: [(&str, &Option<String>); 4] = [
887                ("p:regular", &font.regular),
888                ("p:bold", &font.bold),
889                ("p:italic", &font.italic),
890                ("p:boldItalic", &font.bold_italic),
891            ];
892            for (element_name, relationship_id) in variants {
893                if let Some(relationship_id) = relationship_id {
894                    let mut variant = BytesStart::new(element_name);
895                    variant.push_attribute(("r:id", relationship_id.as_str()));
896                    writer.write_event(Event::Empty(variant))?;
897                }
898            }
899
900            writer.write_event(Event::End(BytesEnd::new("p:embeddedFont")))?;
901        }
902        writer.write_event(Event::End(BytesEnd::new("p:embeddedFontLst")))?;
903    }
904
905    writer.write_event(Event::End(BytesEnd::new("p:presentation")))?;
906    Ok(writer.into_inner())
907}
908
909/// Serializes one `ppt/slides/slideN.xml` (`CT_Slide`) from a [`Slide`].
910fn to_slide_xml(
911    slide: &Slide,
912    state: &mut PackageState,
913    relationships: &mut PartRelationships,
914) -> Result<Vec<u8>> {
915    let mut writer = Writer::new(Vec::new());
916    writer.write_event(Event::Decl(BytesDecl::new(
917        "1.0",
918        Some("UTF-8"),
919        Some("yes"),
920    )))?;
921
922    let mut root = BytesStart::new("p:sld");
923    root.push_attribute(("xmlns:a", A_NAMESPACE));
924    root.push_attribute(("xmlns:r", R_NAMESPACE));
925    root.push_attribute(("xmlns:p", P_NAMESPACE));
926    if slide.hidden {
927        root.push_attribute(("show", "0"));
928    }
929    if slide.hide_master_graphics {
930        root.push_attribute(("showMasterSp", "0"));
931    }
932    writer.write_event(Event::Start(root))?;
933
934    let mut c_sld = BytesStart::new("p:cSld");
935    if let Some(name) = &slide.name {
936        c_sld.push_attribute(("name", name.as_str()));
937    }
938    writer.write_event(Event::Start(c_sld))?;
939    if let Some(background) = &slide.background {
940        write_slide_background(&mut writer, background)?;
941    }
942    write_shape_tree(&mut writer, &slide.shapes, state, relationships)?;
943    writer.write_event(Event::End(BytesEnd::new("p:cSld")))?;
944
945    write_color_map_override(&mut writer)?;
946
947    writer.write_event(Event::End(BytesEnd::new("p:sld")))?;
948    Ok(writer.into_inner())
949}
950
951/// Writes `<p:bg><p:bgPr>`'s own `EG_FillProperties` choice `</p:bgPr></p:bg>`
952/// (`CT_BackgroundProperties`, point 4) — `<p:cSld>`'s own first, optional child (`bg?`, before
953/// `spTree`). `shadeToTitle` and `<a:effectLst>`/`<a:effectDag>` are not modeled, same "direct fill
954/// only, no theme background reference" scope as [`crate::model::Slide::background`]'s own doc
955/// comment.
956fn write_slide_background<W: Write>(
957    writer: &mut Writer<W>,
958    background: &drawing::Fill,
959) -> Result<()> {
960    writer.write_event(Event::Start(BytesStart::new("p:bg")))?;
961    writer.write_event(Event::Start(BytesStart::new("p:bgPr")))?;
962    drawing::write_fill(writer, background)?;
963    writer.write_event(Event::End(BytesEnd::new("p:bgPr")))?;
964    writer.write_event(Event::End(BytesEnd::new("p:bg")))?;
965    Ok(())
966}
967
968/// Writes `<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>` — every slide and slide layout this
969/// crate writes simply inherits its color map from the (single, fixed) slide master, rather than
970/// overriding it.
971fn write_color_map_override<W: Write>(writer: &mut Writer<W>) -> Result<()> {
972    writer.write_event(Event::Start(BytesStart::new("p:clrMapOvr")))?;
973    writer.write_event(Event::Empty(BytesStart::new("a:masterClrMapping")))?;
974    writer.write_event(Event::End(BytesEnd::new("p:clrMapOvr")))?;
975    Ok(())
976}
977
978/// Writes `<p:spTree>`: the mandatory group-shape header (`p:nvGrpSpPr`/`p:grpSpPr`, both
979/// fixed/empty — this crate has no notion of nested shape groups yet, see [`Shape`]'s doc comment)
980/// followed by one child per shape, dispatched by kind.
981fn write_shape_tree<W: Write>(
982    writer: &mut Writer<W>,
983    shapes: &[Shape],
984    state: &mut PackageState,
985    relationships: &mut PartRelationships,
986) -> Result<()> {
987    writer.write_event(Event::Start(BytesStart::new("p:spTree")))?;
988
989    writer.write_event(Event::Start(BytesStart::new("p:nvGrpSpPr")))?;
990    let mut group_cnv_pr = BytesStart::new("p:cNvPr");
991    group_cnv_pr.push_attribute(("id", "1"));
992    group_cnv_pr.push_attribute(("name", ""));
993    writer.write_event(Event::Empty(group_cnv_pr))?;
994    writer.write_event(Event::Empty(BytesStart::new("p:cNvGrpSpPr")))?;
995    writer.write_event(Event::Empty(BytesStart::new("p:nvPr")))?;
996    writer.write_event(Event::End(BytesEnd::new("p:nvGrpSpPr")))?;
997
998    writer.write_event(Event::Start(BytesStart::new("p:grpSpPr")))?;
999    writer.write_event(Event::Start(BytesStart::new("a:xfrm")))?;
1000    write_zero_point(writer, "a:off", "x", "y")?;
1001    write_zero_point(writer, "a:ext", "cx", "cy")?;
1002    write_zero_point(writer, "a:chOff", "x", "y")?;
1003    write_zero_point(writer, "a:chExt", "cx", "cy")?;
1004    writer.write_event(Event::End(BytesEnd::new("a:xfrm")))?;
1005    writer.write_event(Event::End(BytesEnd::new("p:grpSpPr")))?;
1006
1007    write_shapes(writer, shapes, state, relationships)?;
1008
1009    writer.write_event(Event::End(BytesEnd::new("p:spTree")))?;
1010    Ok(())
1011}
1012
1013/// Dispatches and writes each shape in document order — the part shared by `<p:spTree>` (the
1014/// slide's own top-level shape list, via [`write_shape_tree`]) and `<p:grpSp>` (a group's own
1015/// nested shape list, via [`write_group_shape`]), since both are just "one child element per
1016/// [`Shape`]" once their own fixed/real header is out of the way.
1017fn write_shapes<W: Write>(
1018    writer: &mut Writer<W>,
1019    shapes: &[Shape],
1020    state: &mut PackageState,
1021    relationships: &mut PartRelationships,
1022) -> Result<()> {
1023    for shape in shapes {
1024        match shape {
1025            Shape::AutoShape(auto_shape) => write_auto_shape(writer, auto_shape, relationships)?,
1026            Shape::Picture(picture) => write_picture(writer, picture, state, relationships)?,
1027            Shape::Chart(slide_chart) => {
1028                write_slide_chart(writer, slide_chart, state, relationships)?
1029            }
1030            Shape::Group(group) => write_group_shape(writer, group, state, relationships)?,
1031            Shape::Connector(connector) => write_connector(writer, connector)?,
1032            Shape::Table(table) => write_table(writer, table)?,
1033            Shape::Media(media) => write_media_shape(writer, media, state, relationships)?,
1034        }
1035    }
1036    Ok(())
1037}
1038
1039/// Writes a single zero-valued two-attribute empty element, e.g. `<a:off x="0" y="0"/>` or `<a:ext
1040/// cx="0" cy="0"/>`.
1041fn write_zero_point<W: Write>(
1042    writer: &mut Writer<W>,
1043    element_name: &str,
1044    first_attribute: &str,
1045    second_attribute: &str,
1046) -> Result<()> {
1047    let mut start = BytesStart::new(element_name);
1048    start.push_attribute((first_attribute, "0"));
1049    start.push_attribute((second_attribute, "0"));
1050    writer.write_event(Event::Empty(start))?;
1051    Ok(())
1052}
1053
1054/// Writes a single `<p:sp>` (`CT_Shape`) from an [`AutoShape`]. Registers a hyperlink relationship
1055/// (external, or internal for an in-package slide jump) when `shape.hyperlink` is set; see
1056/// [`write_hyperlink_click`].
1057fn write_auto_shape<W: Write>(
1058    writer: &mut Writer<W>,
1059    shape: &AutoShape,
1060    relationships: &mut PartRelationships,
1061) -> Result<()> {
1062    writer.write_event(Event::Start(BytesStart::new("p:sp")))?;
1063
1064    writer.write_event(Event::Start(BytesStart::new("p:nvSpPr")))?;
1065    let mut cnv_pr = BytesStart::new("p:cNvPr");
1066    cnv_pr.push_attribute(("id", shape.id.to_string().as_str()));
1067    cnv_pr.push_attribute(("name", shape.name.as_str()));
1068    match &shape.hyperlink {
1069        None => {
1070            writer.write_event(Event::Empty(cnv_pr))?;
1071        }
1072        Some(target) => {
1073            writer.write_event(Event::Start(cnv_pr))?;
1074            write_hyperlink_click(writer, target, relationships)?;
1075            writer.write_event(Event::End(BytesEnd::new("p:cNvPr")))?;
1076        }
1077    }
1078
1079    let mut cnv_sp_pr = BytesStart::new("p:cNvSpPr");
1080    if shape.is_text_box {
1081        cnv_sp_pr.push_attribute(("txBox", "1"));
1082    }
1083    writer.write_event(Event::Empty(cnv_sp_pr))?;
1084    write_placeholder(writer, shape.placeholder.as_ref())?;
1085    writer.write_event(Event::End(BytesEnd::new("p:nvSpPr")))?;
1086
1087    write_shape_properties(writer, &shape.properties)?;
1088
1089    if let Some(text_body) = &shape.text_body {
1090        writer.write_event(Event::Start(BytesStart::new("p:txBody")))?;
1091        drawing::write_text_body(writer, text_body)?;
1092        writer.write_event(Event::End(BytesEnd::new("p:txBody")))?;
1093    }
1094
1095    writer.write_event(Event::End(BytesEnd::new("p:sp")))?;
1096    Ok(())
1097}
1098
1099/// Writes `<a:hlinkClick./>` for any [`SlideHyperlinkTarget`]. [`SlideHyperlinkTarget::External`]
1100/// registers an external relationship and writes `r:id` alone.
1101/// [`SlideHyperlinkTarget::Slide`] registers an *internal* relationship targeting
1102/// `"./slides/slide{index + 1}.xml"` (resolvable purely from the index — see
1103/// [`SlideHyperlinkTarget::Slide`]'s own doc comment) and pairs `r:id` with
1104/// `action="ppaction://hlinksldjump"`, matching a real fixture. The four relative-jump variants
1105/// need no relationship at all: no `r:id`, just `action="ppaction://hlinkshowjump? jump=."`,
1106/// modeled directly from ECMA-376's documented action values (no fixture in this project's corpus
1107/// exercises them).
1108fn write_hyperlink_click<W: Write>(
1109    writer: &mut Writer<W>,
1110    target: &SlideHyperlinkTarget,
1111    relationships: &mut PartRelationships,
1112) -> Result<()> {
1113    let mut hlink = BytesStart::new("a:hlinkClick");
1114    match target {
1115        SlideHyperlinkTarget::External(url) => {
1116            let relationship_id =
1117                relationships.add_external(HYPERLINK_RELATIONSHIP_TYPE, url.as_str());
1118            hlink.push_attribute(("r:id", relationship_id.as_str()));
1119        }
1120        SlideHyperlinkTarget::Slide(index) => {
1121            let relationship_id = relationships.add(
1122                SLIDE_RELATIONSHIP_TYPE,
1123                format!("../slides/slide{}.xml", index + 1),
1124            );
1125            hlink.push_attribute(("r:id", relationship_id.as_str()));
1126            hlink.push_attribute(("action", "ppaction://hlinksldjump"));
1127        }
1128        SlideHyperlinkTarget::NextSlide => {
1129            hlink.push_attribute(("action", "ppaction://hlinkshowjump?jump=nextslide"));
1130        }
1131        SlideHyperlinkTarget::PreviousSlide => {
1132            hlink.push_attribute(("action", "ppaction://hlinkshowjump?jump=previousslide"));
1133        }
1134        SlideHyperlinkTarget::FirstSlide => {
1135            hlink.push_attribute(("action", "ppaction://hlinkshowjump?jump=firstslide"));
1136        }
1137        SlideHyperlinkTarget::LastSlide => {
1138            hlink.push_attribute(("action", "ppaction://hlinkshowjump?jump=lastslide"));
1139        }
1140    }
1141    writer.write_event(Event::Empty(hlink))?;
1142    Ok(())
1143}
1144
1145/// Writes `<p:nvPr>`, empty unless a placeholder role is set, in which case it wraps a `<p:ph
1146/// type=".." idx="..">` (`CT_Placeholder`).
1147fn write_placeholder<W: Write>(
1148    writer: &mut Writer<W>,
1149    placeholder: Option<&Placeholder>,
1150) -> Result<()> {
1151    match placeholder {
1152        None => {
1153            writer.write_event(Event::Empty(BytesStart::new("p:nvPr")))?;
1154        }
1155        Some(placeholder) => {
1156            writer.write_event(Event::Start(BytesStart::new("p:nvPr")))?;
1157            let mut placeholder_start = BytesStart::new("p:ph");
1158            placeholder_start.push_attribute(("type", placeholder.kind.xml_token()));
1159            if let Some(index) = placeholder.index {
1160                placeholder_start.push_attribute(("idx", index.to_string().as_str()));
1161            }
1162            writer.write_event(Event::Empty(placeholder_start))?;
1163            writer.write_event(Event::End(BytesEnd::new("p:nvPr")))?;
1164        }
1165    }
1166    Ok(())
1167}
1168
1169/// Writes `<p:spPr>` — always with an explicit `<a:xfrm>` and `<a:prstGeom>`, even when
1170/// [`ShapeProperties::transform`]/`::geometry` are unset (a fixed zero-sized rect is written
1171/// instead).
1172///
1173/// This mirrors a fix `excel-ooxml`'s picture writing needed twice this project (see
1174/// `CHANGELOG.md`, "Corriger bug xdr:spPr sans géométrie" / "Ajouter a:xfrm à xdr:pic"): delegating
1175/// entirely to `drawing::write_shape_properties` (which only emits `xfrm`/`prstGeom` when the
1176/// caller actually set them) produced a shape real Office silently refused to render at all.
1177/// Applied here from the start, rather than rediscovered. consolidation: the geometry-fallback +
1178/// fill/line/effects part (everything but the `xfrm`, which differs by host format) now delegates
1179/// to `drawing::write_geometry_fill_line_effects` instead of a hand-rolled copy — this crate's own
1180/// three former copies of that logic (here, [`write_picture`], [`write_connector_properties`]) were
1181/// the only ones that hadn't drifted (they already wrote `effects`; `word-ooxml`'s and
1182/// `excel-ooxml`'s otherwise-identical copies hadn't).
1183fn write_shape_properties<W: Write>(
1184    writer: &mut Writer<W>,
1185    properties: &ShapeProperties,
1186) -> Result<()> {
1187    writer.write_event(Event::Start(BytesStart::new("p:spPr")))?;
1188
1189    match &properties.transform {
1190        Some(transform) => drawing::write_transform(writer, transform)?,
1191        None => {
1192            writer.write_event(Event::Start(BytesStart::new("a:xfrm")))?;
1193            write_zero_point(writer, "a:off", "x", "y")?;
1194            write_zero_point(writer, "a:ext", "cx", "cy")?;
1195            writer.write_event(Event::End(BytesEnd::new("a:xfrm")))?;
1196        }
1197    }
1198
1199    drawing::write_geometry_fill_line_effects(writer, properties, "rect")?;
1200
1201    writer.write_event(Event::End(BytesEnd::new("p:spPr")))?;
1202    Ok(())
1203}
1204
1205/// Writes one `<p:pic>` (`CT_Picture`) from a [`crate::model::Picture`]. Registers the image as a
1206/// package-wide-unique `ppt/media/imageN.*` part (`state`) and a slide-scoped relationship
1207/// (`relationships`) along the way — structure grounded in the same real-Excel/real-Word/real-
1208/// PowerPoint cross-check this project has now applied three times (`nvPicPr`/`blipFill`/`spPr`, in
1209/// that order; `spPr` always carries a real `<a:xfrm>`, since — unlike Excel's cell-anchored
1210/// pictures — a PresentationML picture's position/size lives nowhere else).
1211fn write_picture<W: Write>(
1212    writer: &mut Writer<W>,
1213    picture: &crate::model::Picture,
1214    state: &mut PackageState,
1215    relationships: &mut PartRelationships,
1216) -> Result<()> {
1217    let entry_name = state.media.register(picture);
1218    let relationship_id = relationships.add(IMAGE_RELATIONSHIP_TYPE, format!("../{entry_name}"));
1219
1220    writer.write_event(Event::Start(BytesStart::new("p:pic")))?;
1221
1222    writer.write_event(Event::Start(BytesStart::new("p:nvPicPr")))?;
1223    let mut cnv_pr = BytesStart::new("p:cNvPr");
1224    cnv_pr.push_attribute(("id", picture.id.to_string().as_str()));
1225    cnv_pr.push_attribute(("name", picture.name.as_str()));
1226    cnv_pr.push_attribute(("descr", picture.description.as_str()));
1227    writer.write_event(Event::Empty(cnv_pr))?;
1228    writer.write_event(Event::Start(BytesStart::new("p:cNvPicPr")))?;
1229    let mut pic_locks = BytesStart::new("a:picLocks");
1230    pic_locks.push_attribute(("noChangeAspect", "1"));
1231    writer.write_event(Event::Empty(pic_locks))?;
1232    writer.write_event(Event::End(BytesEnd::new("p:cNvPicPr")))?;
1233    writer.write_event(Event::Empty(BytesStart::new("p:nvPr")))?;
1234    writer.write_event(Event::End(BytesEnd::new("p:nvPicPr")))?;
1235
1236    writer.write_event(Event::Start(BytesStart::new("p:blipFill")))?;
1237    let mut blip = BytesStart::new("a:blip");
1238    blip.push_attribute(("r:embed", relationship_id.as_str()));
1239    // An additional `r:link`, alongside `r:embed`, when this picture also has an external source
1240    // (PowerPoint's own "embedded preview cache + externally-linked original" dual pattern; see
1241    // [`crate::model::Picture::external_link`]'s own doc comment). Shares `IMAGE_RELATIONSHIP_TYPE`
1242    // with `r:embed` — real packages use the same relationship type for both, only `TargetMode`
1243    // differs.
1244    if let Some(external_link) = &picture.external_link {
1245        let link_relationship_id =
1246            relationships.add_external(IMAGE_RELATIONSHIP_TYPE, external_link.as_str());
1247        blip.push_attribute(("r:link", link_relationship_id.as_str()));
1248    }
1249    writer.write_event(Event::Empty(blip))?;
1250    writer.write_event(Event::Start(BytesStart::new("a:stretch")))?;
1251    writer.write_event(Event::Empty(BytesStart::new("a:fillRect")))?;
1252    writer.write_event(Event::End(BytesEnd::new("a:stretch")))?;
1253    writer.write_event(Event::End(BytesEnd::new("p:blipFill")))?;
1254
1255    writer.write_event(Event::Start(BytesStart::new("p:spPr")))?;
1256    writer.write_event(Event::Start(BytesStart::new("a:xfrm")))?;
1257    let mut off = BytesStart::new("a:off");
1258    off.push_attribute(("x", picture.offset_emu.0.to_string().as_str()));
1259    off.push_attribute(("y", picture.offset_emu.1.to_string().as_str()));
1260    writer.write_event(Event::Empty(off))?;
1261    let mut ext = BytesStart::new("a:ext");
1262    ext.push_attribute(("cx", picture.extent_emu.0.to_string().as_str()));
1263    ext.push_attribute(("cy", picture.extent_emu.1.to_string().as_str()));
1264    writer.write_event(Event::Empty(ext))?;
1265    writer.write_event(Event::End(BytesEnd::new("a:xfrm")))?;
1266
1267    let empty_properties = drawing::ShapeProperties::default();
1268    let shape_properties = picture
1269        .shape_properties
1270        .as_ref()
1271        .unwrap_or(&empty_properties);
1272    drawing::write_geometry_fill_line_effects(writer, shape_properties, "rect")?;
1273    writer.write_event(Event::End(BytesEnd::new("p:spPr")))?;
1274
1275    writer.write_event(Event::End(BytesEnd::new("p:pic")))?;
1276    Ok(())
1277}
1278
1279/// Writes one `<p:pic>` carrying an `<a:videoFile>`/`<a:audioFile>` reference, from a
1280/// [`SlideMedia`]. Registers the media bytes as a package-wide-unique `ppt/media/mediaN.*` part and
1281/// a slide-scoped `video`/`audio`-typed relationship (`r:link`, resolved by
1282/// [`crate::model::MediaFormat::is_video`]); the poster frame, if set, is registered and referenced
1283/// the same way an ordinary [`Picture`]'s own image is. Does **not** write the
1284/// Microsoft-proprietary `p14:media` extension real PowerPoint 2010+ also emits — see
1285/// [`SlideMedia`]'s own doc comment.
1286fn write_media_shape<W: Write>(
1287    writer: &mut Writer<W>,
1288    media: &SlideMedia,
1289    state: &mut PackageState,
1290    relationships: &mut PartRelationships,
1291) -> Result<()> {
1292    let media_entry_name = state.media.register_bytes(
1293        "media",
1294        &media.data,
1295        media.format.extension(),
1296        media.format.content_type(),
1297    );
1298    let media_relationship_type = if media.format.is_video() {
1299        VIDEO_RELATIONSHIP_TYPE
1300    } else {
1301        AUDIO_RELATIONSHIP_TYPE
1302    };
1303    let media_relationship_id =
1304        relationships.add(media_relationship_type, format!("../{media_entry_name}"));
1305
1306    writer.write_event(Event::Start(BytesStart::new("p:pic")))?;
1307
1308    writer.write_event(Event::Start(BytesStart::new("p:nvPicPr")))?;
1309    let mut cnv_pr = BytesStart::new("p:cNvPr");
1310    cnv_pr.push_attribute(("id", media.id.to_string().as_str()));
1311    cnv_pr.push_attribute(("name", media.name.as_str()));
1312    writer.write_event(Event::Empty(cnv_pr))?;
1313    writer.write_event(Event::Start(BytesStart::new("p:cNvPicPr")))?;
1314    let mut pic_locks = BytesStart::new("a:picLocks");
1315    pic_locks.push_attribute(("noChangeAspect", "1"));
1316    writer.write_event(Event::Empty(pic_locks))?;
1317    writer.write_event(Event::End(BytesEnd::new("p:cNvPicPr")))?;
1318    writer.write_event(Event::Start(BytesStart::new("p:nvPr")))?;
1319    let media_element_name = if media.format.is_video() {
1320        "a:videoFile"
1321    } else {
1322        "a:audioFile"
1323    };
1324    let mut media_start = BytesStart::new(media_element_name);
1325    media_start.push_attribute(("r:link", media_relationship_id.as_str()));
1326    writer.write_event(Event::Empty(media_start))?;
1327    writer.write_event(Event::End(BytesEnd::new("p:nvPr")))?;
1328    writer.write_event(Event::End(BytesEnd::new("p:nvPicPr")))?;
1329
1330    writer.write_event(Event::Start(BytesStart::new("p:blipFill")))?;
1331    if let Some((poster_data, poster_format)) = &media.poster_image {
1332        let poster_entry_name = state.media.register_bytes(
1333            "image",
1334            poster_data,
1335            poster_format.extension(),
1336            poster_format.content_type(),
1337        );
1338        let poster_relationship_id =
1339            relationships.add(IMAGE_RELATIONSHIP_TYPE, format!("../{poster_entry_name}"));
1340        let mut blip = BytesStart::new("a:blip");
1341        blip.push_attribute(("r:embed", poster_relationship_id.as_str()));
1342        writer.write_event(Event::Empty(blip))?;
1343    }
1344    writer.write_event(Event::Start(BytesStart::new("a:stretch")))?;
1345    writer.write_event(Event::Empty(BytesStart::new("a:fillRect")))?;
1346    writer.write_event(Event::End(BytesEnd::new("a:stretch")))?;
1347    writer.write_event(Event::End(BytesEnd::new("p:blipFill")))?;
1348
1349    writer.write_event(Event::Start(BytesStart::new("p:spPr")))?;
1350    writer.write_event(Event::Start(BytesStart::new("a:xfrm")))?;
1351    write_point(writer, "a:off", "x", "y", media.offset_emu)?;
1352    write_point(writer, "a:ext", "cx", "cy", media.extent_emu)?;
1353    writer.write_event(Event::End(BytesEnd::new("a:xfrm")))?;
1354    drawing::write_geometry_fill_line_effects(
1355        writer,
1356        &drawing::ShapeProperties::default(),
1357        "rect",
1358    )?;
1359    writer.write_event(Event::End(BytesEnd::new("p:spPr")))?;
1360
1361    writer.write_event(Event::End(BytesEnd::new("p:pic")))?;
1362    Ok(())
1363}
1364
1365/// Writes one `<p:graphicFrame>` (`CT_GraphicalObjectFrame`) wrapping a `<c:chart r:id="..">`, from
1366/// a [`crate::model::SlideChart`]. Registers the chart as a package-wide-unique
1367/// `ppt/charts/chartN.xml` part (`state`) and a slide-scoped relationship (`relationships`). Note
1368/// the transform element here is `<p:xfrm>`, not `<a:xfrm>`/`<xdr:xfrm>` — confirmed against a real
1369/// chart-bearing `.pptx` fixture — and, unlike Excel's `SheetChart` (whose `xdr:xfrm` is a fixed,
1370/// meaningless placeholder, real positioning coming from the enclosing `xdr:twoCellAnchor`), this
1371/// one carries the chart's *real* position/ size, the same posture as [`write_picture`]'s own
1372/// `<a:xfrm>`.
1373fn write_slide_chart<W: Write>(
1374    writer: &mut Writer<W>,
1375    slide_chart: &crate::model::SlideChart,
1376    state: &mut PackageState,
1377    relationships: &mut PartRelationships,
1378) -> Result<()> {
1379    let entry_name = state.charts.register(&slide_chart.chart_space)?;
1380    let relationship_id = relationships.add(CHART_RELATIONSHIP_TYPE, format!("../{entry_name}"));
1381
1382    writer.write_event(Event::Start(BytesStart::new("p:graphicFrame")))?;
1383
1384    writer.write_event(Event::Start(BytesStart::new("p:nvGraphicFramePr")))?;
1385    let mut cnv_pr = BytesStart::new("p:cNvPr");
1386    cnv_pr.push_attribute(("id", slide_chart.id.to_string().as_str()));
1387    cnv_pr.push_attribute(("name", slide_chart.name.as_str()));
1388    writer.write_event(Event::Empty(cnv_pr))?;
1389    writer.write_event(Event::Empty(BytesStart::new("p:cNvGraphicFramePr")))?;
1390    writer.write_event(Event::Empty(BytesStart::new("p:nvPr")))?;
1391    writer.write_event(Event::End(BytesEnd::new("p:nvGraphicFramePr")))?;
1392
1393    writer.write_event(Event::Start(BytesStart::new("p:xfrm")))?;
1394    let mut off = BytesStart::new("a:off");
1395    off.push_attribute(("x", slide_chart.offset_emu.0.to_string().as_str()));
1396    off.push_attribute(("y", slide_chart.offset_emu.1.to_string().as_str()));
1397    writer.write_event(Event::Empty(off))?;
1398    let mut ext = BytesStart::new("a:ext");
1399    ext.push_attribute(("cx", slide_chart.extent_emu.0.to_string().as_str()));
1400    ext.push_attribute(("cy", slide_chart.extent_emu.1.to_string().as_str()));
1401    writer.write_event(Event::Empty(ext))?;
1402    writer.write_event(Event::End(BytesEnd::new("p:xfrm")))?;
1403
1404    writer.write_event(Event::Start(BytesStart::new("a:graphic")))?;
1405    let mut graphic_data = BytesStart::new("a:graphicData");
1406    graphic_data.push_attribute(("uri", CHART_NAMESPACE));
1407    writer.write_event(Event::Start(graphic_data))?;
1408    let mut c_chart = BytesStart::new("c:chart");
1409    c_chart.push_attribute(("xmlns:c", CHART_NAMESPACE));
1410    c_chart.push_attribute(("r:id", relationship_id.as_str()));
1411    writer.write_event(Event::Empty(c_chart))?;
1412    writer.write_event(Event::End(BytesEnd::new("a:graphicData")))?;
1413    writer.write_event(Event::End(BytesEnd::new("a:graphic")))?;
1414
1415    writer.write_event(Event::End(BytesEnd::new("p:graphicFrame")))?;
1416    Ok(())
1417}
1418
1419/// Writes one `<p:grpSp>` (`CT_GroupShape`) from a [`ShapeGroup`]: its own non-visual header, a
1420/// *real* `<p:grpSpPr>/<a:xfrm>` (unlike [`write_shape_tree`]'s always-zero one for the slide's own
1421/// top-level `<p:spTree>`), then its children via [`write_shapes`] — recursing naturally for a
1422/// nested group. Structure confirmed against a real fixture with nested groups.
1423fn write_group_shape<W: Write>(
1424    writer: &mut Writer<W>,
1425    group: &ShapeGroup,
1426    state: &mut PackageState,
1427    relationships: &mut PartRelationships,
1428) -> Result<()> {
1429    writer.write_event(Event::Start(BytesStart::new("p:grpSp")))?;
1430
1431    writer.write_event(Event::Start(BytesStart::new("p:nvGrpSpPr")))?;
1432    let mut cnv_pr = BytesStart::new("p:cNvPr");
1433    cnv_pr.push_attribute(("id", group.id.to_string().as_str()));
1434    cnv_pr.push_attribute(("name", group.name.as_str()));
1435    writer.write_event(Event::Empty(cnv_pr))?;
1436    writer.write_event(Event::Empty(BytesStart::new("p:cNvGrpSpPr")))?;
1437    writer.write_event(Event::Empty(BytesStart::new("p:nvPr")))?;
1438    writer.write_event(Event::End(BytesEnd::new("p:nvGrpSpPr")))?;
1439
1440    writer.write_event(Event::Start(BytesStart::new("p:grpSpPr")))?;
1441    let mut xfrm = BytesStart::new("a:xfrm");
1442    if group.rotation_60000ths != 0 {
1443        xfrm.push_attribute(("rot", group.rotation_60000ths.to_string().as_str()));
1444    }
1445    if group.flip_horizontal {
1446        xfrm.push_attribute(("flipH", "1"));
1447    }
1448    if group.flip_vertical {
1449        xfrm.push_attribute(("flipV", "1"));
1450    }
1451    writer.write_event(Event::Start(xfrm))?;
1452    write_point(writer, "a:off", "x", "y", group.offset_emu)?;
1453    write_point(writer, "a:ext", "cx", "cy", group.extent_emu)?;
1454    write_point(writer, "a:chOff", "x", "y", group.child_offset_emu)?;
1455    write_point(writer, "a:chExt", "cx", "cy", group.child_extent_emu)?;
1456    writer.write_event(Event::End(BytesEnd::new("a:xfrm")))?;
1457    writer.write_event(Event::End(BytesEnd::new("p:grpSpPr")))?;
1458
1459    write_shapes(writer, &group.shapes, state, relationships)?;
1460
1461    writer.write_event(Event::End(BytesEnd::new("p:grpSp")))?;
1462    Ok(())
1463}
1464
1465/// Writes a single two-attribute empty element with real (not necessarily zero) values, e.g.
1466/// `<a:off x="123" y="456"/>` — generalizes [`write_zero_point`] for callers (like
1467/// [`write_group_shape`]) that need a genuine, non-zero transform.
1468fn write_point<W: Write>(
1469    writer: &mut Writer<W>,
1470    element_name: &str,
1471    first_attribute: &str,
1472    second_attribute: &str,
1473    (first, second): (i64, i64),
1474) -> Result<()> {
1475    let mut start = BytesStart::new(element_name);
1476    start.push_attribute((first_attribute, first.to_string().as_str()));
1477    start.push_attribute((second_attribute, second.to_string().as_str()));
1478    writer.write_event(Event::Empty(start))?;
1479    Ok(())
1480}
1481
1482/// Writes one `<p:cxnSp>` (`CT_Connector`) from a [`Connector`]. **Not grounded on a real fixture**
1483/// — see `model.rs`'s top-level doc comment and [`Connector`]'s own doc comment.
1484fn write_connector<W: Write>(writer: &mut Writer<W>, connector: &Connector) -> Result<()> {
1485    writer.write_event(Event::Start(BytesStart::new("p:cxnSp")))?;
1486
1487    writer.write_event(Event::Start(BytesStart::new("p:nvCxnSpPr")))?;
1488    let mut cnv_pr = BytesStart::new("p:cNvPr");
1489    cnv_pr.push_attribute(("id", connector.id.to_string().as_str()));
1490    cnv_pr.push_attribute(("name", connector.name.as_str()));
1491    writer.write_event(Event::Empty(cnv_pr))?;
1492
1493    match (&connector.start_connection, &connector.end_connection) {
1494        (None, None) => {
1495            writer.write_event(Event::Empty(BytesStart::new("p:cNvCxnSpPr")))?;
1496        }
1497        _ => {
1498            writer.write_event(Event::Start(BytesStart::new("p:cNvCxnSpPr")))?;
1499            write_shape_connection(writer, "a:stCxn", connector.start_connection.as_ref())?;
1500            write_shape_connection(writer, "a:endCxn", connector.end_connection.as_ref())?;
1501            writer.write_event(Event::End(BytesEnd::new("p:cNvCxnSpPr")))?;
1502        }
1503    }
1504    writer.write_event(Event::Empty(BytesStart::new("p:nvPr")))?;
1505    writer.write_event(Event::End(BytesEnd::new("p:nvCxnSpPr")))?;
1506
1507    write_connector_properties(writer, &connector.properties)?;
1508
1509    writer.write_event(Event::End(BytesEnd::new("p:cxnSp")))?;
1510    Ok(())
1511}
1512
1513/// Writes `<a:stCxn>`/`<a:endCxn>` (`CT_Connection`) if `connection` is set; writes nothing
1514/// otherwise (the caller only enters this branch when at least one of the pair is set, but each is
1515/// independently optional).
1516fn write_shape_connection<W: Write>(
1517    writer: &mut Writer<W>,
1518    element_name: &str,
1519    connection: Option<&crate::model::ShapeConnection>,
1520) -> Result<()> {
1521    if let Some(connection) = connection {
1522        let mut start = BytesStart::new(element_name);
1523        start.push_attribute(("id", connection.shape_id.to_string().as_str()));
1524        start.push_attribute(("idx", connection.index.to_string().as_str()));
1525        writer.write_event(Event::Empty(start))?;
1526    }
1527    Ok(())
1528}
1529
1530/// Writes `<p:spPr>` for a connector — same as [`write_shape_properties`] but defaults to
1531/// `<a:prstGeom prst="line">` (a straight connector) rather than `"rect"` when
1532/// `properties.geometry` is unset, the connector-appropriate equivalent of the same "always write
1533/// geometry explicitly" discipline.
1534fn write_connector_properties<W: Write>(
1535    writer: &mut Writer<W>,
1536    properties: &ShapeProperties,
1537) -> Result<()> {
1538    writer.write_event(Event::Start(BytesStart::new("p:spPr")))?;
1539
1540    match &properties.transform {
1541        Some(transform) => drawing::write_transform(writer, transform)?,
1542        None => {
1543            writer.write_event(Event::Start(BytesStart::new("a:xfrm")))?;
1544            write_zero_point(writer, "a:off", "x", "y")?;
1545            write_zero_point(writer, "a:ext", "cx", "cy")?;
1546            writer.write_event(Event::End(BytesEnd::new("a:xfrm")))?;
1547        }
1548    }
1549
1550    drawing::write_geometry_fill_line_effects(writer, properties, "line")?;
1551
1552    writer.write_event(Event::End(BytesEnd::new("p:spPr")))?;
1553    Ok(())
1554}
1555
1556/// Writes one `<p:graphicFrame>` wrapping an `<a:tbl>` (`CT_Table`) from a [`SlideTable`].
1557/// Structure confirmed against real fixtures — note the frame's own transform element is
1558/// `<p:xfrm>`, same as [`write_slide_chart`]'s.
1559///
1560/// Every row is padded (with empty [`TableCell`]s) or truncated to exactly
1561/// `table.column_widths_emu.len()` cells — real PowerPoint requires one `<a:tc>` per `<a:gridCol>`
1562/// in every row (unlike WordprocessingML tables, where a spanned cell is simply omitted, see
1563/// [`TableCell`]'s own doc comment) — so a caller-supplied row that's too short/long never produces
1564/// a schema-invalid `<a:tbl>`.
1565fn write_table<W: Write>(writer: &mut Writer<W>, table: &SlideTable) -> Result<()> {
1566    writer.write_event(Event::Start(BytesStart::new("p:graphicFrame")))?;
1567
1568    writer.write_event(Event::Start(BytesStart::new("p:nvGraphicFramePr")))?;
1569    let mut cnv_pr = BytesStart::new("p:cNvPr");
1570    cnv_pr.push_attribute(("id", table.id.to_string().as_str()));
1571    cnv_pr.push_attribute(("name", table.name.as_str()));
1572    writer.write_event(Event::Empty(cnv_pr))?;
1573    writer.write_event(Event::Start(BytesStart::new("p:cNvGraphicFramePr")))?;
1574    let mut locks = BytesStart::new("a:graphicFrameLocks");
1575    locks.push_attribute(("noGrp", "1"));
1576    writer.write_event(Event::Empty(locks))?;
1577    writer.write_event(Event::End(BytesEnd::new("p:cNvGraphicFramePr")))?;
1578    writer.write_event(Event::Empty(BytesStart::new("p:nvPr")))?;
1579    writer.write_event(Event::End(BytesEnd::new("p:nvGraphicFramePr")))?;
1580
1581    writer.write_event(Event::Start(BytesStart::new("p:xfrm")))?;
1582    write_point(writer, "a:off", "x", "y", table.offset_emu)?;
1583    write_point(writer, "a:ext", "cx", "cy", table.extent_emu)?;
1584    writer.write_event(Event::End(BytesEnd::new("p:xfrm")))?;
1585
1586    writer.write_event(Event::Start(BytesStart::new("a:graphic")))?;
1587    let mut graphic_data = BytesStart::new("a:graphicData");
1588    graphic_data.push_attribute(("uri", TABLE_NAMESPACE));
1589    writer.write_event(Event::Start(graphic_data))?;
1590
1591    writer.write_event(Event::Start(BytesStart::new("a:tbl")))?;
1592    let mut tbl_pr = BytesStart::new("a:tblPr");
1593    if table.style_first_row {
1594        tbl_pr.push_attribute(("firstRow", "1"));
1595    }
1596    if table.style_first_column {
1597        tbl_pr.push_attribute(("firstCol", "1"));
1598    }
1599    if table.style_last_row {
1600        tbl_pr.push_attribute(("lastRow", "1"));
1601    }
1602    if table.style_last_column {
1603        tbl_pr.push_attribute(("lastCol", "1"));
1604    }
1605    if table.style_band_rows {
1606        tbl_pr.push_attribute(("bandRow", "1"));
1607    }
1608    if table.style_band_columns {
1609        tbl_pr.push_attribute(("bandCol", "1"));
1610    }
1611    writer.write_event(Event::Start(tbl_pr))?;
1612    let table_style_id = table.style_id.as_deref().unwrap_or(TABLE_STYLE_GUID);
1613    validate_table_style_id(table_style_id)?;
1614    writer.write_event(Event::Start(BytesStart::new("a:tableStyleId")))?;
1615    writer.write_event(Event::Text(xml_core::BytesText::new(table_style_id)))?;
1616    writer.write_event(Event::End(BytesEnd::new("a:tableStyleId")))?;
1617    writer.write_event(Event::End(BytesEnd::new("a:tblPr")))?;
1618
1619    let column_count = table.column_widths_emu.len();
1620    writer.write_event(Event::Start(BytesStart::new("a:tblGrid")))?;
1621    for width_emu in &table.column_widths_emu {
1622        let mut grid_col = BytesStart::new("a:gridCol");
1623        grid_col.push_attribute(("w", width_emu.to_string().as_str()));
1624        writer.write_event(Event::Empty(grid_col))?;
1625    }
1626    writer.write_event(Event::End(BytesEnd::new("a:tblGrid")))?;
1627
1628    for row in &table.rows {
1629        write_table_row(writer, row, column_count)?;
1630    }
1631
1632    writer.write_event(Event::End(BytesEnd::new("a:tbl")))?;
1633    writer.write_event(Event::End(BytesEnd::new("a:graphicData")))?;
1634    writer.write_event(Event::End(BytesEnd::new("a:graphic")))?;
1635
1636    writer.write_event(Event::End(BytesEnd::new("p:graphicFrame")))?;
1637    Ok(())
1638}
1639
1640/// Writes one `<a:tr h="..">` (`CT_TableRow`), padding/truncating its cells to exactly
1641/// `column_count` — see [`write_table`]'s own doc comment.
1642fn write_table_row<W: Write>(
1643    writer: &mut Writer<W>,
1644    row: &TableRow,
1645    column_count: usize,
1646) -> Result<()> {
1647    let mut tr = BytesStart::new("a:tr");
1648    tr.push_attribute(("h", row.height_emu.to_string().as_str()));
1649    writer.write_event(Event::Start(tr))?;
1650
1651    for index in 0..column_count {
1652        match row.cells.get(index) {
1653            Some(cell) => write_table_cell(writer, cell)?,
1654            None => write_table_cell(writer, &TableCell::new())?,
1655        }
1656    }
1657
1658    writer.write_event(Event::End(BytesEnd::new("a:tr")))?;
1659    Ok(())
1660}
1661
1662/// Writes one `<a:tc>` (`CT_TableCell`) — always with a real `<a:txBody>` (mandatory per the
1663/// schema, unlike an autoshape's own optional `p:txBody>`) and an `<a:tcPr>` reflecting
1664/// `cell.properties` (`<a:tcPr/>` self-closes, matching every real fixture checked for a
1665/// plainly-styled cell, when `properties` is `None`).
1666fn write_table_cell<W: Write>(writer: &mut Writer<W>, cell: &TableCell) -> Result<()> {
1667    let mut tc = BytesStart::new("a:tc");
1668    if let Some(span) = cell.horizontal_span {
1669        tc.push_attribute(("gridSpan", span.to_string().as_str()));
1670    }
1671    if let Some(span) = cell.vertical_span {
1672        tc.push_attribute(("rowSpan", span.to_string().as_str()));
1673    }
1674    if cell.horizontal_merge {
1675        tc.push_attribute(("hMerge", "1"));
1676    }
1677    if cell.vertical_merge {
1678        tc.push_attribute(("vMerge", "1"));
1679    }
1680    writer.write_event(Event::Start(tc))?;
1681
1682    writer.write_event(Event::Start(BytesStart::new("a:txBody")))?;
1683    match &cell.text_body {
1684        Some(text_body) => drawing::write_text_body(writer, text_body)?,
1685        None => {
1686            // `<a:txBody>` is mandatory (`minOccurs="1"` on `CT_TableCell`) even for a genuinely
1687            // empty cell — write a minimal but schema-complete body/paragraph rather than an
1688            // invalid empty wrapper.
1689            drawing::write_text_body(
1690                writer,
1691                &drawing::TextBody::new().with_paragraph(drawing::TextParagraph::new()),
1692            )?;
1693        }
1694    }
1695    writer.write_event(Event::End(BytesEnd::new("a:txBody")))?;
1696    write_table_cell_properties(writer, cell.properties.as_ref())?;
1697
1698    writer.write_event(Event::End(BytesEnd::new("a:tc")))?;
1699    Ok(())
1700}
1701
1702/// Writes `<a:tcPr>` (`CT_TableCellProperties`, point 8) — self-closing when `properties` is
1703/// `None`, matching the fixed behavior every fixture this crate checked uses for a plainly-styled
1704/// cell. Attribute order (`marL`/`marT`/`marR`/`marB`/`anchor`) and child order
1705/// (`lnL`/`lnR`/`lnT`/`lnB` then the fill choice) follow `CT_TableCellProperties`'s own schema
1706/// sequence, confirmed against a real fixture.
1707fn write_table_cell_properties<W: Write>(
1708    writer: &mut Writer<W>,
1709    properties: Option<&TableCellProperties>,
1710) -> Result<()> {
1711    let Some(properties) = properties else {
1712        writer.write_event(Event::Empty(BytesStart::new("a:tcPr")))?;
1713        return Ok(());
1714    };
1715
1716    let mut start = BytesStart::new("a:tcPr");
1717    if let Some(margin) = properties.margin_left_emu {
1718        start.push_attribute(("marL", margin.to_string().as_str()));
1719    }
1720    if let Some(margin) = properties.margin_top_emu {
1721        start.push_attribute(("marT", margin.to_string().as_str()));
1722    }
1723    if let Some(margin) = properties.margin_right_emu {
1724        start.push_attribute(("marR", margin.to_string().as_str()));
1725    }
1726    if let Some(margin) = properties.margin_bottom_emu {
1727        start.push_attribute(("marB", margin.to_string().as_str()));
1728    }
1729    if let Some(anchor) = &properties.anchor {
1730        start.push_attribute(("anchor", table_cell_anchor_token(anchor)));
1731    }
1732
1733    let has_children = properties.border_left.is_some()
1734        || properties.border_right.is_some()
1735        || properties.border_top.is_some()
1736        || properties.border_bottom.is_some()
1737        || properties.fill.is_some();
1738    if !has_children {
1739        writer.write_event(Event::Empty(start))?;
1740        return Ok(());
1741    }
1742
1743    writer.write_event(Event::Start(start))?;
1744    if let Some(border) = &properties.border_left {
1745        drawing::write_line_named(writer, "a:lnL", border)?;
1746    }
1747    if let Some(border) = &properties.border_right {
1748        drawing::write_line_named(writer, "a:lnR", border)?;
1749    }
1750    if let Some(border) = &properties.border_top {
1751        drawing::write_line_named(writer, "a:lnT", border)?;
1752    }
1753    if let Some(border) = &properties.border_bottom {
1754        drawing::write_line_named(writer, "a:lnB", border)?;
1755    }
1756    if let Some(fill) = &properties.fill {
1757        drawing::write_fill(writer, fill)?;
1758    }
1759    writer.write_event(Event::End(BytesEnd::new("a:tcPr")))?;
1760    Ok(())
1761}
1762
1763/// `ST_TextAnchoringType`'s own XML token, duplicated locally since
1764/// [`drawing::TextAnchor::xml_token`] is crate-private to `drawing` itself (only
1765/// [`drawing::write_text_body_properties`] needs it there — this is the first cross-crate need for
1766/// the same mapping, on `<a:tcPr anchor= "..">` rather than `<a:bodyPr anchor="..">`, same
1767/// `pub(crate)`-per- module posture already used for e.g. `SLIDE_RELATIONSHIP_TYPE`).
1768fn table_cell_anchor_token(anchor: &TextAnchor) -> &'static str {
1769    match anchor {
1770        TextAnchor::Top => "t",
1771        TextAnchor::Center => "ctr",
1772        TextAnchor::Bottom => "b",
1773        TextAnchor::Justified => "just",
1774        TextAnchor::Distributed => "dist",
1775    }
1776}
1777
1778/// Serializes the single fixed `ppt/slideMasters/slideMaster1.xml` (`CT_SlideMaster`) this crate
1779/// ever writes: an empty shape tree (no placeholder shapes — this crate's slides carry their own
1780/// placeholder info directly, see `model.rs`), the standard default color map, and a
1781/// `<p:sldLayoutIdLst>` with the one fixed slide layout this crate writes.
1782fn to_slide_master_xml() -> Result<Vec<u8>> {
1783    let mut writer = Writer::new(Vec::new());
1784    writer.write_event(Event::Decl(BytesDecl::new(
1785        "1.0",
1786        Some("UTF-8"),
1787        Some("yes"),
1788    )))?;
1789
1790    let mut root = BytesStart::new("p:sldMaster");
1791    root.push_attribute(("xmlns:a", A_NAMESPACE));
1792    root.push_attribute(("xmlns:r", R_NAMESPACE));
1793    root.push_attribute(("xmlns:p", P_NAMESPACE));
1794    writer.write_event(Event::Start(root))?;
1795
1796    writer.write_event(Event::Start(BytesStart::new("p:cSld")))?;
1797    write_shape_tree(
1798        &mut writer,
1799        &[],
1800        &mut PackageState::new(),
1801        &mut PartRelationships::new(),
1802    )?;
1803    writer.write_event(Event::End(BytesEnd::new("p:cSld")))?;
1804
1805    write_default_color_map(&mut writer)?;
1806
1807    writer.write_event(Event::Start(BytesStart::new("p:sldLayoutIdLst")))?;
1808    let mut layout_id = BytesStart::new("p:sldLayoutId");
1809    layout_id.push_attribute(("id", (SLIDE_MASTER_ID + 1).to_string().as_str()));
1810    layout_id.push_attribute(("r:id", "rId1"));
1811    writer.write_event(Event::Empty(layout_id))?;
1812    writer.write_event(Event::End(BytesEnd::new("p:sldLayoutIdLst")))?;
1813
1814    writer.write_event(Event::End(BytesEnd::new("p:sldMaster")))?;
1815    Ok(writer.into_inner())
1816}
1817
1818/// Writes `<p:clrMap>`'s fixed default mapping (every slot maps to itself — `bg1="lt1"`,
1819/// `tx1="dk1"`. — the same one real PowerPoint uses for a brand new default deck). Also the exact
1820/// mapping a real notes master uses, so [`to_notes_master_xml`] reuses this same function.
1821fn write_default_color_map<W: Write>(writer: &mut Writer<W>) -> Result<()> {
1822    let mut clr_map = BytesStart::new("p:clrMap");
1823    clr_map.push_attribute(("bg1", "lt1"));
1824    clr_map.push_attribute(("tx1", "dk1"));
1825    clr_map.push_attribute(("bg2", "lt2"));
1826    clr_map.push_attribute(("tx2", "dk2"));
1827    clr_map.push_attribute(("accent1", "accent1"));
1828    clr_map.push_attribute(("accent2", "accent2"));
1829    clr_map.push_attribute(("accent3", "accent3"));
1830    clr_map.push_attribute(("accent4", "accent4"));
1831    clr_map.push_attribute(("accent5", "accent5"));
1832    clr_map.push_attribute(("accent6", "accent6"));
1833    clr_map.push_attribute(("hlink", "hlink"));
1834    clr_map.push_attribute(("folHlink", "folHlink"));
1835    writer.write_event(Event::Empty(clr_map))?;
1836    Ok(())
1837}
1838
1839/// Serializes the single fixed `ppt/slideLayouts/slideLayout1.xml` (`CT_SlideLayout`) this crate
1840/// ever writes: an empty shape tree, and `<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>`
1841/// (inherits the master's color map rather than overriding it).
1842fn to_slide_layout_xml() -> Result<Vec<u8>> {
1843    let mut writer = Writer::new(Vec::new());
1844    writer.write_event(Event::Decl(BytesDecl::new(
1845        "1.0",
1846        Some("UTF-8"),
1847        Some("yes"),
1848    )))?;
1849
1850    let mut root = BytesStart::new("p:sldLayout");
1851    root.push_attribute(("xmlns:a", A_NAMESPACE));
1852    root.push_attribute(("xmlns:r", R_NAMESPACE));
1853    root.push_attribute(("xmlns:p", P_NAMESPACE));
1854    writer.write_event(Event::Start(root))?;
1855
1856    writer.write_event(Event::Start(BytesStart::new("p:cSld")))?;
1857    write_shape_tree(
1858        &mut writer,
1859        &[],
1860        &mut PackageState::new(),
1861        &mut PartRelationships::new(),
1862    )?;
1863    writer.write_event(Event::End(BytesEnd::new("p:cSld")))?;
1864
1865    write_color_map_override(&mut writer)?;
1866
1867    writer.write_event(Event::End(BytesEnd::new("p:sldLayout")))?;
1868    Ok(writer.into_inner())
1869}
1870
1871/// Serializes `ppt/theme/theme1.xml` (`CT_OfficeStyleSheet`) — `clrScheme`/ `fontScheme` come from
1872/// `theme` (Office's own built-in default, [`Theme::office_default`], when `None`, matching this
1873/// function's previous always-fixed behavior byte-for-byte for colors/fonts, modulo one deliberate
1874/// simplification: `dk1`/`lt1` are now always written as plain `<a:srgbClr>` rather than the live
1875/// `<a:sysClr>` binding real Word/PowerPoint themes use for those two slots — schema-valid in its
1876/// place and renders identically outside of that live system-color binding, which this crate has no
1877/// way to honor anyway; same posture `word-ooxml::Theme`'s own doc comment already documents for
1878/// itself). `fmtScheme` stays a fixed copy of Office's own built-in default regardless — see
1879/// [`Theme`]'s own doc comment for why.
1880fn to_theme_xml(theme: Option<&Theme>) -> Vec<u8> {
1881    let owned_default;
1882    let theme = match theme {
1883        Some(theme) => theme,
1884        None => {
1885            owned_default = Theme::office_default();
1886            &owned_default
1887        }
1888    };
1889
1890    let mut xml = String::new();
1891    xml.push_str(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#);
1892    xml.push_str(&format!(
1893        r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="{}">"#,
1894        xml_escape(&theme.name)
1895    ));
1896    xml.push_str("<a:themeElements>");
1897    xml.push_str(&to_color_scheme_xml(&theme.name, &theme.colors));
1898    xml.push_str(&to_font_scheme_xml(&theme.name, &theme.fonts));
1899    xml.push_str(FIXED_FMT_SCHEME_XML);
1900    xml.push_str("</a:themeElements>");
1901    xml.push_str("</a:theme>");
1902    xml.into_bytes()
1903}
1904
1905/// Serializes `<a:clrScheme name="..">`'s 12 color slots from a [`ColorScheme`] — always as
1906/// `<a:srgbClr>` (see [`to_theme_xml`]'s own doc comment for why `dk1`/`lt1`'s live `<a:sysClr>`
1907/// binding isn't modeled).
1908fn to_color_scheme_xml(name: &str, colors: &ColorScheme) -> String {
1909    format!(
1910        concat!(
1911            r#"<a:clrScheme name="{name}">"#,
1912            r#"<a:dk1><a:srgbClr val="{dk1}"/></a:dk1>"#,
1913            r#"<a:lt1><a:srgbClr val="{lt1}"/></a:lt1>"#,
1914            r#"<a:dk2><a:srgbClr val="{dk2}"/></a:dk2>"#,
1915            r#"<a:lt2><a:srgbClr val="{lt2}"/></a:lt2>"#,
1916            r#"<a:accent1><a:srgbClr val="{accent1}"/></a:accent1>"#,
1917            r#"<a:accent2><a:srgbClr val="{accent2}"/></a:accent2>"#,
1918            r#"<a:accent3><a:srgbClr val="{accent3}"/></a:accent3>"#,
1919            r#"<a:accent4><a:srgbClr val="{accent4}"/></a:accent4>"#,
1920            r#"<a:accent5><a:srgbClr val="{accent5}"/></a:accent5>"#,
1921            r#"<a:accent6><a:srgbClr val="{accent6}"/></a:accent6>"#,
1922            r#"<a:hlink><a:srgbClr val="{hlink}"/></a:hlink>"#,
1923            r#"<a:folHlink><a:srgbClr val="{fol_hlink}"/></a:folHlink>"#,
1924            "</a:clrScheme>",
1925        ),
1926        name = xml_escape(name),
1927        dk1 = colors.dark1,
1928        lt1 = colors.light1,
1929        dk2 = colors.dark2,
1930        lt2 = colors.light2,
1931        accent1 = colors.accent1,
1932        accent2 = colors.accent2,
1933        accent3 = colors.accent3,
1934        accent4 = colors.accent4,
1935        accent5 = colors.accent5,
1936        accent6 = colors.accent6,
1937        hlink = colors.hyperlink,
1938        fol_hlink = colors.followed_hyperlink,
1939    )
1940}
1941
1942/// Serializes `<a:fontScheme name="..">` from a [`FontScheme`] — Latin typeface only, `ea`/`cs`
1943/// always empty (see `model.rs`'s `FontScheme` doc comment).
1944fn to_font_scheme_xml(name: &str, fonts: &FontScheme) -> String {
1945    format!(
1946        concat!(
1947            r#"<a:fontScheme name="{name}">"#,
1948            r#"<a:majorFont><a:latin typeface="{major}"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>"#,
1949            r#"<a:minorFont><a:latin typeface="{minor}"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>"#,
1950            "</a:fontScheme>",
1951        ),
1952        name = xml_escape(name),
1953        major = xml_escape(&fonts.major_latin),
1954        minor = xml_escape(&fonts.minor_latin),
1955    )
1956}
1957
1958/// Escapes the handful of characters that are unsafe inside an XML attribute value —
1959/// `theme`/`fonts` come from caller-supplied strings (unlike every other piece of fixed XML this
1960/// module writes via `concat!`), so, unlike those, they can't just be trusted verbatim.
1961fn xml_escape(value: &str) -> String {
1962    value
1963        .replace('&', "&amp;")
1964        .replace('<', "&lt;")
1965        .replace('>', "&gt;")
1966        .replace('"', "&quot;")
1967}
1968
1969/// `<a:fmtScheme>` (`CT_BaseStyles` requires it alongside `clrScheme`/ `fontScheme`) — always
1970/// Office's own fixed built-in default, not customizable (see [`Theme`]'s own doc comment).
1971const FIXED_FMT_SCHEME_XML: &str = concat!(
1972    r#"<a:fmtScheme name="Office">"#,
1973    "<a:fillStyleLst>",
1974    r#"<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>"#,
1975    r#"<a:gradFill rotWithShape="1"><a:gsLst>"#,
1976    r#"<a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="50000"/><a:satMod val="300000"/></a:schemeClr></a:gs>"#,
1977    r#"<a:gs pos="35000"><a:schemeClr val="phClr"><a:tint val="37000"/><a:satMod val="300000"/></a:schemeClr></a:gs>"#,
1978    r#"<a:gs pos="100000"><a:schemeClr val="phClr"><a:tint val="15000"/><a:satMod val="350000"/></a:schemeClr></a:gs>"#,
1979    r#"</a:gsLst><a:lin ang="16200000" scaled="1"/></a:gradFill>"#,
1980    r#"<a:gradFill rotWithShape="1"><a:gsLst>"#,
1981    r#"<a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="100000"/><a:shade val="100000"/><a:satMod val="130000"/></a:schemeClr></a:gs>"#,
1982    r#"<a:gs pos="100000"><a:schemeClr val="phClr"><a:tint val="50000"/><a:shade val="100000"/><a:satMod val="350000"/></a:schemeClr></a:gs>"#,
1983    r#"</a:gsLst><a:lin ang="16200000" scaled="0"/></a:gradFill>"#,
1984    "</a:fillStyleLst>",
1985    "<a:lnStyleLst>",
1986    r#"<a:ln w="9525" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"><a:shade val="95000"/><a:satMod val="105000"/></a:schemeClr></a:solidFill><a:prstDash val="solid"/></a:ln>"#,
1987    r#"<a:ln w="25400" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln>"#,
1988    r#"<a:ln w="38100" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln>"#,
1989    "</a:lnStyleLst>",
1990    "<a:effectStyleLst>",
1991    r#"<a:effectStyle><a:effectLst><a:outerShdw blurRad="40000" dist="20000" dir="5400000" rotWithShape="0"><a:srgbClr val="000000"><a:alpha val="38000"/></a:srgbClr></a:outerShdw></a:effectLst></a:effectStyle>"#,
1992    r#"<a:effectStyle><a:effectLst><a:outerShdw blurRad="40000" dist="23000" dir="5400000" rotWithShape="0"><a:srgbClr val="000000"><a:alpha val="35000"/></a:srgbClr></a:outerShdw></a:effectLst></a:effectStyle>"#,
1993    "<a:effectStyle>",
1994    r#"<a:effectLst><a:outerShdw blurRad="40000" dist="23000" dir="5400000" rotWithShape="0"><a:srgbClr val="000000"><a:alpha val="35000"/></a:srgbClr></a:outerShdw></a:effectLst>"#,
1995    r#"<a:scene3d><a:camera prst="orthographicFront"><a:rot lat="0" lon="0" rev="0"/></a:camera><a:lightRig rig="threePt" dir="t"><a:rot lat="0" lon="0" rev="1200000"/></a:lightRig></a:scene3d>"#,
1996    r#"<a:sp3d><a:bevelT w="63500" h="25400"/></a:sp3d>"#,
1997    "</a:effectStyle>",
1998    "</a:effectStyleLst>",
1999    "<a:bgFillStyleLst>",
2000    r#"<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>"#,
2001    r#"<a:gradFill rotWithShape="1"><a:gsLst>"#,
2002    r#"<a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="40000"/><a:satMod val="350000"/></a:schemeClr></a:gs>"#,
2003    r#"<a:gs pos="40000"><a:schemeClr val="phClr"><a:tint val="45000"/><a:shade val="99000"/><a:satMod val="350000"/></a:schemeClr></a:gs>"#,
2004    r#"<a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="20000"/><a:satMod val="255000"/></a:schemeClr></a:gs>"#,
2005    r#"</a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="-80000" r="50000" b="180000"/></a:path></a:gradFill>"#,
2006    r#"<a:gradFill rotWithShape="1"><a:gsLst>"#,
2007    r#"<a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="80000"/><a:satMod val="300000"/></a:schemeClr></a:gs>"#,
2008    r#"<a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="30000"/><a:satMod val="200000"/></a:schemeClr></a:gs>"#,
2009    r#"</a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path></a:gradFill>"#,
2010    "</a:bgFillStyleLst>",
2011    "</a:fmtScheme>",
2012);
2013
2014/// Serializes `ppt/presProps.xml` (`CT_PresentationProperties`) — every child is optional
2015/// (`minOccurs="0"`), so the fixed empty element is schema-valid.
2016fn to_pres_props_xml() -> Vec<u8> {
2017    const XML: &str = concat!(
2018        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
2019        r#"<p:presentationPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>"#,
2020    );
2021    XML.as_bytes().to_vec()
2022}
2023
2024/// Serializes `ppt/viewProps.xml` (`CT_ViewProperties`) — same posture as `to_pres_props_xml`:
2025/// every child is optional.
2026fn to_view_props_xml() -> Vec<u8> {
2027    const XML: &str = concat!(
2028        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
2029        r#"<p:viewPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>"#,
2030    );
2031    XML.as_bytes().to_vec()
2032}
2033
2034/// Serializes `ppt/tableStyles.xml` (`CT_TableStyleList`). The root element's `def` attribute
2035/// (`ST_Guid`, required) stays the well-known "No Style, No Grid" GUID every default Office
2036/// installation ships (part of the OOXML specification's own reference material, not
2037/// implementation-specific) regardless of `table_styles` — it only matters as the fallback a table
2038/// with no explicit `style_id` resolves to (`SlideTable::style_id`'s own doc comment). Each entry
2039/// in `table_styles` becomes its own `<a:tblStyle>` — see [`crate::model::SlideTableStyle`]'s own
2040/// doc comment for scope.
2041fn to_table_styles_xml(table_styles: &[SlideTableStyle]) -> Result<Vec<u8>> {
2042    let mut writer = Writer::new(Vec::new());
2043    writer.write_event(Event::Decl(BytesDecl::new(
2044        "1.0",
2045        Some("UTF-8"),
2046        Some("yes"),
2047    )))?;
2048
2049    let mut root = BytesStart::new("a:tblStyleLst");
2050    root.push_attribute(("xmlns:a", A_NAMESPACE));
2051    root.push_attribute(("def", TABLE_STYLE_GUID));
2052
2053    if table_styles.is_empty() {
2054        writer.write_event(Event::Empty(root))?;
2055        return Ok(writer.into_inner());
2056    }
2057
2058    writer.write_event(Event::Start(root))?;
2059    for style in table_styles {
2060        write_table_style(&mut writer, style)?;
2061    }
2062    writer.write_event(Event::End(BytesEnd::new("a:tblStyleLst")))?;
2063    Ok(writer.into_inner())
2064}
2065
2066/// Writes one `<a:tblStyle styleId=".." styleName="..">` — only the parts actually set on
2067/// [`SlideTableStyle`] are written as children, same "only write what was actually set" convention
2068/// as everywhere else in this crate.
2069fn write_table_style<W: Write>(writer: &mut Writer<W>, style: &SlideTableStyle) -> Result<()> {
2070    validate_table_style_id(&style.id)?;
2071    let mut start = BytesStart::new("a:tblStyle");
2072    start.push_attribute(("styleId", style.id.as_str()));
2073    start.push_attribute(("styleName", style.name.as_str()));
2074    writer.write_event(Event::Start(start))?;
2075
2076    let parts: [(&str, &Option<TableStylePart>); 9] = [
2077        ("a:wholeTbl", &style.whole_table),
2078        ("a:band1H", &style.band1_horizontal),
2079        ("a:band2H", &style.band2_horizontal),
2080        ("a:band1V", &style.band1_vertical),
2081        ("a:band2V", &style.band2_vertical),
2082        ("a:firstRow", &style.first_row),
2083        ("a:lastRow", &style.last_row),
2084        ("a:firstCol", &style.first_column),
2085        ("a:lastCol", &style.last_column),
2086    ];
2087    for (element_name, part) in parts {
2088        if let Some(part) = part {
2089            write_table_style_part(writer, element_name, part)?;
2090        }
2091    }
2092
2093    writer.write_event(Event::End(BytesEnd::new("a:tblStyle")))?;
2094    Ok(())
2095}
2096
2097/// Writes one `<a:wholeTbl>{tcTxStyle}{tcStyle}</a:wholeTbl>`-shaped element
2098/// (`CT_TableStyleTextStyle` + `CT_TableStyleCellStyle`, see [`crate::model::TableStylePart`]'s own
2099/// doc comment for why this crate combines the two).
2100fn write_table_style_part<W: Write>(
2101    writer: &mut Writer<W>,
2102    element_name: &str,
2103    part: &TableStylePart,
2104) -> Result<()> {
2105    writer.write_event(Event::Start(BytesStart::new(element_name)))?;
2106
2107    let mut tc_tx_style = BytesStart::new("a:tcTxStyle");
2108    if part.bold {
2109        tc_tx_style.push_attribute(("b", "on"));
2110    }
2111    if part.italic {
2112        tc_tx_style.push_attribute(("i", "on"));
2113    }
2114    if let Some(color) = &part.text_color {
2115        writer.write_event(Event::Start(tc_tx_style))?;
2116        drawing::write_color(writer, color)?;
2117        writer.write_event(Event::End(BytesEnd::new("a:tcTxStyle")))?;
2118    } else {
2119        writer.write_event(Event::Empty(tc_tx_style))?;
2120    }
2121
2122    if let Some(fill) = &part.fill {
2123        writer.write_event(Event::Start(BytesStart::new("a:tcStyle")))?;
2124        writer.write_event(Event::Start(BytesStart::new("a:fill")))?;
2125        drawing::write_fill(writer, fill)?;
2126        writer.write_event(Event::End(BytesEnd::new("a:fill")))?;
2127        writer.write_event(Event::End(BytesEnd::new("a:tcStyle")))?;
2128    } else {
2129        writer.write_event(Event::Empty(BytesStart::new("a:tcStyle")))?;
2130    }
2131
2132    writer.write_event(Event::End(BytesEnd::new(element_name)))?;
2133    Ok(())
2134}
2135
2136/// Serializes `docProps/core.xml` from a presentation's [`DocumentProperties`] — a direct port of
2137/// `excel-ooxml`'s own `to_core_properties_xml` (see that function's own doc comment).
2138/// Every `CT_CoreProperties` child is optional, so an empty `<cp:coreProperties>` root remains
2139/// schema-valid when every field is `None`.
2140fn to_core_properties_xml(properties: &DocumentProperties) -> Result<Vec<u8>> {
2141    let mut writer = Writer::new(Vec::new());
2142    writer.write_event(Event::Decl(BytesDecl::new(
2143        "1.0",
2144        Some("UTF-8"),
2145        Some("yes"),
2146    )))?;
2147
2148    let mut root = BytesStart::new("cp:coreProperties");
2149    root.push_attribute((
2150        "xmlns:cp",
2151        "http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
2152    ));
2153    root.push_attribute(("xmlns:dc", "http://purl.org/dc/elements/1.1/"));
2154    root.push_attribute(("xmlns:dcterms", "http://purl.org/dc/terms/"));
2155    root.push_attribute(("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"));
2156
2157    let has_any = properties.title.is_some()
2158        || properties.author.is_some()
2159        || properties.subject.is_some()
2160        || properties.keywords.is_some();
2161    if !has_any {
2162        writer.write_event(Event::Empty(root))?;
2163        return Ok(writer.into_inner());
2164    }
2165    writer.write_event(Event::Start(root))?;
2166
2167    if let Some(title) = &properties.title {
2168        write_text_element(&mut writer, "dc:title", title)?;
2169    }
2170    if let Some(author) = &properties.author {
2171        write_text_element(&mut writer, "dc:creator", author)?;
2172    }
2173    if let Some(subject) = &properties.subject {
2174        write_text_element(&mut writer, "dc:subject", subject)?;
2175    }
2176    if let Some(keywords) = &properties.keywords {
2177        write_text_element(&mut writer, "cp:keywords", keywords)?;
2178    }
2179
2180    writer.write_event(Event::End(BytesEnd::new("cp:coreProperties")))?;
2181    Ok(writer.into_inner())
2182}
2183
2184/// Serializes `docProps/app.xml` (`CT_Properties`) from a presentation's [`DocumentProperties`].
2185/// `Application` is always set (mirrors `word-ooxml`/`excel-ooxml`'s own extended properties);
2186/// `Slides` is filled in from the actual slide count since it costs nothing extra and real
2187/// PowerPoint always sets it; `Company`/`Manager`/`HyperlinkBase` are each set only when the
2188/// matching `DocumentProperties` field is.
2189fn to_extended_properties_xml(
2190    slide_count: usize,
2191    properties: &DocumentProperties,
2192) -> Result<Vec<u8>> {
2193    let mut writer = Writer::new(Vec::new());
2194    writer.write_event(Event::Decl(BytesDecl::new(
2195        "1.0",
2196        Some("UTF-8"),
2197        Some("yes"),
2198    )))?;
2199
2200    let mut root = BytesStart::new("Properties");
2201    root.push_attribute((
2202        "xmlns",
2203        "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties",
2204    ));
2205    writer.write_event(Event::Start(root))?;
2206
2207    write_text_element(&mut writer, "Application", "office-toolkit")?;
2208    write_text_element(&mut writer, "Slides", slide_count.to_string().as_str())?;
2209    if let Some(company) = &properties.company {
2210        write_text_element(&mut writer, "Company", company)?;
2211    }
2212    if let Some(manager) = &properties.manager {
2213        write_text_element(&mut writer, "Manager", manager)?;
2214    }
2215    if let Some(hyperlink_base) = &properties.hyperlink_base {
2216        write_text_element(&mut writer, "HyperlinkBase", hyperlink_base)?;
2217    }
2218
2219    writer.write_event(Event::End(BytesEnd::new("Properties")))?;
2220    Ok(writer.into_inner())
2221}
2222
2223/// Serializes `docProps/custom.xml` (OPC custom properties) from a presentation's
2224/// [`DocumentProperties::custom_properties`] — only called when non-empty. A direct port of
2225/// `excel-ooxml`'s own `to_custom_properties_xml` — see that function's own doc comment for the
2226/// `fmtid`/`pid` scheme.
2227fn to_custom_properties_xml(
2228    custom_properties: &[(String, CustomPropertyValue)],
2229) -> Result<Vec<u8>> {
2230    let mut writer = Writer::new(Vec::new());
2231    writer.write_event(Event::Decl(BytesDecl::new(
2232        "1.0",
2233        Some("UTF-8"),
2234        Some("yes"),
2235    )))?;
2236
2237    let mut root = BytesStart::new("Properties");
2238    root.push_attribute(("xmlns", CUSTOM_PROPERTIES_NAMESPACE));
2239    root.push_attribute(("xmlns:vt", CUSTOM_PROPERTIES_VT_NAMESPACE));
2240    writer.write_event(Event::Start(root))?;
2241
2242    for (index, (name, value)) in custom_properties.iter().enumerate() {
2243        let pid = FIRST_CUSTOM_PROPERTY_PID + index as i32;
2244
2245        let mut property = BytesStart::new("property");
2246        property.push_attribute(("fmtid", CUSTOM_PROPERTY_FMTID));
2247        property.push_attribute(("pid", pid.to_string().as_str()));
2248        property.push_attribute(("name", name.as_str()));
2249        writer.write_event(Event::Start(property))?;
2250
2251        let (variant_element, text) = match value {
2252            CustomPropertyValue::Text(text) => ("vt:lpwstr", text.clone()),
2253            CustomPropertyValue::Bool(value) => (
2254                "vt:bool",
2255                if *value {
2256                    "true".to_string()
2257                } else {
2258                    "false".to_string()
2259                },
2260            ),
2261            CustomPropertyValue::Int(value) => ("vt:i4", value.to_string()),
2262            CustomPropertyValue::Number(value) => ("vt:r8", value.to_string()),
2263        };
2264        write_text_element(&mut writer, variant_element, &text)?;
2265
2266        writer.write_event(Event::End(BytesEnd::new("property")))?;
2267    }
2268
2269    writer.write_event(Event::End(BytesEnd::new("Properties")))?;
2270    Ok(writer.into_inner())
2271}
2272
2273/// Writes `<element_name>{text}</element_name>`, a tiny helper used by every docProps writer above
2274/// to cut down on repetition.
2275fn write_text_element<W: Write>(
2276    writer: &mut Writer<W>,
2277    element_name: &str,
2278    text: &str,
2279) -> Result<()> {
2280    writer.write_event(Event::Start(BytesStart::new(element_name)))?;
2281    writer.write_event(Event::Text(xml_core::BytesText::new(text)))?;
2282    writer.write_event(Event::End(BytesEnd::new(element_name)))?;
2283    Ok(())
2284}
2285
2286/// Serializes the single fixed `ppt/notesMasters/notesMaster1.xml` (`CT_NotesMaster`) this crate
2287/// ever writes: an empty shape tree, the standard default color map (see
2288/// [`write_default_color_map`]). `<p:notesStyle>` (default paragraph formatting for notes text) is
2289/// `minOccurs="0"` and omitted, same "skip optional, out-of-scope boilerplate" posture as the slide
2290/// master's own `<p:txStyles>`.
2291fn to_notes_master_xml() -> Result<Vec<u8>> {
2292    let mut writer = Writer::new(Vec::new());
2293    writer.write_event(Event::Decl(BytesDecl::new(
2294        "1.0",
2295        Some("UTF-8"),
2296        Some("yes"),
2297    )))?;
2298
2299    let mut root = BytesStart::new("p:notesMaster");
2300    root.push_attribute(("xmlns:a", A_NAMESPACE));
2301    root.push_attribute(("xmlns:r", R_NAMESPACE));
2302    root.push_attribute(("xmlns:p", P_NAMESPACE));
2303    writer.write_event(Event::Start(root))?;
2304
2305    writer.write_event(Event::Start(BytesStart::new("p:cSld")))?;
2306    write_shape_tree(
2307        &mut writer,
2308        &[],
2309        &mut PackageState::new(),
2310        &mut PartRelationships::new(),
2311    )?;
2312    writer.write_event(Event::End(BytesEnd::new("p:cSld")))?;
2313
2314    write_default_color_map(&mut writer)?;
2315
2316    writer.write_event(Event::End(BytesEnd::new("p:notesMaster")))?;
2317    Ok(writer.into_inner())
2318}
2319
2320/// Serializes one `ppt/notesSlides/notesSlideN.xml` from a slide's speaker notes text. The root
2321/// element is `<p:notes>` (`CT_NotesSlide`) — note this differs from the part's own file name
2322/// convention, confirmed against a real notes-bearing `.pptx` fixture. Real PowerPoint always wraps
2323/// notes text in two placeholder shapes — a `sldImg` placeholder (the slide thumbnail area, left
2324/// empty here — this crate doesn't render slide thumbnails) and a `body` placeholder at `idx="1"`
2325/// (the actual notes text) — reconstructed here the same way, reusing
2326/// [`write_shape_tree`]/[`write_auto_shape`] directly rather than duplicating their
2327/// placeholder-writing logic. A third, optional `sldNum` placeholder real PowerPoint also writes is
2328/// omitted (cosmetic, out of scope).
2329fn to_notes_slide_xml(notes: &drawing::TextBody) -> Result<Vec<u8>> {
2330    let mut writer = Writer::new(Vec::new());
2331    writer.write_event(Event::Decl(BytesDecl::new(
2332        "1.0",
2333        Some("UTF-8"),
2334        Some("yes"),
2335    )))?;
2336
2337    let mut root = BytesStart::new("p:notes");
2338    root.push_attribute(("xmlns:a", A_NAMESPACE));
2339    root.push_attribute(("xmlns:r", R_NAMESPACE));
2340    root.push_attribute(("xmlns:p", P_NAMESPACE));
2341    writer.write_event(Event::Start(root))?;
2342
2343    let slide_image_shape = Shape::AutoShape(
2344        AutoShape::new(2, "Image de la diapositive")
2345            .with_placeholder(Placeholder::new(PlaceholderKind::SlideImage)),
2346    );
2347    let body_shape = Shape::AutoShape(
2348        AutoShape::new(3, "Texte de la note")
2349            .with_placeholder(Placeholder::new(PlaceholderKind::Body).with_index(1))
2350            .with_text_body(notes.clone()),
2351    );
2352
2353    writer.write_event(Event::Start(BytesStart::new("p:cSld")))?;
2354    write_shape_tree(
2355        &mut writer,
2356        &[slide_image_shape, body_shape],
2357        &mut PackageState::new(),
2358        &mut PartRelationships::new(),
2359    )?;
2360    writer.write_event(Event::End(BytesEnd::new("p:cSld")))?;
2361
2362    write_color_map_override(&mut writer)?;
2363
2364    writer.write_event(Event::End(BytesEnd::new("p:notes")))?;
2365    Ok(writer.into_inner())
2366}
2367
2368/// Serializes one `ppt/comments/commentN.xml` (`CT_CommentList`) from a slide's [`SlideComment`]s,
2369/// registering each comment's author against `authors` (assigning a numeric id, deduplicated by
2370/// name) along the way. **Not grounded on a real fixture** — see `model.rs`'s top-level doc
2371/// comment.
2372fn to_comments_xml(comments: &[SlideComment], authors: &mut CommentAuthorRegistry) -> Vec<u8> {
2373    let mut xml = String::new();
2374    xml.push_str(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#);
2375    xml.push_str(r#"<p:cmLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">"#);
2376    for comment in comments {
2377        let author_id = authors.id_for(&comment.author, &comment.initials);
2378        xml.push_str(&format!(
2379            r#"<p:cm authorId="{author_id}" dt="{dt}" idx="1"><p:pos x="{x}" y="{y}"/><p:text>{text}</p:text></p:cm>"#,
2380            author_id = author_id,
2381            dt = xml_escape(&comment.date),
2382            x = comment.position_emu.0,
2383            y = comment.position_emu.1,
2384            text = xml_escape(&comment.text),
2385        ));
2386    }
2387    xml.push_str("</p:cmLst>");
2388    xml.into_bytes()
2389}
2390
2391/// Serializes `ppt/commentAuthors.xml` (`CT_CommentAuthorList`) from the package-wide deduplicated
2392/// author list collected while writing every slide's own comments (see [`CommentAuthorRegistry`]'s
2393/// own doc comment). `<p:cmAuthor>`'s `lastIdx`/`clrIdx` attributes are mandatory per
2394/// `CT_CommentAuthor` — `lastIdx` (the highest comment index this author has authored) is always
2395/// written as `1` (this crate never assigns a comment any `idx` other than `1`, see
2396/// [`to_comments_xml`]); `clrIdx` (which of PowerPoint's author-color swatches to use) cycles
2397/// through 0-3 by registration order, matching real PowerPoint's own round-robin assignment.
2398fn to_comment_authors_xml(authors: &CommentAuthorRegistry) -> Vec<u8> {
2399    let mut xml = String::new();
2400    xml.push_str(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#);
2401    xml.push_str(
2402        r#"<p:cmAuthorLst xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">"#,
2403    );
2404    for (id, (name, initials)) in authors.authors.iter().enumerate() {
2405        xml.push_str(&format!(
2406            r#"<p:cmAuthor id="{id}" name="{name}" initials="{initials}" lastIdx="1" clrIdx="{clr_idx}"/>"#,
2407            id = id,
2408            name = xml_escape(name),
2409            initials = xml_escape(initials),
2410            clr_idx = id % 4,
2411        ));
2412    }
2413    xml.push_str("</p:cmAuthorLst>");
2414    xml.into_bytes()
2415}