Skip to main content

powerpoint_ooxml/
model.rs

1//! In-memory model of a `.pptx` package.
2//!
3//! A presentation is a sequence of slides; a slide is a sequence of [`Shape`]s, an enum covering
4//! every distinct shape kind PresentationML defines: plain autoshapes with no placeholder
5//! inheritance, images (`Shape::Picture`), charts (`Shape::Chart`), placeholder metadata on
6//! autoshapes, per-slide speaker notes, shape groups (`Shape::Group`), connectors
7//! (`Shape::Connector`), tables (`Shape::Table`), per-slide comments ([`SlideComment`]), and a
8//! customizable color/font theme ([`Theme`]). A fully customizable slide layout/master model,
9//! backgrounds, and SmartArt are not modeled.
10//!
11//! [`Connector`] and [`SlideComment`] have no real fixture in this project's local corpus to ground
12//! their structure against (unlike every other shape kind, which was cross-checked against a real,
13//! non-synthetic `.pptx`; see each type's own doc comment). Their shape is instead built directly
14//! from ECMA-376's own schema text, the same fallback used whenever no fixture happens to exercise
15//! a given property (e.g. `word-ooxml`'s `CT_TcBorders` child sequence). Flagged here for extra
16//! scrutiny once opened in a real PowerPoint installation.
17//!
18//! A shape's content (position/size, outline, fill, text) reuses the `drawing` crate's
19//! [`drawing::ShapeProperties`]/[`drawing::TextBody`] directly rather than redefining them — the
20//! same shared DrawingML vocabulary already used by `word-ooxml` (`<w:drawing>`) and `excel-ooxml`
21//! (`xdr:sp`/`xdr:pic`). See `drawing`'s own top-level doc comment: it explicitly anticipated
22//! PowerPoint as the format that would lean on it "the most heavily of the three, since a slide's
23//! shapes essentially *are* its content". A chart on a slide reuses the `chart` crate's
24//! `ChartSpace` verbatim, same posture as `word-ooxml`/ `excel-ooxml`'s own embedded charts.
25
26use chart::ChartSpace;
27use drawing::{Color, Fill, Line, ShapeProperties, TextAnchor, TextBody};
28
29/// EMUs (English Metric Units) per inch — 914,400, the same conversion factor
30/// `word-ooxml`/`drawing` already use for every EMU-denominated field.
31pub const EMU_PER_INCH: i64 = 914_400;
32
33/// The default slide size this crate uses when a [`Presentation`] doesn't override it: 16:9
34/// widescreen, `12192000 x 6858000` EMU (13.333 x 7.5 inches) — real PowerPoint's own current
35/// default.
36pub const DEFAULT_SLIDE_WIDTH_EMU: i64 = 12_192_000;
37pub const DEFAULT_SLIDE_HEIGHT_EMU: i64 = 6_858_000;
38
39/// The default notes page size (4:3 portrait, `6858000 x 9144000` EMU), same posture: not
40/// customizable, just written as a fixed, schema-complete value so a generated presentation always
41/// has a valid `<p:notesSz>` (mandatory per `CT_Presentation`), matching the real fixture's own
42/// value.
43pub const DEFAULT_NOTES_WIDTH_EMU: i64 = 6_858_000;
44pub const DEFAULT_NOTES_HEIGHT_EMU: i64 = 9_144_000;
45
46/// A `.pptx` presentation: an ordered list of slides, plus the slide size.
47///
48/// A real PowerPoint package also always carries a slide master, at least one slide layout, and a
49/// theme — but this crate has no customizable model for the slide master/layout (still a single
50/// minimal, fixed pair under the hood; the reader ignores their content entirely, it only resolves
51/// each slide's own shapes). The theme, however, *is* customizable (`theme`) — unlike the
52/// master/layout, a slide master's theme relationship is mandatory per ECMA-376
53/// but its actual color/font content is the single most visible piece of a deck's visual identity,
54/// so it gets its own model rather than staying permanently fixed like `excel-ooxml`'s theme.
55#[derive(Debug, Clone, PartialEq)]
56pub struct Presentation {
57    /// The slides, in display order.
58    pub slides: Vec<Slide>,
59    /// `<p:sldSz cx=".." cy="..">`, in EMUs.
60    pub slide_width_emu: i64,
61    pub slide_height_emu: i64,
62    /// This presentation's color/font theme. `None` (the default) still always produces a complete
63    /// `ppt/theme/theme1.xml` — the part itself is mandatory, not optional, unlike e.g.
64    /// `word-ooxml::Document.theme` — just with Office's own built-in default colors/fonts
65    /// ([`Theme::office_default`]) rather than a caller-supplied palette.
66    pub theme: Option<Theme>,
67    /// Document metadata (`docProps/core.xml`/`app.xml`/`custom.xml`). Ports `excel-ooxml`'s own
68    /// [`DocumentProperties`] posture unchanged (same fields, same "each `None`/empty stays an
69    /// omitted, schema-valid element/part" behavior).
70    pub properties: DocumentProperties,
71    /// Package-wide custom table styles (`ppt/tableStyles.xml`'s own `<a:tblStyle>` entries, beyond
72    /// the single fixed built-in one this crate always declares as `def`). Referenced by id from
73    /// [`SlideTable::style_id`]. An empty `Vec` (the default) means every table in this
74    /// presentation uses the fixed built-in style, unchanged.
75    pub table_styles: Vec<SlideTableStyle>,
76    /// TrueType/OpenType fonts embedded directly in the package (`ppt/fonts/fontN.fntdata`,
77    /// `<p:embeddedFontLst>`). Lets a presentation carry a non-standard font so it renders
78    /// correctly even on a machine that doesn't have that font installed. An empty `Vec` (the
79    /// default) omits `<p:embeddedFontLst>` entirely and leaves `<p:presentation>`'s own
80    /// `embedTrueTypeFonts` attribute unset, same "only write what was actually set" convention as
81    /// every other optional part in this crate.
82    pub embedded_fonts: Vec<EmbeddedFont>,
83}
84
85impl Presentation {
86    /// Creates an empty presentation (no slides), with the default 16:9 widescreen slide size and
87    /// no custom theme (Office's own built-in default colors/fonts are used).
88    pub fn new() -> Self {
89        Self {
90            slides: Vec::new(),
91            slide_width_emu: DEFAULT_SLIDE_WIDTH_EMU,
92            slide_height_emu: DEFAULT_SLIDE_HEIGHT_EMU,
93            theme: None,
94            properties: DocumentProperties::new(),
95            table_styles: Vec::new(),
96            embedded_fonts: Vec::new(),
97        }
98    }
99
100    /// Appends a slide.
101    pub fn with_slide(mut self, slide: Slide) -> Self {
102        self.slides.push(slide);
103        self
104    }
105
106    /// Appends an empty slide and returns a mutable reference to it, to build it up in place
107    /// (`presentation.add_slide().add_shape(..)`). A `&mut self` counterpart to
108    /// [`Self::with_slide`], for building a presentation slide-by-slide in a loop without the
109    /// consuming builder's `presentation = presentation.with_slide(..)` reassignment on every
110    /// iteration.
111    pub fn add_slide(&mut self) -> &mut Slide {
112        self.slides.push(Slide::new());
113        self.slides.last_mut().expect("a slide was just pushed")
114    }
115
116    /// Overrides the slide size (default: 16:9 widescreen).
117    pub fn with_slide_size(mut self, width_emu: i64, height_emu: i64) -> Self {
118        self.slide_width_emu = width_emu;
119        self.slide_height_emu = height_emu;
120        self
121    }
122
123    /// Sets a custom color/font theme, replacing Office's own built-in default.
124    pub fn with_theme(mut self, theme: Theme) -> Self {
125        self.theme = Some(theme);
126        self
127    }
128
129    /// Sets this presentation's document metadata.
130    pub fn with_properties(mut self, properties: DocumentProperties) -> Self {
131        self.properties = properties;
132        self
133    }
134
135    /// Appends a custom table style.
136    pub fn with_table_style(mut self, table_style: SlideTableStyle) -> Self {
137        self.table_styles.push(table_style);
138        self
139    }
140
141    /// Appends an embedded font.
142    pub fn with_embedded_font(mut self, font: EmbeddedFont) -> Self {
143        self.embedded_fonts.push(font);
144        self
145    }
146}
147
148impl Default for Presentation {
149    fn default() -> Self {
150        Self::new()
151    }
152}
153
154/// A single slide: an ordered list of shapes (`<p:spTree>`'s children), plus optional speaker
155/// notes.
156#[derive(Debug, Clone, PartialEq, Default)]
157pub struct Slide {
158    pub shapes: Vec<Shape>,
159    /// This slide's speaker notes (`ppt/notesSlides/notesSlideN.xml`'s own "body" placeholder
160    /// text), if any. `None` (the default) means no notes part is written for this slide at all —
161    /// real PowerPoint packages commonly have no notes for most slides, so this stays a genuinely
162    /// optional, per-slide part rather than always-present boilerplate (mirrors `word-ooxml`'s
163    /// header/footer, `styles.xml`, etc. — every optional part in this workspace follows the same
164    /// "only written when actually set" convention). See [`Presentation`]'s writer for the fixed
165    /// notes-master/notes-slide boilerplate this produces around the text itself.
166    pub notes: Option<TextBody>,
167    /// This slide's comments (`ppt/comments/commentN.xml`), if any — a genuinely optional part,
168    /// only written when non-empty, same "only written when actually set" convention as `notes`.
169    /// See [`SlideComment`]'s own doc comment for the caveat that this feature isn't grounded on a
170    /// real fixture.
171    pub comments: Vec<SlideComment>,
172    /// `<p:cSld>/<p:bg>/<p:bgPr>` — this slide's own background fill, overriding whatever the slide
173    /// layout/master would otherwise show through. `None` (the default) omits `<p:bg>` entirely —
174    /// the slide simply inherits its background from the layout/master chain, same "no override"
175    /// default every other optional field in this crate uses. Only the direct fill choice
176    /// (`<p:bgPr>`, `EG_FillProperties`) is modeled — `<p:bgRef>` (a reference into the theme's own
177    /// background style list) is not. Grounded against a real fixture (`ppt/slides/slide2.xml`),
178    /// which confirmed `<p:bg>` as `<p:cSld>`'s very first child, directly wrapping `<p:bgPr>`
179    /// before any fill.
180    pub background: Option<Fill>,
181    /// `<p:sld show="0">` (`CT_Slide`'s own `show` attribute) — hides this slide from the normal
182    /// slide-show sequence (it's still present in the deck, just skipped when presenting;
183    /// PowerPoint's own "Hide Slide" toggle). `false` (the default) omits the attribute, matching
184    /// the schema's own default of a shown slide. Grounded against a real fixture.
185    pub hidden: bool,
186    /// `<p:sld name=".">` — an optional display name for this slide (distinct from its title
187    /// placeholder's own text), shown e.g. in PowerPoint's Slide Navigator/Outline view. `None`
188    /// omits the attribute entirely. **Not grounded on a fixture** — no corpus `.pptx` file
189    /// examined actually sets it — modeled directly from `CT_Slide`'s schema anyway, the same
190    /// posture already used for e.g. [`crate::model::SlideComment`]/[`crate::model::Connector`].
191    pub name: Option<String>,
192    /// `<p:sld showMasterSp="0">` (`CT_Slide`'s own `showMasterSp` attribute, inverted here) —
193    /// hides this slide's inherited slide- master placeholder graphics/decorations (e.g. a logo or
194    /// footer bar drawn directly on the master, not through a placeholder this slide overrides).
195    /// The schema's own default is `showMasterSp="1"` (shown) when the attribute is omitted — this
196    /// field is named/stored inverted (`false` = shown, matching every other boolean "override"
197    /// field in this crate, e.g. `hidden`) so that `Slide::new`'s all-`false` default already
198    /// matches real PowerPoint's own default behavior, and the attribute is only ever written (as
199    /// `"0"`) when explicitly hidden. Grounded against real fixtures.
200    pub hide_master_graphics: bool,
201}
202
203impl Slide {
204    /// Creates an empty slide (no shapes, no notes, no comments, no background override, shown,
205    /// unnamed, showing inherited master graphics).
206    pub fn new() -> Self {
207        Self::default()
208    }
209
210    /// Appends a shape.
211    pub fn with_shape(mut self, shape: Shape) -> Self {
212        self.shapes.push(shape);
213        self
214    }
215
216    /// Appends a shape and returns a mutable reference to it. A `&mut self` counterpart to
217    /// [`Self::with_shape`], same rationale as [`Presentation::add_slide`].
218    pub fn add_shape(&mut self, shape: Shape) -> &mut Shape {
219        self.shapes.push(shape);
220        self.shapes.last_mut().expect("a shape was just pushed")
221    }
222
223    /// Hides this slide from the normal slide-show sequence (`show="0"`).
224    pub fn with_hidden(mut self, hidden: bool) -> Self {
225        self.hidden = hidden;
226        self
227    }
228
229    /// Sets this slide's display name (`<p:sld name="..">`).
230    pub fn with_name(mut self, name: impl Into<String>) -> Self {
231        self.name = Some(name.into());
232        self
233    }
234
235    /// Sets this slide's speaker notes.
236    pub fn with_notes(mut self, notes: TextBody) -> Self {
237        self.notes = Some(notes);
238        self
239    }
240
241    /// Appends a comment.
242    pub fn with_comment(mut self, comment: SlideComment) -> Self {
243        self.comments.push(comment);
244        self
245    }
246
247    /// Sets this slide's own background fill, overriding the layout/master.
248    pub fn with_background(mut self, background: Fill) -> Self {
249        self.background = Some(background);
250        self
251    }
252
253    /// Hides this slide's inherited slide-master graphics (`showMasterSp="0"`).
254    pub fn with_hide_master_graphics(mut self, hide: bool) -> Self {
255        self.hide_master_graphics = hide;
256        self
257    }
258}
259
260/// A single item in a slide's shape tree (`<p:spTree>`'s children, in document order) — an enum
261/// covering every distinct shape kind PresentationML defines.
262#[derive(Debug, Clone, PartialEq)]
263pub enum Shape {
264    /// `<p:sp>`, `CT_Shape` — an autoshape or text box, possibly a placeholder.
265    AutoShape(AutoShape),
266    /// `<p:pic>`, `CT_Picture` — an embedded image.
267    Picture(Picture),
268    /// `<p:graphicFrame>` wrapping a `<c:chart>` — an embedded chart. Boxed: a chart's own nested
269    /// `ChartSpace` dwarfs every other variant here, and clippy's `large_enum_variant` flags the
270    /// resulting gap (every `Shape`, no matter its real kind, otherwise pays for the biggest one).
271    Chart(Box<SlideChart>),
272    /// `<p:grpSp>`, `CT_GroupShape` — a group of shapes moving/resizing as one, itself containing
273    /// any of these same variants (including nested groups).
274    Group(ShapeGroup),
275    /// `<p:cxnSp>`, `CT_Connector` — a straight/elbow/curved connector line, typically linking two
276    /// other shapes.
277    Connector(Connector),
278    /// `<p:graphicFrame>` wrapping an `<a:tbl>` — an embedded table.
279    Table(SlideTable),
280    /// `<p:pic>` carrying an `<a:videoFile>`/`<a:audioFile>` reference instead of a plain image —
281    /// an embedded video or audio clip.
282    Media(SlideMedia),
283}
284
285/// An autoshape or text box (`<p:sp>`, `CT_Shape`).
286///
287/// Real PowerPoint title/content slides overwhelmingly use *placeholder* shapes
288/// (position/formatting inherited from the slide layout/master, see [`Placeholder`]) rather than
289/// freestanding ones. `placeholder` is `None` for a freestanding shape and `Some` for one that
290/// plays a placeholder role; either way, `properties`/`text_body` describe the shape's own content
291/// exactly the same way.
292#[derive(Debug, Clone, PartialEq)]
293pub struct AutoShape {
294    /// `<p:cNvPr id="..">` — must be unique within the slide, and must not collide with `1`, always
295    /// reserved by this crate's writer for the slide's own group shape (`<p:nvGrpSpPr><p:cNvPr
296    /// id="1"../>`) — mirrors `word-ooxml`'s existing convention of caller-managed numeric ids (see
297    /// e.g. `Bookmark::new(id)`/`NoteReference::new(id)`).
298    pub id: u32,
299    /// `<p:cNvPr name="..">` — a human-readable shape name (e.g. "Zone de texte 1"), purely
300    /// cosmetic (shown in PowerPoint's Selection Pane).
301    pub name: String,
302    /// `<p:spPr>`'s content — position/size, outline geometry, fill, line. Reuses
303    /// `drawing::ShapeProperties` verbatim (see this module's own doc comment). When `transform` is
304    /// unset, the writer still always emits a fixed zero-valued `<a:xfrm>` and a default `rect`
305    /// `<a:prstGeom>` — the same "always write geometry, never delegate silently" fix this project
306    /// already had to apply twice to `excel-ooxml`'s picture writing (missing geometry, then
307    /// missing `<a:xfrm>`, both making a shape invisible in real Excel) — applied here proactively
308    /// rather than rediscovered.
309    ///
310    /// Unlike `excel-ooxml`'s picture reader (which unconditionally clears `transform`/demotes a
311    /// plain `rect` `geometry` back to `None` on read, because a picture's *real* position always
312    /// comes from its separate two-cell anchor, never from `spPr`'s own `xfrm`), this crate's
313    /// reader makes no such attempt for a *freestanding* shape: `xfrm` is its only source of
314    /// position/size, so a real, non-zero transform must round-trip faithfully. This means an
315    /// [`AutoShape`] written with `properties` left at its `ShapeProperties::new()` default reads
316    /// back with an explicit zero-offset/zero-extent transform and a `rect` geometry, not `None` —
317    /// a known, deliberate round-trip asymmetry for this specific case, not a bug. A *placeholder*
318    /// shape (`<p:spPr/>` empty, position/formatting inherited) reads back with `properties` at
319    /// that same default, for the exact same textual reason — genuinely indistinguishable from the
320    /// freestanding zero-sized case by this crate, another accepted limitation of not modeling
321    /// slide layout inheritance yet.
322    pub properties: ShapeProperties,
323    /// `<p:txBody>` — this shape's text content, if any. Reuses `drawing::TextBody` verbatim.
324    pub text_body: Option<TextBody>,
325    /// `<p:nvSpPr>/<p:nvPr>/<p:ph>` — this shape's placeholder role, if any. `None` for a
326    /// freestanding shape.
327    pub placeholder: Option<Placeholder>,
328    /// `<p:nvSpPr>/<p:cNvSpPr txBox="1">` — marks this shape as a genuine text box rather than an
329    /// ordinary autoshape. Purely a hint real PowerPoint itself uses (e.g. to decide whether the
330    /// shape gets a default outline/fill when the user starts typing into an empty one) — this
331    /// crate doesn't otherwise treat a text box specially; `properties`/`text_body` describe its
332    /// content exactly the same way regardless. Grounded against a real fixture confirming the
333    /// exact `<p:cNvSpPr txBox="1"/>` shape.
334    pub is_text_box: bool,
335    /// `<p:nvSpPr>/<p:cNvPr>/<a:hlinkClick>` (including the internal-target/relative-jump
336    /// variants) — a clickable hyperlink on this whole shape (as opposed to a hyperlink on an
337    /// individual text run, `drawing::Hyperlink` on `drawing::TextRunProperties`, which this crate
338    /// does reuse for run text but doesn't yet resolve to a real relationship — see `writer.rs`'s
339    /// own doc comment on hyperlinks for why shape-level support came first). `None` for no link.
340    /// See [`SlideHyperlinkTarget`]'s own doc comment for the target kinds.
341    pub hyperlink: Option<SlideHyperlinkTarget>,
342}
343
344impl AutoShape {
345    /// Creates a freestanding shape with no explicit position/size/fill/ line, no text, no
346    /// placeholder role, not a text box, and no hyperlink (use [`AutoShape::with_properties`]/
347    /// [`AutoShape::with_text_body`]/[`AutoShape::with_placeholder`]/
348    /// [`AutoShape::with_text_box`]/[`AutoShape::with_hyperlink`]/
349    /// [`AutoShape::with_hyperlink_target`] to fill those in).
350    pub fn new(id: u32, name: impl Into<String>) -> Self {
351        Self {
352            id,
353            name: name.into(),
354            properties: ShapeProperties::new(),
355            text_body: None,
356            placeholder: None,
357            is_text_box: false,
358            hyperlink: None,
359        }
360    }
361
362    /// Sets this shape's position/size/outline/fill/line.
363    pub fn with_properties(mut self, properties: ShapeProperties) -> Self {
364        self.properties = properties;
365        self
366    }
367
368    /// Sets this shape's text content.
369    pub fn with_text_body(mut self, text_body: TextBody) -> Self {
370        self.text_body = Some(text_body);
371        self
372    }
373
374    /// Marks this shape as playing the given placeholder role.
375    pub fn with_placeholder(mut self, placeholder: Placeholder) -> Self {
376        self.placeholder = Some(placeholder);
377        self
378    }
379
380    /// Marks (or unmarks) this shape as a genuine text box (`txBox="1"`).
381    pub fn with_text_box(mut self, is_text_box: bool) -> Self {
382        self.is_text_box = is_text_box;
383        self
384    }
385
386    /// Attaches a clickable hyperlink to this whole shape, given its target URL — sugar for
387    /// `with_hyperlink_target(SlideHyperlinkTarget::External(url))`.
388    pub fn with_hyperlink(mut self, url: impl Into<String>) -> Self {
389        self.hyperlink = Some(SlideHyperlinkTarget::External(url.into()));
390        self
391    }
392
393    /// Attaches a clickable hyperlink to this whole shape, given any [`SlideHyperlinkTarget`] — the
394    /// general form of [`AutoShape::with_hyperlink`], also covering internal slide jumps.
395    pub fn with_hyperlink_target(mut self, target: SlideHyperlinkTarget) -> Self {
396        self.hyperlink = Some(target);
397        self
398    }
399}
400
401/// A clickable hyperlink's target (`<a:hlinkClick>`, `CT_Hyperlink`): either
402/// [`SlideHyperlinkTarget::External`], or a jump to another slide in the *same* presentation rather
403/// than an outside URL. `<a:hlinkClick r:id=".">`'s `r:id` resolves to a relationship — for
404/// [`SlideHyperlinkTarget::External`] that relationship's target is the URL itself
405/// (`TargetMode="External"`); for
406/// [`SlideHyperlinkTarget::Slide`] it instead targets another part *inside* the same package
407/// (`TargetMode="Internal"`, `"./slides/slideN.xml"`), paired with
408/// `action="ppaction://hlinksldjump"`. The four relative-jump variants need no relationship at all
409/// — they're expressed purely via `action="ppaction://hlinkshowjump?jump=."` with no `r:id`
410/// attribute. Grounded against a real fixture for the `r:id`+`hlinksldjump` shape
411/// ([`SlideHyperlinkTarget::Slide`]); the relative- jump actions have no fixture in this project's
412/// corpus but are modeled directly from the `ppaction://hlinkshowjump` action values ECMA-376
413/// itself documents for "next slide"/"previous slide"/"first slide"/"last slide".
414#[derive(Debug, Clone, PartialEq, Eq)]
415pub enum SlideHyperlinkTarget {
416    /// An ordinary outside URL (e.g. `"https://example.com"`), the only kind this crate supported
417    /// previously.
418    External(String),
419    /// Jumps to another slide in the same presentation, given as a 0-based index into
420    /// [`Presentation::slides`] — resolved to a relative relationship target
421    /// (`"./slides/slide{index + 1}.xml"`) purely from that index at write time, since slide
422    /// numbering is fully deterministic (no pre-built global slide-id map needed). Not validated
423    /// against the presentation's actual slide count — an out-of-range index simply produces a link
424    /// real PowerPoint would show as broken, the same best-effort posture already used elsewhere in
425    /// this crate (e.g. table cell merge spans).
426    Slide(usize),
427    /// Jumps to the next slide in presentation order.
428    NextSlide,
429    /// Jumps to the previous slide in presentation order.
430    PreviousSlide,
431    /// Jumps to the first slide in the presentation.
432    FirstSlide,
433    /// Jumps to the last slide in the presentation.
434    LastSlide,
435}
436
437/// `<p:nvPr><p:ph type=".." idx=".."/></p:nvPr>` (`CT_Placeholder`) — a shape's placeholder role:
438/// which slot of the slide layout/master it fills, so its position/formatting can be inherited from
439/// there instead of being set explicitly. This crate never resolves that inheritance (no slide
440/// layout/master model beyond a single fixed, empty one — see `Presentation`'s doc comment);
441/// recording `Placeholder` on a read-back shape is purely informational; nothing else in this crate
442/// consults it.
443#[derive(Debug, Clone, PartialEq, Eq)]
444pub struct Placeholder {
445    /// `type` — which slot this shape fills (`ST_PlaceholderType`).
446    pub kind: PlaceholderKind,
447    /// `idx` — disambiguates multiple placeholders of the same `kind` on one layout (e.g. several
448    /// `body` placeholders). `None` omits the attribute, matching a title placeholder's own
449    /// convention (real PowerPoint never writes `idx` on `title`/`ctrTitle`).
450    pub index: Option<u32>,
451}
452
453impl Placeholder {
454    /// Creates a placeholder role with no explicit index.
455    pub fn new(kind: PlaceholderKind) -> Self {
456        Self { kind, index: None }
457    }
458
459    /// Sets this placeholder's `idx` attribute.
460    pub fn with_index(mut self, index: u32) -> Self {
461        self.index = Some(index);
462        self
463    }
464}
465
466/// A useful subset of `ST_PlaceholderType`'s values — the ones a shape (as opposed to a placeholder
467/// *picture*/*chart*/*table*, not modeled) can play. [`PlaceholderKind::Other`] preserves
468/// round-trip fidelity for any type not explicitly modeled here (`chart`/`tbl`/
469/// `clipArt`/`dgm`/`media`/`sldImg`/`pic`, all placeholder roles for a non-`<p:sp>` shape kind that
470/// this crate doesn't tag with placeholder info on the writing side, but must still be able to read
471/// back without error), same "closed enum + escape hatch" posture `drawing::PresetShape` already
472/// established.
473#[derive(Debug, Clone, PartialEq, Eq)]
474pub enum PlaceholderKind {
475    Title,
476    Body,
477    CenterTitle,
478    SubTitle,
479    DateTime,
480    SlideNumber,
481    Footer,
482    Header,
483    Object,
484    /// `sldImg` — the slide-thumbnail placeholder on a notes slide (see `Presentation`'s writer,
485    /// which emits one automatically around `Slide.notes`).
486    SlideImage,
487    Other(String),
488}
489
490impl PlaceholderKind {
491    pub(crate) fn xml_token(&self) -> &str {
492        match self {
493            PlaceholderKind::Title => "title",
494            PlaceholderKind::Body => "body",
495            PlaceholderKind::CenterTitle => "ctrTitle",
496            PlaceholderKind::SubTitle => "subTitle",
497            PlaceholderKind::DateTime => "dt",
498            PlaceholderKind::SlideNumber => "sldNum",
499            PlaceholderKind::Footer => "ftr",
500            PlaceholderKind::Header => "hdr",
501            PlaceholderKind::Object => "obj",
502            PlaceholderKind::SlideImage => "sldImg",
503            PlaceholderKind::Other(token) => token,
504        }
505    }
506
507    pub(crate) fn from_xml_token(token: &str) -> Self {
508        match token {
509            "title" => PlaceholderKind::Title,
510            "body" => PlaceholderKind::Body,
511            "ctrTitle" => PlaceholderKind::CenterTitle,
512            "subTitle" => PlaceholderKind::SubTitle,
513            "dt" => PlaceholderKind::DateTime,
514            "sldNum" => PlaceholderKind::SlideNumber,
515            "ftr" => PlaceholderKind::Footer,
516            "hdr" => PlaceholderKind::Header,
517            "obj" => PlaceholderKind::Object,
518            "sldImg" => PlaceholderKind::SlideImage,
519            other => PlaceholderKind::Other(other.to_string()),
520        }
521    }
522}
523
524/// An embedded image (`<p:pic>`, `CT_Picture`).
525///
526/// Unlike an [`AutoShape`], a picture's position/size is never optional or defaulted:
527/// `offset_emu`/`extent_emu` are plain fields (not routed through `drawing::Transform2D`'s `Option`
528/// flexibility), since a PresentationML picture's `<a:xfrm>` — unlike Excel's cell-anchored
529/// pictures — is the shape's *only* source of position/size; there is no separate anchor to fall
530/// back on, so demanding a concrete value upfront avoids ever having to invent one.
531#[derive(Debug, Clone, PartialEq)]
532pub struct Picture {
533    /// `<p:cNvPr id="..">` — same uniqueness rules as `AutoShape::id`.
534    pub id: u32,
535    /// `<p:cNvPr name="..">`.
536    pub name: String,
537    /// The raw, encoded image bytes (e.g. the bytes of a `.png` file).
538    pub data: Vec<u8>,
539    /// The image's encoding, also used to pick the media part's extension and content type.
540    pub format: PictureFormat,
541    /// `<a:off x=".." y="..">`, in EMUs.
542    pub offset_emu: (i64, i64),
543    /// `<a:ext cx=".." cy="..">`, in EMUs.
544    pub extent_emu: (i64, i64),
545    /// Alt text / description (`p:cNvPr`'s `descr` attribute). May be empty.
546    pub description: String,
547    /// Additional shape formatting (fill/line/rotation, and a non-default outline geometry — e.g.
548    /// an oval crop) for the picture's own `p:spPr`, beyond the fixed default rectangle. `None`
549    /// (the default) produces a plain rectangular picture with no extra fill/line, the same fixed
550    /// shape `word-ooxml`/`excel-ooxml`'s own pictures default to. `transform` is never consulted
551    /// here — `offset_emu`/`extent_emu` above are always authoritative for a picture's
552    /// position/size, mirroring `word_ooxml::Image::shape_properties`'s own posture.
553    pub shape_properties: Option<ShapeProperties>,
554    /// `<a:blip r:link=".">`'s own external target. When set, the writer emits *both* `r:embed`
555    /// (pointing at `data` below, embedded in the package as usual) and `r:link`
556    /// (`TargetMode="External"`, this URL/path) on the same `<a:blip>` — PowerPoint's own "embedded
557    /// preview cache + externally-linked original" dual pattern, so the picture still displays even
558    /// when the external source is unreachable. `None` (the default) omits `r:link` entirely — a
559    /// plain, fully-embedded picture, unchanged. Grounded against a real fixture.
560    pub external_link: Option<String>,
561}
562
563impl Picture {
564    /// Creates a picture at the given size, positioned at `(0, 0)` (use [`Picture::with_offset`] to
565    /// place it elsewhere), with no alt text, no extra shape formatting, and no external link.
566    pub fn new(
567        id: u32,
568        name: impl Into<String>,
569        data: impl Into<Vec<u8>>,
570        format: PictureFormat,
571        width_emu: i64,
572        height_emu: i64,
573    ) -> Self {
574        Self {
575            id,
576            name: name.into(),
577            data: data.into(),
578            format,
579            offset_emu: (0, 0),
580            extent_emu: (width_emu, height_emu),
581            description: String::new(),
582            shape_properties: None,
583            external_link: None,
584        }
585    }
586
587    /// Sets this picture's position.
588    pub fn with_offset(mut self, x_emu: i64, y_emu: i64) -> Self {
589        self.offset_emu = (x_emu, y_emu);
590        self
591    }
592
593    /// Sets this picture's alt text / description.
594    pub fn with_description(mut self, description: impl Into<String>) -> Self {
595        self.description = description.into();
596        self
597    }
598
599    /// Sets this picture's additional shape formatting (fill/line/ geometry), beyond the fixed
600    /// default rectangle.
601    pub fn with_shape_properties(mut self, shape_properties: ShapeProperties) -> Self {
602        self.shape_properties = Some(shape_properties);
603        self
604    }
605
606    /// Additionally links this picture to an external image (`r:link`), alongside its own embedded
607    /// `data` — PowerPoint's own "embedded preview cache + externally-linked original" dual
608    /// pattern.
609    pub fn with_external_link(mut self, url: impl Into<String>) -> Self {
610        self.external_link = Some(url.into());
611        self
612    }
613}
614
615/// An image's encoding — mirrors `word-ooxml::ImageFormat` by name and scope for cross-crate
616/// consistency (same real-world concept, each host crate keeps its own tiny copy rather than one
617/// depending on the other — there is no shared "media" crate, and this enum is far too small to
618/// justify creating one).
619#[derive(Debug, Clone, Copy, PartialEq, Eq)]
620pub enum PictureFormat {
621    Png,
622    Jpeg,
623    Gif,
624    Bmp,
625}
626
627impl PictureFormat {
628    pub(crate) fn extension(&self) -> &'static str {
629        match self {
630            PictureFormat::Png => "png",
631            PictureFormat::Jpeg => "jpeg",
632            PictureFormat::Gif => "gif",
633            PictureFormat::Bmp => "bmp",
634        }
635    }
636
637    pub(crate) fn content_type(&self) -> &'static str {
638        match self {
639            PictureFormat::Png => "image/png",
640            PictureFormat::Jpeg => "image/jpeg",
641            PictureFormat::Gif => "image/gif",
642            PictureFormat::Bmp => "image/bmp",
643        }
644    }
645
646    /// Guesses a format from a part name's extension (e.g. `/ppt/media/image1.jpeg"`), for reading
647    /// an existing package back.
648    pub(crate) fn from_extension(part_name: &str) -> Option<Self> {
649        let extension = part_name.rsplit('.').next()?.to_ascii_lowercase();
650        match extension.as_str() {
651            "png" => Some(PictureFormat::Png),
652            "jpeg" | "jpg" => Some(PictureFormat::Jpeg),
653            "gif" => Some(PictureFormat::Gif),
654            "bmp" => Some(PictureFormat::Bmp),
655            _ => None,
656        }
657    }
658}
659
660/// An embedded video or audio clip (`<p:pic>` carrying an `<a:videoFile r:link=".">`/`<a:audioFile
661/// r:link=".">` reference in its own `<p:nvPr>`, instead of the plain `<a:blip>` image an ordinary
662/// [`Picture`] has).
663///
664/// Real PowerPoint (2010+) also writes a Microsoft-proprietary `<p:extLst><p:ext><p14:media
665/// r:embed=".."/></p:ext></p:extLst>` alongside the core `<a:videoFile>`/`<a:audioFile>` element,
666/// referencing the same media part a second time for its own newer UI features — not modeled here
667/// (an extension outside ECMA-376 itself, same "skip Microsoft-only extensions" posture this crate
668/// already takes for `<a:extLst>` generally); the core relationship alone is schema-complete and
669/// should still play in real PowerPoint.
670///
671/// `<p:timing>` (autoplay/on-click trigger, bookmarks, trim points) lives at the *slide* level, not
672/// on the shape itself, and is a materially larger, separate feature — not modeled. Unlike
673/// [`Picture`], this type has no `shape_properties` field: the writer always produces a plain
674/// default rectangle (`<a:prstGeom prst="rect">`, no custom fill/ line), and any non-default
675/// geometry/fill/line an existing file's own `<p:pic>` might carry is simply discarded when read
676/// back — a video/audio clip's own outline styling is a rare, cosmetic refinement didn't ask for.
677#[derive(Debug, Clone, PartialEq)]
678pub struct SlideMedia {
679    /// `<p:cNvPr id="..">` — same uniqueness rules as `AutoShape::id`.
680    pub id: u32,
681    /// `<p:cNvPr name="..">`.
682    pub name: String,
683    /// The raw, encoded media bytes (e.g. the bytes of a `.mp4` file).
684    pub data: Vec<u8>,
685    /// The media's encoding, also used to pick the media part's extension/ content type and to tell
686    /// video from audio (`format.is_video()`).
687    pub format: MediaFormat,
688    /// `<a:off x=".." y="..">`, in EMUs — same "never optional, no separate anchor" posture as
689    /// [`Picture::offset_emu`].
690    pub offset_emu: (i64, i64),
691    /// `<a:ext cx=".." cy="..">`, in EMUs.
692    pub extent_emu: (i64, i64),
693    /// This clip's poster frame (`<p:blipFill>`'s own `<a:blip>`, the still image real PowerPoint
694    /// shows before playback) — the raw image bytes plus its format. `None` omits `<a:blip>` from
695    /// `<p:blipFill>` entirely (schema-valid — `blip` is `minOccurs="0"` — but the clip will render
696    /// with no visible thumbnail until played).
697    pub poster_image: Option<(Vec<u8>, PictureFormat)>,
698}
699
700impl SlideMedia {
701    /// Creates a media clip at the given size, positioned at `(0, 0)`, with no poster frame.
702    pub fn new(
703        id: u32,
704        name: impl Into<String>,
705        data: impl Into<Vec<u8>>,
706        format: MediaFormat,
707        width_emu: i64,
708        height_emu: i64,
709    ) -> Self {
710        Self {
711            id,
712            name: name.into(),
713            data: data.into(),
714            format,
715            offset_emu: (0, 0),
716            extent_emu: (width_emu, height_emu),
717            poster_image: None,
718        }
719    }
720
721    /// Sets this clip's position.
722    pub fn with_offset(mut self, x_emu: i64, y_emu: i64) -> Self {
723        self.offset_emu = (x_emu, y_emu);
724        self
725    }
726
727    /// Sets this clip's poster frame image.
728    pub fn with_poster_image(mut self, data: impl Into<Vec<u8>>, format: PictureFormat) -> Self {
729        self.poster_image = Some((data.into(), format));
730        self
731    }
732}
733
734/// A video/audio clip's encoding (`<a:videoFile>`/`<a:audioFile>`'s referenced media part) —
735/// mirrors [`PictureFormat`]'s own "closed enum, small known set" posture, covering the handful of
736/// container formats real PowerPoint itself natively embeds rather than every format a media player
737/// could theoretically open.
738#[derive(Debug, Clone, Copy, PartialEq, Eq)]
739pub enum MediaFormat {
740    Mp4,
741    Wmv,
742    Avi,
743    Mp3,
744    Wav,
745}
746
747impl MediaFormat {
748    /// Whether this format is a video (as opposed to an audio-only) clip — decides whether the
749    /// writer emits `<a:videoFile>` or `<a:audioFile>`.
750    pub(crate) fn is_video(&self) -> bool {
751        matches!(self, MediaFormat::Mp4 | MediaFormat::Wmv | MediaFormat::Avi)
752    }
753
754    pub(crate) fn extension(&self) -> &'static str {
755        match self {
756            MediaFormat::Mp4 => "mp4",
757            MediaFormat::Wmv => "wmv",
758            MediaFormat::Avi => "avi",
759            MediaFormat::Mp3 => "mp3",
760            MediaFormat::Wav => "wav",
761        }
762    }
763
764    pub(crate) fn content_type(&self) -> &'static str {
765        match self {
766            MediaFormat::Mp4 => "video/mp4",
767            MediaFormat::Wmv => "video/x-ms-wmv",
768            MediaFormat::Avi => "video/avi",
769            MediaFormat::Mp3 => "audio/mpeg",
770            MediaFormat::Wav => "audio/wav",
771        }
772    }
773
774    /// Guesses a format from a part name's extension (e.g. `/ppt/media/media1.mp4`), for reading an
775    /// existing package back.
776    pub(crate) fn from_extension(part_name: &str) -> Option<Self> {
777        let extension = part_name.rsplit('.').next()?.to_ascii_lowercase();
778        match extension.as_str() {
779            "mp4" => Some(MediaFormat::Mp4),
780            "wmv" => Some(MediaFormat::Wmv),
781            "avi" => Some(MediaFormat::Avi),
782            "mp3" => Some(MediaFormat::Mp3),
783            "wav" => Some(MediaFormat::Wav),
784            _ => None,
785        }
786    }
787}
788
789/// An embedded chart (`<p:graphicFrame>` wrapping a `<c:chart r:id="..">`).
790///
791/// Reuses the `chart` crate's `ChartSpace` verbatim, same posture as
792/// `word-ooxml::EmbeddedChart`/`excel-ooxml::SheetChart` — **cached values only, no embedded
793/// `.xlsx` workbook as a data source**, matching the scope of those two.
794#[derive(Debug, Clone, PartialEq)]
795pub struct SlideChart {
796    /// `<p:cNvPr id="..">` — same uniqueness rules as `AutoShape::id`.
797    pub id: u32,
798    /// `<p:cNvPr name="..">`.
799    pub name: String,
800    pub chart_space: ChartSpace,
801    /// `<p:xfrm><a:off x=".." y="..">`, in EMUs. Like [`Picture`], a chart's position/size is never
802    /// optional — `<p:xfrm>` is its only source of it (no separate anchor, unlike Excel's
803    /// `SheetChart`).
804    pub offset_emu: (i64, i64),
805    /// `<p:xfrm><a:ext cx=".." cy="..">`, in EMUs.
806    pub extent_emu: (i64, i64),
807}
808
809impl SlideChart {
810    /// Creates an embedded chart at the given size, positioned at `(0, 0)` (use
811    /// [`SlideChart::with_offset`] to place it elsewhere).
812    pub fn new(
813        id: u32,
814        name: impl Into<String>,
815        chart_space: ChartSpace,
816        width_emu: i64,
817        height_emu: i64,
818    ) -> Self {
819        Self {
820            id,
821            name: name.into(),
822            chart_space,
823            offset_emu: (0, 0),
824            extent_emu: (width_emu, height_emu),
825        }
826    }
827
828    /// Sets this chart's position.
829    pub fn with_offset(mut self, x_emu: i64, y_emu: i64) -> Self {
830        self.offset_emu = (x_emu, y_emu);
831        self
832    }
833}
834
835/// A group of shapes moving/resizing together (`<p:grpSp>`, `CT_GroupShape`).
836///
837/// A group's own transform is two coordinate pairs working together, not one:
838/// `offset_emu`/`extent_emu` (`<a:off>`/`<a:ext>`) place the group on the slide, exactly like any
839/// other shape; `child_offset_emu`/ `child_extent_emu` (`<a:chOff>`/`<a:chExt>`) define the
840/// coordinate space the *children*'s own `xfrm` values are expressed in — real PowerPoint scales
841/// between the two automatically whenever they differ (e.g. a group's children keep their original
842/// small-scale coordinates while the group itself is stretched much larger on the slide). Confirmed
843/// against a real fixture with nested groups, including a group whose child coordinate space
844/// genuinely differs from its own on-slide extent. Groups can nest (a `Shape::Group` inside
845/// another's own `shapes`), confirmed on the same fixture.
846#[derive(Debug, Clone, PartialEq)]
847pub struct ShapeGroup {
848    /// `<p:cNvPr id="..">` — same uniqueness rules as `AutoShape::id`.
849    pub id: u32,
850    /// `<p:cNvPr name="..">`.
851    pub name: String,
852    /// `<a:xfrm><a:off x=".." y="..">`, in EMUs.
853    pub offset_emu: (i64, i64),
854    /// `<a:xfrm><a:ext cx=".." cy="..">`, in EMUs.
855    pub extent_emu: (i64, i64),
856    /// `<a:xfrm><a:chOff x=".." y="..">`, in EMUs — the coordinate space this group's children's
857    /// own `xfrm` values are expressed in.
858    pub child_offset_emu: (i64, i64),
859    /// `<a:xfrm><a:chExt cx=".." cy="..">`, in EMUs.
860    pub child_extent_emu: (i64, i64),
861    /// `<a:xfrm rot=".">` — `ST_Angle`, in 60,000ths of a degree, positive clockwise. `0` (the
862    /// default) omits the attribute — no rotation. Symmetric to
863    /// [`drawing::Transform2D::rotation_60000ths`]. Grounded against real fixtures.
864    pub rotation_60000ths: i32,
865    /// `<a:xfrm flipH=".">` — mirrors the whole group horizontally before rotation is applied.
866    /// **Not grounded on a group specifically** in the fixture corpus — modeled by analogy to
867    /// [`drawing::Transform2D::flip_horizontal`] (identical `CT_GroupTransform2D`/`CT_Transform2D`
868    /// attribute, already grounded there).
869    pub flip_horizontal: bool,
870    /// `<a:xfrm flipV="..">` — mirrors the whole group vertically. Same grounding caveat as
871    /// `flip_horizontal`.
872    pub flip_vertical: bool,
873    /// The group's children, in document order — any shape kind, including nested groups.
874    pub shapes: Vec<Shape>,
875}
876
877impl ShapeGroup {
878    /// Creates an empty group (no children) with a zero-valued transform — use
879    /// [`ShapeGroup::with_transform`] to position it and its children's coordinate space, and
880    /// [`ShapeGroup::with_shape`] to add children.
881    pub fn new(id: u32, name: impl Into<String>) -> Self {
882        Self {
883            id,
884            name: name.into(),
885            offset_emu: (0, 0),
886            extent_emu: (0, 0),
887            child_offset_emu: (0, 0),
888            child_extent_emu: (0, 0),
889            rotation_60000ths: 0,
890            flip_horizontal: false,
891            flip_vertical: false,
892            shapes: Vec::new(),
893        }
894    }
895
896    /// Sets this group's own on-slide position/size and its children's coordinate space (often the
897    /// same values, when no scaling between the two is needed).
898    pub fn with_transform(
899        mut self,
900        offset_emu: (i64, i64),
901        extent_emu: (i64, i64),
902        child_offset_emu: (i64, i64),
903        child_extent_emu: (i64, i64),
904    ) -> Self {
905        self.offset_emu = offset_emu;
906        self.extent_emu = extent_emu;
907        self.child_offset_emu = child_offset_emu;
908        self.child_extent_emu = child_extent_emu;
909        self
910    }
911
912    /// Sets this group's rotation, given in ordinary degrees (converted to the schema's
913    /// 60,000ths-of-a-degree unit internally).
914    pub fn with_rotation_degrees(mut self, degrees: f64) -> Self {
915        self.rotation_60000ths = (degrees * 60_000.0).round() as i32;
916        self
917    }
918
919    /// Mirrors the whole group horizontally.
920    pub fn with_flip_horizontal(mut self, flip: bool) -> Self {
921        self.flip_horizontal = flip;
922        self
923    }
924
925    /// Mirrors the whole group vertically.
926    pub fn with_flip_vertical(mut self, flip: bool) -> Self {
927        self.flip_vertical = flip;
928        self
929    }
930
931    /// Appends a child shape.
932    pub fn with_shape(mut self, shape: Shape) -> Self {
933        self.shapes.push(shape);
934        self
935    }
936}
937
938/// A straight/elbow/curved connector line (`<p:cxnSp>`, `CT_Connector`), typically used to visually
939/// link two other shapes.
940///
941/// **Not grounded on a real fixture** — see this module's own top-level doc comment. Structure
942/// follows ECMA-376's `CT_Connector`/ `CT_ConnectorNonVisual` directly: a connector's own
943/// `<p:spPr>` is the exact same `CT_ShapeProperties` content type an autoshape's `<p:spPr>` uses
944/// (position/geometry/fill/line), so `properties` reuses `drawing::ShapeProperties` verbatim, same
945/// as [`AutoShape`] — only the non-visual wrapper element name (`p:cxnSp`/`p:nvCxnSpPr` instead of
946/// `p:sp`/`p:nvSpPr`) and the optional start/end shape attachments differ. When
947/// `properties.geometry` is unset, the writer defaults to `<a:prstGeom prst="line">` (a straight
948/// connector) rather than `AutoShape`'s own `rect` default — the same "always write geometry
949/// explicitly" discipline, just with a connector-appropriate default preset.
950#[derive(Debug, Clone, PartialEq)]
951pub struct Connector {
952    /// `<p:cNvPr id="..">` — same uniqueness rules as `AutoShape::id`.
953    pub id: u32,
954    /// `<p:cNvPr name="..">`.
955    pub name: String,
956    /// `<p:spPr>`'s content — position/size, outline geometry, fill, line.
957    pub properties: ShapeProperties,
958    /// `<p:nvCxnSpPr><p:cNvCxnSpPr><a:stCxn id=".." idx=".."/>` — which other shape (and which of
959    /// its connection sites) this connector's start point is attached to. `None` for a floating,
960    /// unattached start point (positioned by `properties`' own `xfrm` alone).
961    pub start_connection: Option<ShapeConnection>,
962    /// `<a:endCxn id=".." idx=".."/>` — same as `start_connection`, for the connector's end point.
963    pub end_connection: Option<ShapeConnection>,
964}
965
966impl Connector {
967    /// Creates a floating connector (no shape attachments) with no explicit position/size/line.
968    pub fn new(id: u32, name: impl Into<String>) -> Self {
969        Self {
970            id,
971            name: name.into(),
972            properties: ShapeProperties::new(),
973            start_connection: None,
974            end_connection: None,
975        }
976    }
977
978    /// Sets this connector's position/size/line/fill.
979    pub fn with_properties(mut self, properties: ShapeProperties) -> Self {
980        self.properties = properties;
981        self
982    }
983
984    /// Attaches this connector's start point to another shape's connection site.
985    pub fn with_start_connection(mut self, shape_id: u32, index: u32) -> Self {
986        self.start_connection = Some(ShapeConnection { shape_id, index });
987        self
988    }
989
990    /// Attaches this connector's end point to another shape's connection site.
991    pub fn with_end_connection(mut self, shape_id: u32, index: u32) -> Self {
992        self.end_connection = Some(ShapeConnection { shape_id, index });
993        self
994    }
995}
996
997/// One end of a [`Connector`]'s attachment to another shape (`<a:stCxn>`/`<a:endCxn>`,
998/// `CT_Connection`).
999#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1000pub struct ShapeConnection {
1001    /// `id` — the attached shape's `<p:cNvPr id="..">`.
1002    pub shape_id: u32,
1003    /// `idx` — which of that shape's connection sites (a preset-shape- specific numbered list of
1004    /// attachment points around its outline; this crate doesn't validate the index against the
1005    /// target shape's actual preset).
1006    pub index: u32,
1007}
1008
1009/// An embedded table (`<p:graphicFrame>` wrapping `<a:tbl>`, `a:graphicData
1010/// uri="../drawingml/2006/table"`) — DrawingML's own table vocabulary, distinct from
1011/// `word-ooxml::Table`'s WordprocessingML `w:tbl` (a different XML namespace/vocabulary entirely,
1012/// despite the conceptual overlap — no code is shared between the two, same posture as every other
1013/// cross-crate "same real-world concept, separate small copy" case in this workspace, e.g.
1014/// `PictureFormat`).
1015///
1016/// Structure (`p:graphicFrame`/`p:xfrm`/`a:tbl`/`a:tblGrid`/`a:tr`/`a:tc`) confirmed against real
1017/// fixtures: note the frame's own transform element is `<p:xfrm>`, not `<a:xfrm>`, same as
1018/// [`SlideChart`]'s. `<a:tableStyleId>` (a GUID referencing a table style) is written from
1019/// `style_id` when set (see [`SlideTableStyle`]), otherwise falls back to the same
1020/// fixed built-in default this crate's `tableStyles.xml` always declares, matching this crate's
1021/// existing posture on `p:xfrm`/theme boilerplate elsewhere.
1022#[derive(Debug, Clone, PartialEq)]
1023pub struct SlideTable {
1024    /// `<p:cNvPr id="..">` — same uniqueness rules as `AutoShape::id`.
1025    pub id: u32,
1026    /// `<p:cNvPr name="..">`.
1027    pub name: String,
1028    /// `<p:xfrm><a:off x=".." y="..">`, in EMUs.
1029    pub offset_emu: (i64, i64),
1030    /// `<p:xfrm><a:ext cx=".." cy="..">`, in EMUs.
1031    pub extent_emu: (i64, i64),
1032    /// `<a:tblGrid>`'s `<a:gridCol w="..">` widths, in EMUs, one per column (column count is
1033    /// derived from this, mirroring `word_ooxml::Table.column_widths`).
1034    pub column_widths_emu: Vec<i64>,
1035    pub rows: Vec<TableRow>,
1036    /// `<a:tableStyleId>` — references one of `Presentation.table_styles` by
1037    /// [`SlideTableStyle::id`]. `None` (the default) falls back to this crate's fixed built-in
1038    /// style, unchanged.
1039    pub style_id: Option<String>,
1040    /// `<a:tblPr firstRow=".">` — applies `style_id`'s `first_row` part to this table's actual
1041    /// first row (a header-row emphasis, e.g. bold text/a distinct fill). `false` (the default,
1042    /// matching `CT_TableProperties`'s own schema default) omits the attribute. **Without this, a
1043    /// referenced [`SlideTableStyle`]'s per-part formatting has no visible effect in real
1044    /// PowerPoint at all** — these six toggles are what actually turns each style part "on" for a
1045    /// given table; `style_id` alone only makes the style *available*. Grounded against a real
1046    /// fixture (`firstRow="1" bandRow="1"`).
1047    pub style_first_row: bool,
1048    /// `<a:tblPr firstCol="..">` — applies `style_id`'s `first_column` part. See
1049    /// [`SlideTable::style_first_row`]'s doc comment.
1050    pub style_first_column: bool,
1051    /// `<a:tblPr lastRow="..">` — applies `style_id`'s `last_row` part (a totals-row emphasis). See
1052    /// [`SlideTable::style_first_row`]'s doc comment.
1053    pub style_last_row: bool,
1054    /// `<a:tblPr lastCol="..">` — applies `style_id`'s `last_column` part. See
1055    /// [`SlideTable::style_first_row`]'s doc comment.
1056    pub style_last_column: bool,
1057    /// `<a:tblPr bandRow="..">` — applies `style_id`'s `band1_horizontal`/ `band2_horizontal` parts
1058    /// as alternating row stripes. See [`SlideTable::style_first_row`]'s doc comment.
1059    pub style_band_rows: bool,
1060    /// `<a:tblPr bandCol="..">` — applies `style_id`'s `band1_vertical`/ `band2_vertical` parts as
1061    /// alternating column stripes. See [`SlideTable::style_first_row`]'s doc comment.
1062    pub style_band_columns: bool,
1063}
1064
1065impl SlideTable {
1066    /// Creates an empty table (no rows/columns) at the given size, positioned at `(0, 0)`, using
1067    /// the fixed built-in table style with none of its parts (first row/column, last row/column,
1068    /// banding) actually applied.
1069    pub fn new(id: u32, name: impl Into<String>, width_emu: i64, height_emu: i64) -> Self {
1070        Self {
1071            id,
1072            name: name.into(),
1073            offset_emu: (0, 0),
1074            extent_emu: (width_emu, height_emu),
1075            column_widths_emu: Vec::new(),
1076            rows: Vec::new(),
1077            style_id: None,
1078            style_first_row: false,
1079            style_first_column: false,
1080            style_last_row: false,
1081            style_last_column: false,
1082            style_band_rows: false,
1083            style_band_columns: false,
1084        }
1085    }
1086
1087    /// Sets this table's position.
1088    pub fn with_offset(mut self, x_emu: i64, y_emu: i64) -> Self {
1089        self.offset_emu = (x_emu, y_emu);
1090        self
1091    }
1092
1093    /// Uses a custom table style (by id) instead of the fixed built-in one.
1094    pub fn with_style_id(mut self, style_id: impl Into<String>) -> Self {
1095        self.style_id = Some(style_id.into());
1096        self
1097    }
1098
1099    /// Sets the column widths (and, implicitly, the column count).
1100    pub fn with_column_widths(mut self, column_widths_emu: impl Into<Vec<i64>>) -> Self {
1101        self.column_widths_emu = column_widths_emu.into();
1102        self
1103    }
1104
1105    /// Appends a row.
1106    pub fn with_row(mut self, row: TableRow) -> Self {
1107        self.rows.push(row);
1108        self
1109    }
1110
1111    /// Applies the referenced style's first-row part to this table's actual first row (e.g. a bold
1112    /// header row).
1113    pub fn with_style_first_row(mut self, apply: bool) -> Self {
1114        self.style_first_row = apply;
1115        self
1116    }
1117
1118    /// Applies the referenced style's first-column part.
1119    pub fn with_style_first_column(mut self, apply: bool) -> Self {
1120        self.style_first_column = apply;
1121        self
1122    }
1123
1124    /// Applies the referenced style's last-row part (e.g. a totals row).
1125    pub fn with_style_last_row(mut self, apply: bool) -> Self {
1126        self.style_last_row = apply;
1127        self
1128    }
1129
1130    /// Applies the referenced style's last-column part.
1131    pub fn with_style_last_column(mut self, apply: bool) -> Self {
1132        self.style_last_column = apply;
1133        self
1134    }
1135
1136    /// Applies the referenced style's horizontal-banding parts (alternating row stripes).
1137    pub fn with_style_band_rows(mut self, apply: bool) -> Self {
1138        self.style_band_rows = apply;
1139        self
1140    }
1141
1142    /// Applies the referenced style's vertical-banding parts (alternating column stripes).
1143    pub fn with_style_band_columns(mut self, apply: bool) -> Self {
1144        self.style_band_columns = apply;
1145        self
1146    }
1147}
1148
1149/// One row of a [`SlideTable`] (`<a:tr h="..">`, `CT_TableRow`).
1150#[derive(Debug, Clone, PartialEq, Default)]
1151pub struct TableRow {
1152    /// `h` — row height, in EMUs.
1153    pub height_emu: i64,
1154    pub cells: Vec<TableCell>,
1155}
1156
1157impl TableRow {
1158    /// Creates an empty row (no cells) at the given height.
1159    pub fn new(height_emu: i64) -> Self {
1160        Self {
1161            height_emu,
1162            cells: Vec::new(),
1163        }
1164    }
1165
1166    /// Appends a cell.
1167    pub fn with_cell(mut self, cell: TableCell) -> Self {
1168        self.cells.push(cell);
1169        self
1170    }
1171}
1172
1173/// One cell of a [`TableRow`] (`<a:tc>`, `CT_TableCell`).
1174///
1175/// **The merge fields are not grounded on a real fixture** — see this module's own top-level doc
1176/// comment; none of this project's real table fixtures happens to exercise a merged cell, only the
1177/// base `graphicFrame`/`tbl`/`tblGrid`/`tr`/`tc` structure itself is fixture- confirmed. Built from
1178/// ECMA-376's own `CT_TableCell` attribute list, which is genuinely different from
1179/// `word_ooxml::TableCell`'s merge model despite the conceptual overlap: WordprocessingML lets a
1180/// spanned cell be omitted from its row entirely (`gridSpan` absorbs the columns, no placeholder
1181/// `w:tc` needed for what it covers), but `CT_TableCell` has no such shortcut — every row must
1182/// carry exactly one `<a:tc>` per `<a:gridCol>`, and a merge is expressed by marking the *absorbed*
1183/// cells with `hMerge`/`vMerge` (plain booleans, not an enum) rather than omitting them, while the
1184/// top/left cell of the merge carries `gridSpan`/`rowSpan` (the count, not a boolean).
1185/// [`SlideTable`]'s writer enforces the "one cell per grid column, every row" shape by padding a
1186/// short row with empty cells rather than silently producing an invalid `.pptx`.
1187#[derive(Debug, Clone, PartialEq, Default)]
1188pub struct TableCell {
1189    pub text_body: Option<TextBody>,
1190    /// `gridSpan` — how many grid columns this cell spans (a horizontal merge, the top/left cell
1191    /// only). `None` means `1` (no merge), matching `CT_TableCell`'s own schema default.
1192    pub horizontal_span: Option<u32>,
1193    /// `rowSpan` — how many rows this cell spans (a vertical merge, the top/left cell only). `None`
1194    /// means `1`.
1195    pub vertical_span: Option<u32>,
1196    /// `hMerge` — `true` marks this cell as absorbed by a horizontal merge from the cell to its
1197    /// left (this cell must still be present in the row, typically with no text of its own).
1198    pub horizontal_merge: bool,
1199    /// `vMerge` — `true` marks this cell as absorbed by a vertical merge from the cell above it.
1200    pub vertical_merge: bool,
1201    /// `<a:tcPr>` (`CT_TableCellProperties`) — this cell's own margins, vertical anchor, borders,
1202    /// and background fill, overriding whatever the table's style would otherwise apply. `None`
1203    /// (the default) omits `<a:tcPr>` entirely — the cell simply inherits everything from the table
1204    /// style, same "no override" default every other optional field in this crate uses.
1205    pub properties: Option<TableCellProperties>,
1206}
1207
1208impl TableCell {
1209    /// Creates an empty cell (no text, no merge, no property overrides).
1210    pub fn new() -> Self {
1211        Self::default()
1212    }
1213
1214    /// Sets this cell's text content.
1215    pub fn with_text_body(mut self, text_body: TextBody) -> Self {
1216        self.text_body = Some(text_body);
1217        self
1218    }
1219
1220    /// Sets this cell's own margin/anchor/border/fill overrides (`<a:tcPr>`).
1221    pub fn with_properties(mut self, properties: TableCellProperties) -> Self {
1222        self.properties = Some(properties);
1223        self
1224    }
1225
1226    /// Marks this cell as the top/left cell of a horizontal merge spanning `span` grid columns —
1227    /// the `span - 1` cells to its right still need to be present in the row, each created via
1228    /// [`TableCell::horizontally_merged`].
1229    pub fn with_horizontal_span(mut self, span: u32) -> Self {
1230        self.horizontal_span = Some(span);
1231        self
1232    }
1233
1234    /// Marks this cell as the top/left cell of a vertical merge spanning `span` rows — the `span -
1235    /// 1` cells below it (same column, following rows) still need to be present, each created via
1236    /// [`TableCell::vertically_merged`].
1237    pub fn with_vertical_span(mut self, span: u32) -> Self {
1238        self.vertical_span = Some(span);
1239        self
1240    }
1241
1242    /// An empty placeholder cell absorbed by a horizontal merge from its left neighbor
1243    /// (`hMerge="1"`).
1244    pub fn horizontally_merged() -> Self {
1245        Self {
1246            horizontal_merge: true,
1247            ..Self::default()
1248        }
1249    }
1250
1251    /// An empty placeholder cell absorbed by a vertical merge from the cell above it
1252    /// (`vMerge="1"`).
1253    pub fn vertically_merged() -> Self {
1254        Self {
1255            vertical_merge: true,
1256            ..Self::default()
1257        }
1258    }
1259}
1260
1261/// A [`TableCell`]'s own property overrides (`<a:tcPr>`, `CT_TableCellProperties`). Not modeled:
1262/// `anchorCtr`/`horzOverflow` attributes, the diagonal borders (`<a:lnTlToBr>`/`<a:lnBlToTr>`), and
1263/// `<a:cell3D>` (bevel/lighting effects) — all secondary refinements on top of margins, vertical
1264/// anchor, four straight borders, and background fill. Grounded against a real fixture, which
1265/// confirmed the attribute set and the
1266/// `lnL`/`lnR`/`lnT`/`lnB` then fill-choice element order (`CT_TableCellProperties`'s own
1267/// sequence).
1268#[derive(Debug, Clone, PartialEq, Default)]
1269pub struct TableCellProperties {
1270    /// `marL`, in EMUs.
1271    pub margin_left_emu: Option<i64>,
1272    /// `marT`, in EMUs.
1273    pub margin_top_emu: Option<i64>,
1274    /// `marR`, in EMUs.
1275    pub margin_right_emu: Option<i64>,
1276    /// `marB`, in EMUs.
1277    pub margin_bottom_emu: Option<i64>,
1278    /// `anchor` — vertical alignment of this cell's text within its own height. Reuses
1279    /// [`drawing::TextAnchor`] directly, the same type `<a:bodyPr anchor="..">` uses
1280    /// (`CT_TableCellProperties` and `CT_TextBodyProperties` both reference
1281    /// `ST_TextAnchoringType`).
1282    pub anchor: Option<TextAnchor>,
1283    /// `<a:lnL>` — this cell's left border.
1284    pub border_left: Option<Line>,
1285    /// `<a:lnR>` — this cell's right border.
1286    pub border_right: Option<Line>,
1287    /// `<a:lnT>` — this cell's top border.
1288    pub border_top: Option<Line>,
1289    /// `<a:lnB>` — this cell's bottom border.
1290    pub border_bottom: Option<Line>,
1291    /// `EG_FillProperties` — this cell's own background fill, overriding the table style's own cell
1292    /// fill.
1293    pub fill: Option<Fill>,
1294}
1295
1296impl TableCellProperties {
1297    pub fn new() -> Self {
1298        Self::default()
1299    }
1300
1301    /// Sets all four margins at once, in EMUs.
1302    pub fn with_margins_emu(mut self, left: i64, top: i64, right: i64, bottom: i64) -> Self {
1303        self.margin_left_emu = Some(left);
1304        self.margin_top_emu = Some(top);
1305        self.margin_right_emu = Some(right);
1306        self.margin_bottom_emu = Some(bottom);
1307        self
1308    }
1309
1310    /// Sets this cell's vertical text anchor.
1311    pub fn with_anchor(mut self, anchor: TextAnchor) -> Self {
1312        self.anchor = Some(anchor);
1313        self
1314    }
1315
1316    /// Sets this cell's left border.
1317    pub fn with_border_left(mut self, border: Line) -> Self {
1318        self.border_left = Some(border);
1319        self
1320    }
1321
1322    /// Sets this cell's right border.
1323    pub fn with_border_right(mut self, border: Line) -> Self {
1324        self.border_right = Some(border);
1325        self
1326    }
1327
1328    /// Sets this cell's top border.
1329    pub fn with_border_top(mut self, border: Line) -> Self {
1330        self.border_top = Some(border);
1331        self
1332    }
1333
1334    /// Sets this cell's bottom border.
1335    pub fn with_border_bottom(mut self, border: Line) -> Self {
1336        self.border_bottom = Some(border);
1337        self
1338    }
1339
1340    /// Sets this cell's own background fill.
1341    pub fn with_fill(mut self, fill: Fill) -> Self {
1342        self.fill = Some(fill);
1343        self
1344    }
1345}
1346
1347/// A slide comment (`ppt/comments/commentN.xml`'s own `<p:cm>`, `CT_Comment`) — the legacy comment
1348/// mechanism ECMA-376 itself defines (pre-2016 "modern threaded comments", which PowerPoint now
1349/// stores in a different, Microsoft-proprietary way this crate doesn't model).
1350///
1351/// **Not grounded on a real fixture** — see this module's own top-level doc comment. Structure
1352/// follows ECMA-376's `CT_Comment`/`CT_CommentList`/ `CT_CommentAuthor`/`CT_CommentAuthorList`
1353/// directly (§19.4): a `<p:cm>`'s `authorId` attribute references a `<p:cmAuthor>` entry in the
1354/// package-wide `ppt/commentAuthors.xml` by numeric id — this crate hides that indirection
1355/// entirely, storing the author's name/initials directly on each `SlideComment` and having the
1356/// writer deduplicate/number authors automatically (an author appearing on several comments becomes
1357/// one `<p:cmAuthor>` entry, matching how real PowerPoint itself avoids duplicate author entries).
1358#[derive(Debug, Clone, PartialEq)]
1359pub struct SlideComment {
1360    /// `<p:cmAuthor name="..">` — this comment's author's display name.
1361    pub author: String,
1362    /// `<p:cmAuthor initials="..">`.
1363    pub initials: String,
1364    /// `<p:cm dt="..">` (`ST_DateTime`) — an ISO-8601 date/time string (e.g.
1365    /// `"2024-01-15T10:30:00.000"`). Stored as a plain string, same posture as every other
1366    /// date/time field in this workspace (no dedicated date/time type anywhere).
1367    pub date: String,
1368    /// `<p:pos x=".." y="..">`, in EMUs — where the comment's marker sits on the slide.
1369    pub position_emu: (i64, i64),
1370    /// `<p:text>` — the comment's own body text (plain text only, unlike e.g.
1371    /// `word_ooxml::Comment`'s block-level content — `CT_Comment`'s `text` is a plain `xsd:string`,
1372    /// no rich formatting at all).
1373    pub text: String,
1374}
1375
1376impl SlideComment {
1377    /// Creates a comment at position `(0, 0)`.
1378    pub fn new(
1379        author: impl Into<String>,
1380        initials: impl Into<String>,
1381        date: impl Into<String>,
1382        text: impl Into<String>,
1383    ) -> Self {
1384        Self {
1385            author: author.into(),
1386            initials: initials.into(),
1387            date: date.into(),
1388            position_emu: (0, 0),
1389            text: text.into(),
1390        }
1391    }
1392
1393    /// Sets this comment's marker position.
1394    pub fn with_position(mut self, x_emu: i64, y_emu: i64) -> Self {
1395        self.position_emu = (x_emu, y_emu);
1396        self
1397    }
1398}
1399
1400/// A presentation's customizable color/font theme (`ppt/theme/theme1.xml`'s own
1401/// `<a:clrScheme>`/`<a:fontScheme>`).
1402///
1403/// Deliberately mirrors `word_ooxml::Theme`/`ColorScheme`/`FontScheme` by name and shape, not by
1404/// crate dependency — this workspace's established posture for a shared real-world concept with no
1405/// shared crate backing it (see `PictureFormat`'s own doc comment for the same reasoning). Unlike
1406/// `word_ooxml::Theme` (genuinely optional — `word/theme/theme1.xml` is only written when
1407/// `Document.theme` is set), a PowerPoint theme part is *mandatory*: `Presentation.theme: None`
1408/// still always produces a full `theme1.xml`, just with [`Theme::office_default`]'s colors/fonts
1409/// rather than a caller-supplied palette — see `Presentation`'s own doc comment.
1410///
1411/// Real PowerPoint files can and do carry more than one distinct theme (a real fixture has both
1412/// `theme1.xml` and `theme2.xml`, the latter the notes master's own); this crate, like
1413/// `word-ooxml`, still only exposes a single customizable theme for the slide master (the notes
1414/// master's own separate theme part stays fixed at `Theme::office_default`'s content regardless).
1415///
1416/// `fmtScheme` (`CT_BaseStyles` requires it alongside `clrScheme`/ `fontScheme`) stays a fixed,
1417/// non-configurable copy of Office's own built-in default, same scope limit as
1418/// `word_ooxml::Theme`'s own `fmtScheme` — irrelevant to `word-ooxml` there, but genuinely relevant
1419/// to a slide's shapes here; still out of scope.
1420#[derive(Debug, Clone, PartialEq, Eq)]
1421pub struct Theme {
1422    /// The theme's name (`<a:theme name="..">`), also reused unchanged as the nested
1423    /// `<a:clrScheme>`/`<a:fontScheme>` elements' own `name` attribute, same convention as
1424    /// `word_ooxml::Theme`.
1425    pub name: String,
1426    pub colors: ColorScheme,
1427    pub fonts: FontScheme,
1428}
1429
1430/// A theme's 12-color palette (`<a:clrScheme>`, `CT_ColorScheme`) — mirrors
1431/// `word_ooxml::ColorScheme` by name/shape (see that type's own doc comment for the full rationale,
1432/// including why every slot is a plain RGB hex string rather than modeling `<a:sysClr>`'s live
1433/// system-color binding).
1434#[derive(Debug, Clone, PartialEq, Eq)]
1435pub struct ColorScheme {
1436    pub dark1: String,
1437    pub light1: String,
1438    pub dark2: String,
1439    pub light2: String,
1440    pub accent1: String,
1441    pub accent2: String,
1442    pub accent3: String,
1443    pub accent4: String,
1444    pub accent5: String,
1445    pub accent6: String,
1446    pub hyperlink: String,
1447    pub followed_hyperlink: String,
1448}
1449
1450/// A theme's font scheme (`<a:fontScheme>`, `CT_FontScheme`) — mirrors `word_ooxml::FontScheme` by
1451/// name/shape (Latin typeface only, same scope limit).
1452#[derive(Debug, Clone, PartialEq, Eq)]
1453pub struct FontScheme {
1454    pub major_latin: String,
1455    pub minor_latin: String,
1456}
1457
1458impl Theme {
1459    /// Office's own built-in default theme ("Office": Calibri Light/ Calibri, the classic
1460    /// blue-accented palette) — byte-for-byte the same colors/fonts this crate's writer has always
1461    /// produced as its fixed theme, now expressed as data instead of a hardcoded XML literal.
1462    pub fn office_default() -> Self {
1463        Self {
1464            name: "Office".to_string(),
1465            colors: ColorScheme::office_default(),
1466            fonts: FontScheme::office_default(),
1467        }
1468    }
1469}
1470
1471impl ColorScheme {
1472    pub fn office_default() -> Self {
1473        Self {
1474            dark1: "000000".to_string(),
1475            light1: "FFFFFF".to_string(),
1476            dark2: "44546A".to_string(),
1477            light2: "E7E6E6".to_string(),
1478            accent1: "4472C4".to_string(),
1479            accent2: "ED7D31".to_string(),
1480            accent3: "A5A5A5".to_string(),
1481            accent4: "FFC000".to_string(),
1482            accent5: "5B9BD5".to_string(),
1483            accent6: "70AD47".to_string(),
1484            hyperlink: "0563C1".to_string(),
1485            followed_hyperlink: "954F72".to_string(),
1486        }
1487    }
1488}
1489
1490impl FontScheme {
1491    pub fn office_default() -> Self {
1492        Self {
1493            major_latin: "Calibri Light".to_string(),
1494            minor_latin: "Calibri".to_string(),
1495        }
1496    }
1497}
1498
1499/// Document metadata (`docProps/core.xml`'s Dublin Core fields plus `docProps/app.xml`'s
1500/// `Company`/`Manager`/`HyperlinkBase`, and `docProps/custom.xml`'s user-defined properties). A
1501/// direct port of `excel-ooxml::DocumentProperties`'s own shape (same fields, same posture — see
1502/// that type's own doc comment for the full rationale); no PowerPoint-specific fields added, since
1503/// `docProps/app.xml`'s schema (`CT_Properties`, extended-properties) is shared verbatim across
1504/// every OOXML application, only differing in which app-specific statistics fields (`Slides` here
1505/// vs. `Sheets`/`Words` elsewhere) each host actually sets — this crate already sets `Slides`
1506/// unconditionally in `writer.rs`, independent of this type.
1507#[derive(Debug, Default, Clone, PartialEq)]
1508pub struct DocumentProperties {
1509    /// `docProps/core.xml`'s `<dc:title>`.
1510    pub title: Option<String>,
1511    /// `docProps/core.xml`'s `<dc:creator>`.
1512    pub author: Option<String>,
1513    /// `docProps/core.xml`'s `<dc:subject>`.
1514    pub subject: Option<String>,
1515    /// `docProps/core.xml`'s `<cp:keywords>`.
1516    pub keywords: Option<String>,
1517    /// `docProps/app.xml`'s `<Company>`.
1518    pub company: Option<String>,
1519    /// `docProps/app.xml`'s `<Manager>`.
1520    pub manager: Option<String>,
1521    /// `docProps/app.xml`'s `<HyperlinkBase>`.
1522    pub hyperlink_base: Option<String>,
1523    /// User-defined custom document properties (`docProps/custom.xml`,
1524    /// `CT_Properties`/`CT_Property`). A `Vec` of `(name, value)` pairs, not a map: insertion order
1525    /// is preserved and determines the sequential `pid` each entry gets when written, matching
1526    /// `excel-ooxml`/`word-ooxml`'s own `custom_properties` convention exactly. Only written if
1527    /// non-empty.
1528    pub custom_properties: Vec<(String, CustomPropertyValue)>,
1529}
1530
1531/// A single custom document property's value — same 4 variants as
1532/// `excel-ooxml::CustomPropertyValue`/`word_ooxml::CustomPropertyValue` (`CT_Property`'s value
1533/// `xsd:choice` has several more — vectors, blobs, currency, a date variant — not modeled here,
1534/// same "simple case first" posture as those two sibling types).
1535#[derive(Debug, Clone, PartialEq)]
1536pub enum CustomPropertyValue {
1537    /// A text value (`vt:lpwstr`).
1538    Text(String),
1539    /// A boolean value (`vt:bool`).
1540    Bool(bool),
1541    /// A 32-bit signed integer value (`vt:i4`).
1542    Int(i32),
1543    /// A 64-bit floating-point value (`vt:r8`).
1544    Number(f64),
1545}
1546
1547impl DocumentProperties {
1548    pub fn new() -> Self {
1549        Self::default()
1550    }
1551
1552    pub fn with_title(mut self, title: impl Into<String>) -> Self {
1553        self.title = Some(title.into());
1554        self
1555    }
1556
1557    pub fn with_author(mut self, author: impl Into<String>) -> Self {
1558        self.author = Some(author.into());
1559        self
1560    }
1561
1562    pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
1563        self.subject = Some(subject.into());
1564        self
1565    }
1566
1567    pub fn with_keywords(mut self, keywords: impl Into<String>) -> Self {
1568        self.keywords = Some(keywords.into());
1569        self
1570    }
1571
1572    pub fn with_company(mut self, company: impl Into<String>) -> Self {
1573        self.company = Some(company.into());
1574        self
1575    }
1576
1577    pub fn with_manager(mut self, manager: impl Into<String>) -> Self {
1578        self.manager = Some(manager.into());
1579        self
1580    }
1581
1582    pub fn with_hyperlink_base(mut self, hyperlink_base: impl Into<String>) -> Self {
1583        self.hyperlink_base = Some(hyperlink_base.into());
1584        self
1585    }
1586
1587    /// Appends one custom document property.
1588    pub fn with_custom_property(
1589        mut self,
1590        name: impl Into<String>,
1591        value: CustomPropertyValue,
1592    ) -> Self {
1593        self.custom_properties.push((name.into(), value));
1594        self
1595    }
1596}
1597
1598/// A custom table style (`<a:tblStyle styleId="." styleName=".">`, `CT_TableStyle`). Package-wide
1599/// ([`Presentation::table_styles`]), referenced by id from [`SlideTable::style_id`].
1600///
1601/// Grounded against a real fixture (`ppt/tableStyles.xml`, the built-in "Medium Style 2 - Accent 1"
1602/// style). Real `CT_TableStyle` has 12 optional "table part" children
1603/// (`wholeTbl`/`band1H`/`band2H`/`band1V`/`band2V`/`firstRow`/`lastRow`/
1604/// `firstCol`/`lastCol`/`neCell`/`nwCell`/`seCell`/`swCell`); this crate models the 9
1605/// commonly-authored ones (everything but the four single- corner-cell variants, a real-world
1606/// rarity). Within each part (`CT_TablePartStyle`), only fill + bold/italic + text color are
1607/// modeled (see [`TableStylePart`]) — no per-side border customization (`CT_TableCellBorderStyle`)
1608/// and no `fontRef` index, both schema- optional. A deliberate scope reduction, not a
1609/// fixture-exhaustive mirror of every real PowerPoint table style's structure.
1610#[derive(Debug, Clone, PartialEq, Default)]
1611pub struct SlideTableStyle {
1612    /// `styleId` (`ST_Guid`) — referenced from [`SlideTable::style_id`].
1613    pub id: String,
1614    /// `styleName`.
1615    pub name: String,
1616    /// `<a:wholeTbl>` — the base style every other part layers on top of.
1617    pub whole_table: Option<TableStylePart>,
1618    /// `<a:band1H>` — odd row banding.
1619    pub band1_horizontal: Option<TableStylePart>,
1620    /// `<a:band2H>` — even row banding.
1621    pub band2_horizontal: Option<TableStylePart>,
1622    /// `<a:band1V>` — odd column banding.
1623    pub band1_vertical: Option<TableStylePart>,
1624    /// `<a:band2V>` — even column banding.
1625    pub band2_vertical: Option<TableStylePart>,
1626    /// `<a:firstRow>` — header row.
1627    pub first_row: Option<TableStylePart>,
1628    /// `<a:lastRow>` — total/footer row.
1629    pub last_row: Option<TableStylePart>,
1630    /// `<a:firstCol>`.
1631    pub first_column: Option<TableStylePart>,
1632    /// `<a:lastCol>`.
1633    pub last_column: Option<TableStylePart>,
1634}
1635
1636impl SlideTableStyle {
1637    /// Creates a table style with the given id/name and no parts set yet (an empty `<a:tblStyle>` —
1638    /// schema-valid, though visually a no-op until at least one part is set).
1639    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
1640        Self {
1641            id: id.into(),
1642            name: name.into(),
1643            ..Default::default()
1644        }
1645    }
1646
1647    pub fn with_whole_table(mut self, part: TableStylePart) -> Self {
1648        self.whole_table = Some(part);
1649        self
1650    }
1651
1652    pub fn with_band1_horizontal(mut self, part: TableStylePart) -> Self {
1653        self.band1_horizontal = Some(part);
1654        self
1655    }
1656
1657    pub fn with_band2_horizontal(mut self, part: TableStylePart) -> Self {
1658        self.band2_horizontal = Some(part);
1659        self
1660    }
1661
1662    pub fn with_band1_vertical(mut self, part: TableStylePart) -> Self {
1663        self.band1_vertical = Some(part);
1664        self
1665    }
1666
1667    pub fn with_band2_vertical(mut self, part: TableStylePart) -> Self {
1668        self.band2_vertical = Some(part);
1669        self
1670    }
1671
1672    pub fn with_first_row(mut self, part: TableStylePart) -> Self {
1673        self.first_row = Some(part);
1674        self
1675    }
1676
1677    pub fn with_last_row(mut self, part: TableStylePart) -> Self {
1678        self.last_row = Some(part);
1679        self
1680    }
1681
1682    pub fn with_first_column(mut self, part: TableStylePart) -> Self {
1683        self.first_column = Some(part);
1684        self
1685    }
1686
1687    pub fn with_last_column(mut self, part: TableStylePart) -> Self {
1688        self.last_column = Some(part);
1689        self
1690    }
1691}
1692
1693/// One "table part" style (`CT_TableStyleTextStyle` + `CT_TableStyleCellStyle` combined — real
1694/// PowerPoint always writes both wrapped in the same `<a:wholeTbl>`/`<a:band1H>`/etc. element, so
1695/// this crate models them as a single value rather than two separate optional fields) — see
1696/// [`SlideTableStyle`]'s own doc comment for scope.
1697#[derive(Debug, Clone, PartialEq, Default)]
1698pub struct TableStylePart {
1699    /// `<a:tcStyle><a:fill>{fill}</a:fill></a:tcStyle>` — the cell's own background. `None` omits
1700    /// `<a:fill>` (inherits).
1701    pub fill: Option<Fill>,
1702    /// `<a:tcTxStyle b="on">` — bold text.
1703    pub bold: bool,
1704    /// `<a:tcTxStyle i="on">` — italic text.
1705    pub italic: bool,
1706    /// `<a:tcTxStyle>{color}</a:tcTxStyle>` — the cell's own text color. `None` omits the color
1707    /// choice (inherits). Reuses [`Color`] verbatim — like the rest of this crate's own color
1708    /// usage, a theme-relative `<a:schemeClr>` reference isn't modeled.
1709    pub text_color: Option<Color>,
1710}
1711
1712impl TableStylePart {
1713    pub fn new() -> Self {
1714        Self::default()
1715    }
1716
1717    pub fn with_fill(mut self, fill: Fill) -> Self {
1718        self.fill = Some(fill);
1719        self
1720    }
1721
1722    pub fn with_bold(mut self, bold: bool) -> Self {
1723        self.bold = bold;
1724        self
1725    }
1726
1727    pub fn with_italic(mut self, italic: bool) -> Self {
1728        self.italic = italic;
1729        self
1730    }
1731
1732    pub fn with_text_color(mut self, color: Color) -> Self {
1733        self.text_color = Some(color);
1734        self
1735    }
1736}
1737
1738/// A TrueType/OpenType font embedded directly in the package (`<p:embeddedFont><p:font
1739/// typeface="."/>{variants}</p:embeddedFont>`, `CT_EmbeddedFontListEntry`).
1740///
1741/// **Not grounded on a real fixture** — no `.pptx` in this project's local corpus embeds a font.
1742/// Built directly from ECMA-376's own `CT_EmbeddedFontListEntry`/ `CT_EmbeddedFontDataId` schema
1743/// text instead — flagged for extra scrutiny once opened in real PowerPoint, same posture already
1744/// used for [`Connector`]/[`SlideComment`] and [`crate::MediaFormat`]'s `p14:media` non-support.
1745#[derive(Debug, Clone, PartialEq, Default)]
1746pub struct EmbeddedFont {
1747    /// `<p:font typeface="..">` — the font family name real PowerPoint resolves text runs against.
1748    pub typeface: String,
1749    /// `<p:regular r:id="..">`'s own `.fntdata` bytes.
1750    pub regular: Option<Vec<u8>>,
1751    /// `<p:bold r:id="..">`'s own `.fntdata` bytes.
1752    pub bold: Option<Vec<u8>>,
1753    /// `<p:italic r:id="..">`'s own `.fntdata` bytes.
1754    pub italic: Option<Vec<u8>>,
1755    /// `<p:boldItalic r:id="..">`'s own `.fntdata` bytes.
1756    pub bold_italic: Option<Vec<u8>>,
1757}
1758
1759impl EmbeddedFont {
1760    /// Creates an embedded font entry with no variants set yet (use
1761    /// `with_regular`/`with_bold`/`with_italic`/`with_bold_italic` to attach at least one).
1762    pub fn new(typeface: impl Into<String>) -> Self {
1763        Self {
1764            typeface: typeface.into(),
1765            ..Default::default()
1766        }
1767    }
1768
1769    pub fn with_regular(mut self, data: impl Into<Vec<u8>>) -> Self {
1770        self.regular = Some(data.into());
1771        self
1772    }
1773
1774    pub fn with_bold(mut self, data: impl Into<Vec<u8>>) -> Self {
1775        self.bold = Some(data.into());
1776        self
1777    }
1778
1779    pub fn with_italic(mut self, data: impl Into<Vec<u8>>) -> Self {
1780        self.italic = Some(data.into());
1781        self
1782    }
1783
1784    pub fn with_bold_italic(mut self, data: impl Into<Vec<u8>>) -> Self {
1785        self.bold_italic = Some(data.into());
1786        self
1787    }
1788}