Skip to main content

powerpoint_ooxml/
reader.rs

1//! Reading a `.pptx` package into our [`Presentation`] model.
2//!
3//! A slide's own shapes (autoshapes, pictures, charts, groups, connectors, tables —
4//! position/size/outline/fill/line, text, placeholder role), its speaker notes, its comments, and
5//! the slide master's theme are resolved. The slide master/layout's own *shape* content (as opposed
6//! to its theme) is still parsed by neither this function nor anything it calls (this crate has no
7//! customizable model for the master/layout themselves yet, see `model.rs`'s doc comment).
8//! Confirmed against real-world fixtures covering a single-slide deck, pictures/charts, notes,
9//! groups, tables, and a multi-theme package — see this crate's tests. Connectors and comments
10//! have no such fixture (none in this project's local corpus carries either) — see `model.rs`'s
11//! top-level doc comment.
12
13use std::collections::HashMap;
14use std::io::{Read, Seek};
15
16use drawing::{Fill, Geometry, PresetShape, ShapeProperties, TextAnchor, TextBody};
17use opc::{Package, Relationships, TargetMode};
18use xml_core::{BytesStart, Event, Reader};
19
20use crate::error::{Error, Result};
21use crate::model::{
22    AutoShape, ColorScheme, Connector, CustomPropertyValue, DEFAULT_SLIDE_HEIGHT_EMU,
23    DEFAULT_SLIDE_WIDTH_EMU, DocumentProperties, EmbeddedFont, FontScheme, MediaFormat, Picture,
24    PictureFormat, Placeholder, PlaceholderKind, Presentation, Shape, ShapeConnection, ShapeGroup,
25    Slide, SlideChart, SlideComment, SlideHyperlinkTarget, SlideMedia, SlideTable, SlideTableStyle,
26    TableCell, TableCellProperties, TableRow, TableStylePart, Theme,
27};
28
29const OFFICE_DOCUMENT_RELATIONSHIP_TYPE: &str =
30    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument";
31const NOTES_SLIDE_RELATIONSHIP_TYPE: &str =
32    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide";
33// An internal slide-jump hyperlink's own relationship type, distinguishing it from
34// `SlideHyperlinkTarget::External`'s (`"hyperlink"`) when resolving `<a:hlinkClick>`. Duplicated
35// from `writer.rs`'s own constant of the same name (per-module constants, not shared between
36// `reader.rs`/`writer.rs` — this crate's established convention).
37const SLIDE_RELATIONSHIP_TYPE: &str =
38    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide";
39const SLIDE_MASTER_RELATIONSHIP_TYPE: &str =
40    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster";
41const THEME_RELATIONSHIP_TYPE: &str =
42    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme";
43const COMMENT_AUTHORS_RELATIONSHIP_TYPE: &str =
44    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/commentAuthors";
45const COMMENT_RELATIONSHIP_TYPE: &str =
46    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
47const TABLE_NAMESPACE: &str = "http://schemas.openxmlformats.org/drawingml/2006/table";
48const CORE_PROPERTIES_RELATIONSHIP_TYPE: &str =
49    "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties";
50const EXTENDED_PROPERTIES_RELATIONSHIP_TYPE: &str =
51    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties";
52const CUSTOM_PROPERTIES_RELATIONSHIP_TYPE: &str =
53    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties";
54// No `FONT_RELATIONSHIP_TYPE` constant here (unlike `writer.rs`, which needs it to *write* the
55// relationship): `resolve_embedded_font_variant` below resolves an embedded font's `r:id` directly
56// via `Relationships::by_id`, without ever filtering by relationship type.
57const TABLE_STYLES_RELATIONSHIP_TYPE: &str =
58    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableStyles";
59// Same well-known "No Style, No Grid" GUID `writer.rs`'s own `TABLE_STYLE_GUID` uses — a table read
60// back with exactly this `<a:tableStyleId>` text resolves to `SlideTable::style_id: None` (the
61// fixed built-in fallback), not `Some("{5C22..}")`, preserving round-trip symmetry with a table
62// this crate's own writer produced without an explicit `style_id`.
63const TABLE_STYLE_GUID: &str = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}";
64
65impl Presentation {
66    /// Reads a `.pptx` package from a seekable byte source.
67    pub fn read_from<R: Read + Seek>(reader: R) -> Result<Self> {
68        let package = Package::read_from(reader)?;
69
70        let main_relationship = package
71            .relationships()
72            .iter()
73            .find(|relationship| relationship.rel_type == OFFICE_DOCUMENT_RELATIONSHIP_TYPE)
74            .ok_or(Error::MissingMainPresentation)?;
75        let presentation_part_name = match main_relationship.target_mode {
76            TargetMode::Internal => format!("/{}", main_relationship.target),
77            TargetMode::External => return Err(Error::MissingMainPresentation),
78        };
79        let presentation_part = package
80            .part(&presentation_part_name)
81            .ok_or(Error::MissingMainPresentation)?;
82        let xml = std::str::from_utf8(&presentation_part.data)
83            .map_err(|error| Error::InvalidUtf8(presentation_part_name.clone(), error))?;
84
85        let parsed = parse_presentation_xml(xml)?;
86
87        let mut presentation = Presentation::new();
88        presentation.slide_width_emu = parsed.slide_width_emu;
89        presentation.slide_height_emu = parsed.slide_height_emu;
90        presentation.theme = read_presentation_theme(&package, &presentation_part.relationships)?;
91
92        // `ppt/tableStyles.xml`. Always present (this crate's own writer always writes it, even
93        // with an empty `<a:tblStyleLst>`), but tolerant of it being missing regardless.
94        if let Some(relationship) = presentation_part
95            .relationships
96            .iter()
97            .find(|relationship| relationship.rel_type == TABLE_STYLES_RELATIONSHIP_TYPE)
98        {
99            let part_name = format!("/ppt/{}", relationship.target);
100            if let Some(part) = package.part(&part_name) {
101                let xml = std::str::from_utf8(&part.data)
102                    .map_err(|error| Error::InvalidUtf8(part_name.clone(), error))?;
103                presentation.table_styles = parse_table_styles_xml(xml)?;
104            }
105        }
106
107        // Embedded fonts — `parse_presentation_xml` only collected raw `r:id`s (it has no access to
108        // the package's own relationships/parts); resolve each to its actual `.fntdata` bytes here,
109        // tolerant of any one relationship/part being missing (skips that variant rather than
110        // erroring the whole presentation).
111        for parsed_font in &parsed.embedded_fonts {
112            presentation.embedded_fonts.push(EmbeddedFont {
113                typeface: parsed_font.typeface.clone(),
114                regular: resolve_embedded_font_variant(
115                    &package,
116                    &presentation_part.relationships,
117                    &parsed_font.regular,
118                ),
119                bold: resolve_embedded_font_variant(
120                    &package,
121                    &presentation_part.relationships,
122                    &parsed_font.bold,
123                ),
124                italic: resolve_embedded_font_variant(
125                    &package,
126                    &presentation_part.relationships,
127                    &parsed_font.italic,
128                ),
129                bold_italic: resolve_embedded_font_variant(
130                    &package,
131                    &presentation_part.relationships,
132                    &parsed_font.bold_italic,
133                ),
134            });
135        }
136
137        // `docProps/core.xml`/`app.xml`/`custom.xml`. Resolved off the *package root* relationships
138        // (`_rels/.rels`), same as `ppt/presentation.xml` itself. Tolerant of any one
139        // part/relationship being missing.
140        presentation.properties = read_document_properties(&package)?;
141
142        // `ppt/commentAuthors.xml` is package-wide, resolved once via `ppt/presentation.xml`'s own
143        // relationships — every comment on every slide references it by numeric author id (see
144        // `writer.rs`'s `CommentAuthorRegistry`'s own doc comment for the write side of this same
145        // indirection).
146        let comment_authors = match presentation_part
147            .relationships
148            .iter()
149            .find(|relationship| relationship.rel_type == COMMENT_AUTHORS_RELATIONSHIP_TYPE)
150        {
151            Some(relationship) => {
152                let part_name = format!("/ppt/{}", relationship.target);
153                match package.part(&part_name) {
154                    Some(part) => {
155                        let xml = std::str::from_utf8(&part.data)
156                            .map_err(|error| Error::InvalidUtf8(part_name.clone(), error))?;
157                        parse_comment_authors_xml(xml)?
158                    }
159                    None => HashMap::new(),
160                }
161            }
162            None => HashMap::new(),
163        };
164
165        for relationship_id in &parsed.slide_relationship_ids {
166            let relationship = presentation_part
167                .relationships
168                .by_id(relationship_id)
169                .ok_or_else(|| Error::MissingSlideRelationship(relationship_id.clone()))?;
170            // `ppt/presentation.xml`'s relationships are relative to `ppt/` (e.g.
171            // "slides/slide1.xml") — mirrors `word-ooxml`'s own `/word/` convention for
172            // `word/document.xml.rels`.
173            let slide_part_name = format!("/ppt/{}", relationship.target);
174            let slide_part = package
175                .part(&slide_part_name)
176                .ok_or_else(|| Error::MissingSlidePart(slide_part_name.clone()))?;
177            let slide_xml = std::str::from_utf8(&slide_part.data)
178                .map_err(|error| Error::InvalidUtf8(slide_part_name.clone(), error))?;
179
180            let context = SlideContext {
181                slide_part_name: &slide_part_name,
182                relationships: &slide_part.relationships,
183                package: &package,
184            };
185            let mut slide = parse_slide_xml(slide_xml, &context)?;
186
187            // A slide's speaker notes live in a *separate* part
188            // (`ppt/notesSlides/notesSlideN.xml`), reached via a `notesSlide`-typed relationship on
189            // the slide's own `.rels` — not embedded in the slide's own XML at all. Only present
190            // when the slide actually has notes (see `Slide::notes`'s doc comment).
191            if let Some(notes_relationship) = slide_part
192                .relationships
193                .iter()
194                .find(|relationship| relationship.rel_type == NOTES_SLIDE_RELATIONSHIP_TYPE)
195            {
196                let notes_part_name =
197                    resolve_relative_target(&slide_part_name, &notes_relationship.target);
198                let notes_part = package
199                    .part(&notes_part_name)
200                    .ok_or_else(|| Error::MissingNotesPart(notes_part_name.clone()))?;
201                let notes_xml = std::str::from_utf8(&notes_part.data)
202                    .map_err(|error| Error::InvalidUtf8(notes_part_name.clone(), error))?;
203                slide.notes = Some(parse_notes_slide_xml(notes_xml)?);
204            }
205
206            // A slide's comments live in their own part (`ppt/comments/commentN.xml`), reached via
207            // a `comments`- typed relationship on the slide's own `.rels`, same "own separate part"
208            // shape as notes. **Not grounded on a real fixture** — see `model.rs`'s top-level doc
209            // comment.
210            if let Some(comments_relationship) = slide_part
211                .relationships
212                .iter()
213                .find(|relationship| relationship.rel_type == COMMENT_RELATIONSHIP_TYPE)
214            {
215                let comments_part_name =
216                    resolve_relative_target(&slide_part_name, &comments_relationship.target);
217                if let Some(comments_part) = package.part(&comments_part_name) {
218                    let comments_xml = std::str::from_utf8(&comments_part.data)
219                        .map_err(|error| Error::InvalidUtf8(comments_part_name.clone(), error))?;
220                    slide.comments = parse_comments_xml(comments_xml, &comment_authors)?;
221                }
222            }
223
224            presentation.slides.push(slide);
225        }
226
227        Ok(presentation)
228    }
229}
230
231/// The pieces of `ppt/presentation.xml` this crate reads back.
232struct ParsedPresentation {
233    slide_width_emu: i64,
234    slide_height_emu: i64,
235    /// The `r:id` of each `<p:sldId>`, in `<p:sldIdLst>` (display) order.
236    slide_relationship_ids: Vec<String>,
237    /// `<p:embeddedFontLst>`'s own entries, `r:id`s not yet resolved to actual bytes (that needs
238    /// the package/relationships this XML-only parser doesn't have access to — see `read_from`'s
239    /// own resolution step).
240    embedded_fonts: Vec<ParsedEmbeddedFont>,
241}
242
243/// One `<p:embeddedFont>` entry, `r:id`s not yet resolved.
244struct ParsedEmbeddedFont {
245    typeface: String,
246    regular: Option<String>,
247    bold: Option<String>,
248    italic: Option<String>,
249    bold_italic: Option<String>,
250}
251
252/// Parses `ppt/presentation.xml` (`CT_Presentation`): `<p:sldSz>`, the ordered list of slide
253/// relationship ids from `<p:sldIdLst>`, and `<p:embeddedFontLst>`. Everything else
254/// (`<p:sldMasterIdLst>`, `<p:notesMasterIdLst>`, `<p:notesSz>`, `<p:defaultTextStyle>`.) is
255/// ignored — this crate has no model for any of it (the notes master is always the single fixed one
256/// this crate's own writer produces, so there's nothing distinguishing to read back).
257fn parse_presentation_xml(xml: &str) -> Result<ParsedPresentation> {
258    let mut slide_width_emu = DEFAULT_SLIDE_WIDTH_EMU;
259    let mut slide_height_emu = DEFAULT_SLIDE_HEIGHT_EMU;
260    let mut slide_relationship_ids = Vec::new();
261    let mut embedded_fonts = Vec::new();
262
263    let mut reader = Reader::from_xml_str(xml);
264    loop {
265        match reader.read_event()? {
266            Event::Eof => break,
267
268            Event::Empty(start) if start.local_name().as_ref() == b"sldId" => {
269                if let Some(relationship_id) = attribute_by_qname(&start, b"r:id") {
270                    slide_relationship_ids.push(relationship_id);
271                }
272            }
273
274            Event::Empty(start) if start.local_name().as_ref() == b"sldSz" => {
275                if let Some(value) =
276                    attribute_by_qname(&start, b"cx").and_then(|value| value.parse().ok())
277                {
278                    slide_width_emu = value;
279                }
280                if let Some(value) =
281                    attribute_by_qname(&start, b"cy").and_then(|value| value.parse().ok())
282                {
283                    slide_height_emu = value;
284                }
285            }
286
287            Event::Start(start) if start.local_name().as_ref() == b"embeddedFontLst" => {
288                embedded_fonts = parse_embedded_font_list(&mut reader)?;
289            }
290
291            _ => {}
292        }
293    }
294
295    Ok(ParsedPresentation {
296        slide_width_emu,
297        slide_height_emu,
298        slide_relationship_ids,
299        embedded_fonts,
300    })
301}
302
303/// Parses `<p:embeddedFontLst>`'s content (a reader positioned right after its own opening tag was
304/// consumed), stopping at `</p:embeddedFontLst>`.
305fn parse_embedded_font_list(reader: &mut Reader) -> Result<Vec<ParsedEmbeddedFont>> {
306    let mut fonts = Vec::new();
307
308    loop {
309        match reader.read_event()? {
310            Event::Start(start) if start.local_name().as_ref() == b"embeddedFont" => {
311                fonts.push(parse_embedded_font(reader)?);
312            }
313            Event::End(end) if end.local_name().as_ref() == b"embeddedFontLst" => break,
314            Event::Eof => break,
315            _ => {}
316        }
317    }
318
319    Ok(fonts)
320}
321
322/// Parses one `<p:embeddedFont>`'s content, assuming its own `Start` was just consumed by the
323/// caller — consumes up to and including its own closing tag.
324fn parse_embedded_font(reader: &mut Reader) -> Result<ParsedEmbeddedFont> {
325    let mut typeface = String::new();
326    let mut regular = None;
327    let mut bold = None;
328    let mut italic = None;
329    let mut bold_italic = None;
330
331    loop {
332        match reader.read_event()? {
333            Event::Empty(start) if start.local_name().as_ref() == b"font" => {
334                typeface = attribute_by_qname(&start, b"typeface").unwrap_or_default();
335            }
336            Event::Empty(start) if start.local_name().as_ref() == b"regular" => {
337                regular = attribute_by_qname(&start, b"r:id");
338            }
339            Event::Empty(start) if start.local_name().as_ref() == b"bold" => {
340                bold = attribute_by_qname(&start, b"r:id");
341            }
342            Event::Empty(start) if start.local_name().as_ref() == b"italic" => {
343                italic = attribute_by_qname(&start, b"r:id");
344            }
345            Event::Empty(start) if start.local_name().as_ref() == b"boldItalic" => {
346                bold_italic = attribute_by_qname(&start, b"r:id");
347            }
348
349            Event::Start(_) => skip_subtree(reader)?,
350            Event::End(end) if end.local_name().as_ref() == b"embeddedFont" => break,
351            Event::Eof => break,
352            _ => {}
353        }
354    }
355
356    Ok(ParsedEmbeddedFont {
357        typeface,
358        regular,
359        bold,
360        italic,
361        bold_italic,
362    })
363}
364
365/// The context a single slide's shapes are parsed against: its own part name (needed to resolve any
366/// relative relationship target it carries, e.g. `./media/image1.png`) and its own `.rels`
367/// relationships (images/ charts/notes), plus the whole package (to fetch the resolved parts'
368/// bytes). Mirrors `word-ooxml`/`excel-ooxml`'s own per-part reading context.
369struct SlideContext<'a> {
370    slide_part_name: &'a str,
371    relationships: &'a Relationships,
372    package: &'a Package,
373}
374
375/// Resolves a relationship target that's relative to its owning part's own directory (e.g.
376/// `./media/image1.png` from `ppt/slides/_rels/slide1.xml.rels`) into an absolute part name (e.g.
377/// `/ppt/media/image1.png`).
378///
379/// Unlike `ppt/presentation.xml`'s own relationships (always resolved with the simple
380/// `format!("/ppt/{}", target)` trick, reused above, because `ppt/presentation.xml` sits directly
381/// inside `ppt/`), a slide's own relationships need real relative-path resolution: `ppt/slides/`
382/// and `ppt/media/`/`ppt/charts/`/`ppt/notesSlides/` are *siblings* under `ppt/`, not parent/child,
383/// so a target like `./media/image1.png` must walk back up one directory from `ppt/slides/` before
384/// descending into `media/` — confirmed against real-world fixtures.
385fn resolve_relative_target(base_part_name: &str, target: &str) -> String {
386    let base_directory = match base_part_name.rfind('/') {
387        Some(index) => &base_part_name[..index],
388        None => "",
389    };
390    let mut segments: Vec<&str> = base_directory
391        .split('/')
392        .filter(|segment| !segment.is_empty())
393        .collect();
394
395    for segment in target.split('/') {
396        match segment {
397            "" | "." => {}
398            ".." => {
399                segments.pop();
400            }
401            other => segments.push(other),
402        }
403    }
404
405    format!("/{}", segments.join("/"))
406}
407
408/// Parses one `ppt/slides/slideN.xml` (`CT_Slide`) into a [`Slide`]: every
409/// `<p:sp>`/`<p:pic>`/`<p:graphicFrame>`/`<p:grpSp>`/`<p:cxnSp>` in `<p:spTree>` becomes a
410/// [`Shape`]. This is a flat, non-nesting-aware event scan (matching by local name wherever it
411/// occurs in the document, not by tracking `<p:spTree>` depth) — safe because every shape-kind
412/// parser this dispatches to fully consumes its own subtree (including any nested shapes inside a
413/// `<p:grpSp>`, via [`parse_group_shape`]'s own recursive call back into this same per-shape
414/// dispatch) before returning, so the reader's cursor never lands on an already-handled inner
415/// element a second time. Speaker notes/comments aren't resolved here (they live in separate parts,
416/// see `Presentation::read_from`).
417fn parse_slide_xml(xml: &str, context: &SlideContext) -> Result<Slide> {
418    let mut slide = Slide::new();
419    let mut reader = Reader::from_xml_str(xml);
420    let content = parse_shapes_until_eof(&mut reader, context)?;
421    slide.shapes = content.shapes;
422    slide.background = content.background;
423    slide.hidden = content.hidden;
424    slide.name = content.name;
425    slide.hide_master_graphics = content.hide_master_graphics;
426    Ok(slide)
427}
428
429/// Scans for top-level shape-starting events until EOF, dispatching each to its own parser — the
430/// part shared by [`parse_slide_xml`] (the whole document) and [`parse_group_shape`] (a group's own
431/// nested shape list, which stops at its own `</p:grpSp>` instead of EOF — see that function's own
432/// loop). Also captures `<p:bg>` — `<p:cSld>`'s own first, optional child, harmless to detect via
433/// this same flat scan since it can never itself contain a shape — and `<p:sld
434/// show=".">`/`<p:cSld name=".">`'s own attributes, plus `<p:sld showMasterSp=".">`
435/// (`<p:grpSp>` and every shape kind also happen to carry a `name` attribute of their own, but on
436/// `<p:cNvPr>`, never bare `name` on the element itself the way `<p:cSld>` does — no ambiguity in
437/// practice). This function is only ever called once per document, on the whole `<p:sld>`
438/// (`<p:grpSp>`'s own nested shapes are scanned by [`parse_group_shape`]'s own, separate loop,
439/// which has no use for `<p:sld>`/`<p:cSld>`'s attributes and so doesn't capture them). The
440/// `<p:sld>`-level fields [`parse_shapes_until_eof`] collects in one EOF-terminated pass — grouped
441/// into a struct instead of a 5-tuple so the return type stays readable at the call site (clippy's
442/// `type_complexity` flags bare tuples this wide, and named fields also rule out silently
443/// transposing two same-typed positions, e.g. the two `bool`s here).
444struct ParsedSlideContent {
445    shapes: Vec<Shape>,
446    background: Option<Fill>,
447    hidden: bool,
448    name: Option<String>,
449    hide_master_graphics: bool,
450}
451
452fn parse_shapes_until_eof(
453    reader: &mut Reader,
454    context: &SlideContext,
455) -> Result<ParsedSlideContent> {
456    let mut shapes = Vec::new();
457    let mut background = None;
458    let mut hidden = false;
459    let mut name = None;
460    let mut hide_master_graphics = false;
461    loop {
462        match reader.read_event()? {
463            Event::Eof => break,
464            Event::Start(start) if start.local_name().as_ref() == b"sld" => {
465                hidden = attribute_by_qname(&start, b"show").as_deref() == Some("0");
466                hide_master_graphics =
467                    attribute_by_qname(&start, b"showMasterSp").as_deref() == Some("0");
468            }
469            Event::Start(start) if start.local_name().as_ref() == b"cSld" => {
470                name = attribute_by_qname(&start, b"name").filter(|value| !value.is_empty());
471            }
472            Event::Start(start) if start.local_name().as_ref() == b"bg" => {
473                background = parse_slide_background(reader)?;
474            }
475            Event::Start(start) if start.local_name().as_ref() == b"sp" => {
476                shapes.push(Shape::AutoShape(parse_auto_shape(reader, Some(context))?));
477            }
478            Event::Start(start) if start.local_name().as_ref() == b"pic" => {
479                shapes.push(parse_pic_shape(reader, context)?);
480            }
481            Event::Start(start) if start.local_name().as_ref() == b"graphicFrame" => {
482                shapes.push(parse_graphic_frame_shape(reader, context)?);
483            }
484            Event::Start(start) if start.local_name().as_ref() == b"grpSp" => {
485                shapes.push(Shape::Group(parse_group_shape(reader, context)?));
486            }
487            Event::Start(start) if start.local_name().as_ref() == b"cxnSp" => {
488                shapes.push(Shape::Connector(parse_connector(reader)?));
489            }
490            _ => {}
491        }
492    }
493    Ok(ParsedSlideContent {
494        shapes,
495        background,
496        hidden,
497        name,
498        hide_master_graphics,
499    })
500}
501
502/// Parses `<p:bg>`'s content (a reader positioned right after its own opening tag was consumed),
503/// stopping at `</p:bg>` — only `<p:bgPr>`'s own fill choice is read back (`<p:bgRef>`, a theme
504/// background-style reference, is not modeled — see [`crate::model::Slide::background`]'s own doc
505/// comment).
506fn parse_slide_background(reader: &mut Reader) -> Result<Option<Fill>> {
507    let mut fill = None;
508    loop {
509        match reader.read_event()? {
510            Event::Eof => break,
511            Event::End(end) if end.local_name().as_ref() == b"bg" => break,
512            Event::Start(start) if start.local_name().as_ref() == b"bgPr" => {
513                fill = drawing::read_fill(reader)?;
514            }
515            Event::Start(_) => skip_subtree(reader)?,
516            Event::Empty(_) => {}
517            _ => {}
518        }
519    }
520    Ok(fill)
521}
522
523/// Parses `<p:grpSp>`'s content (`CT_GroupShape`) from a reader positioned right after the caller
524/// already consumed its own opening tag. Recurses back into the same per-shape dispatch as
525/// [`parse_shapes_until_eof`] for its own children (including nested groups), stopping at its own
526/// `</p:grpSp>` instead of EOF. Confirmed against a real fixture with nested groups.
527fn parse_group_shape(reader: &mut Reader, context: &SlideContext) -> Result<ShapeGroup> {
528    let mut id: u32 = 0;
529    let mut name = String::new();
530    let mut offset_emu = (0i64, 0i64);
531    let mut extent_emu = (0i64, 0i64);
532    let mut child_offset_emu = (0i64, 0i64);
533    let mut child_extent_emu = (0i64, 0i64);
534    let mut rotation_60000ths: i32 = 0;
535    let mut flip_horizontal = false;
536    let mut flip_vertical = false;
537    let mut shapes = Vec::new();
538
539    loop {
540        match reader.read_event()? {
541            Event::Eof => break,
542            Event::End(end) if end.local_name().as_ref() == b"grpSp" => break,
543
544            Event::Start(start) if start.local_name().as_ref() == b"nvGrpSpPr" => {}
545            Event::End(end) if end.local_name().as_ref() == b"nvGrpSpPr" => {}
546
547            Event::Empty(start) if start.local_name().as_ref() == b"cNvPr" => {
548                id = attribute_by_qname(&start, b"id")
549                    .and_then(|value| value.parse().ok())
550                    .unwrap_or(0);
551                name = attribute_by_qname(&start, b"name").unwrap_or_default();
552            }
553            Event::Start(start) if start.local_name().as_ref() == b"cNvPr" => {
554                id = attribute_by_qname(&start, b"id")
555                    .and_then(|value| value.parse().ok())
556                    .unwrap_or(0);
557                name = attribute_by_qname(&start, b"name").unwrap_or_default();
558                skip_subtree(reader)?;
559            }
560
561            Event::Empty(start) if start.local_name().as_ref() == b"cNvGrpSpPr" => {}
562            Event::Start(start) if start.local_name().as_ref() == b"cNvGrpSpPr" => {
563                // Can carry `<a:grpSpLocks/>` — not modeled.
564                skip_subtree(reader)?;
565            }
566
567            Event::Empty(start) if start.local_name().as_ref() == b"nvPr" => {}
568            Event::Start(start) if start.local_name().as_ref() == b"nvPr" => skip_subtree(reader)?,
569
570            Event::Start(start) if start.local_name().as_ref() == b"grpSpPr" => {}
571            Event::End(end) if end.local_name().as_ref() == b"grpSpPr" => {}
572
573            Event::Start(start) if start.local_name().as_ref() == b"xfrm" => {
574                rotation_60000ths = attribute_by_qname(&start, b"rot")
575                    .and_then(|value| value.parse().ok())
576                    .unwrap_or(0);
577                flip_horizontal = attribute_by_qname(&start, b"flipH").as_deref() == Some("1");
578                flip_vertical = attribute_by_qname(&start, b"flipV").as_deref() == Some("1");
579                let transform = parse_group_transform(reader)?;
580                offset_emu = transform.offset;
581                extent_emu = transform.extent;
582                child_offset_emu = transform.child_offset;
583                child_extent_emu = transform.child_extent;
584            }
585
586            // Nested shapes — same dispatch as the top-level `<p:spTree>`.
587            Event::Start(start) if start.local_name().as_ref() == b"sp" => {
588                shapes.push(Shape::AutoShape(parse_auto_shape(reader, Some(context))?));
589            }
590            Event::Start(start) if start.local_name().as_ref() == b"pic" => {
591                shapes.push(parse_pic_shape(reader, context)?);
592            }
593            Event::Start(start) if start.local_name().as_ref() == b"graphicFrame" => {
594                shapes.push(parse_graphic_frame_shape(reader, context)?);
595            }
596            Event::Start(start) if start.local_name().as_ref() == b"grpSp" => {
597                shapes.push(Shape::Group(parse_group_shape(reader, context)?));
598            }
599            Event::Start(start) if start.local_name().as_ref() == b"cxnSp" => {
600                shapes.push(Shape::Connector(parse_connector(reader)?));
601            }
602
603            Event::Start(_) => skip_subtree(reader)?,
604            Event::Empty(_) => {}
605            _ => {}
606        }
607    }
608
609    Ok(ShapeGroup {
610        id,
611        name,
612        offset_emu,
613        extent_emu,
614        child_offset_emu,
615        child_extent_emu,
616        rotation_60000ths,
617        flip_horizontal,
618        flip_vertical,
619        shapes,
620    })
621}
622
623/// The four `(x, y)`-shaped pairs [`parse_group_transform`] reads off a `<p:grpSpPr>`'s `<a:xfrm>`
624/// — grouped into a struct instead of a 4-tuple-of-tuples (clippy's `type_complexity` flags nesting
625/// this deep, and named fields also make it obvious which pair is the group's own on-slide extent
626/// versus its children's coordinate-space extent, which a bare positional tuple doesn't).
627struct GroupTransform {
628    offset: (i64, i64),
629    extent: (i64, i64),
630    child_offset: (i64, i64),
631    child_extent: (i64, i64),
632}
633
634/// Parses a `<p:grpSpPr>`'s own `<a:xfrm>` — `<a:off>`/`<a:ext>` (the group's own on-slide
635/// position/size) plus `<a:chOff>`/`<a:chExt>` (the coordinate space its children's own `xfrm`
636/// values are expressed in), from a reader positioned right after `<a:xfrm>`'s own opening tag was
637/// consumed.
638fn parse_group_transform(reader: &mut Reader) -> Result<GroupTransform> {
639    let mut offset = (0i64, 0i64);
640    let mut extent = (0i64, 0i64);
641    let mut child_offset = (0i64, 0i64);
642    let mut child_extent = (0i64, 0i64);
643
644    loop {
645        match reader.read_event()? {
646            Event::Eof => break,
647            Event::End(end) if end.local_name().as_ref() == b"xfrm" => break,
648
649            Event::Empty(start) if start.local_name().as_ref() == b"off" => {
650                offset = read_xy_attributes(&start, b"x", b"y");
651            }
652            Event::Empty(start) if start.local_name().as_ref() == b"ext" => {
653                extent = read_xy_attributes(&start, b"cx", b"cy");
654            }
655            Event::Empty(start) if start.local_name().as_ref() == b"chOff" => {
656                child_offset = read_xy_attributes(&start, b"x", b"y");
657            }
658            Event::Empty(start) if start.local_name().as_ref() == b"chExt" => {
659                child_extent = read_xy_attributes(&start, b"cx", b"cy");
660            }
661
662            Event::Start(_) => skip_subtree(reader)?,
663            Event::Empty(_) => {}
664            _ => {}
665        }
666    }
667
668    Ok(GroupTransform {
669        offset,
670        extent,
671        child_offset,
672        child_extent,
673    })
674}
675
676/// Reads a two-attribute empty element's values as `(i64, i64)`, e.g. `<a:off x="1" y="2"/>` with
677/// `first = b"x"`, `second = b"y"`.
678fn read_xy_attributes(start: &BytesStart, first: &[u8], second: &[u8]) -> (i64, i64) {
679    let first_value = attribute_by_qname(start, first)
680        .and_then(|value| value.parse().ok())
681        .unwrap_or(0);
682    let second_value = attribute_by_qname(start, second)
683        .and_then(|value| value.parse().ok())
684        .unwrap_or(0);
685    (first_value, second_value)
686}
687
688/// Parses `<p:cxnSp>`'s content (`CT_Connector`) from a reader positioned right after the caller
689/// already consumed its own opening tag. **Not grounded on a real fixture** — see `model.rs`'s
690/// top-level doc comment.
691fn parse_connector(reader: &mut Reader) -> Result<Connector> {
692    let mut id: u32 = 0;
693    let mut name = String::new();
694    let mut properties = ShapeProperties::new();
695    let mut start_connection = None;
696    let mut end_connection = None;
697
698    loop {
699        match reader.read_event()? {
700            Event::Eof => break,
701            Event::End(end) if end.local_name().as_ref() == b"cxnSp" => break,
702
703            Event::Start(start) if start.local_name().as_ref() == b"nvCxnSpPr" => {}
704            Event::End(end) if end.local_name().as_ref() == b"nvCxnSpPr" => {}
705
706            Event::Empty(start) if start.local_name().as_ref() == b"cNvPr" => {
707                id = attribute_by_qname(&start, b"id")
708                    .and_then(|value| value.parse().ok())
709                    .unwrap_or(0);
710                name = attribute_by_qname(&start, b"name").unwrap_or_default();
711            }
712            Event::Start(start) if start.local_name().as_ref() == b"cNvPr" => {
713                id = attribute_by_qname(&start, b"id")
714                    .and_then(|value| value.parse().ok())
715                    .unwrap_or(0);
716                name = attribute_by_qname(&start, b"name").unwrap_or_default();
717                skip_subtree(reader)?;
718            }
719
720            Event::Empty(start) if start.local_name().as_ref() == b"cNvCxnSpPr" => {}
721            Event::Start(start) if start.local_name().as_ref() == b"cNvCxnSpPr" => {}
722            Event::End(end) if end.local_name().as_ref() == b"cNvCxnSpPr" => {}
723
724            Event::Empty(start) if start.local_name().as_ref() == b"stCxn" => {
725                start_connection = Some(parse_shape_connection(&start));
726            }
727            Event::Empty(start) if start.local_name().as_ref() == b"endCxn" => {
728                end_connection = Some(parse_shape_connection(&start));
729            }
730
731            Event::Empty(start) if start.local_name().as_ref() == b"nvPr" => {}
732            Event::Start(start) if start.local_name().as_ref() == b"nvPr" => skip_subtree(reader)?,
733
734            Event::Empty(start) if start.local_name().as_ref() == b"spPr" => {}
735            Event::Start(start) if start.local_name().as_ref() == b"spPr" => {
736                properties = drawing::read_shape_properties(reader)?;
737            }
738
739            Event::Start(_) => skip_subtree(reader)?,
740            Event::Empty(_) => {}
741            _ => {}
742        }
743    }
744
745    Ok(Connector {
746        id,
747        name,
748        properties,
749        start_connection,
750        end_connection,
751    })
752}
753
754/// Reads a `<a:stCxn>`/`<a:endCxn>`'s `id`/`idx` attributes (`CT_Connection`).
755fn parse_shape_connection(start: &BytesStart) -> ShapeConnection {
756    ShapeConnection {
757        shape_id: attribute_by_qname(start, b"id")
758            .and_then(|value| value.parse().ok())
759            .unwrap_or(0),
760        index: attribute_by_qname(start, b"idx")
761            .and_then(|value| value.parse().ok())
762            .unwrap_or(0),
763    }
764}
765
766/// Parses `<p:sp>`'s content (`CT_Shape`) from a reader positioned right after the caller already
767/// consumed its own opening tag — same contract as
768/// `drawing::read_shape_properties`/`read_text_body`. `context` resolves a `<a:hlinkClick>` on this
769/// shape's own `<p:cNvPr>` back to its real target URL; `None` when no relationship context is
770/// available (e.g. [`parse_notes_slide_xml`]'s own placeholder shapes, which this crate never gives
771/// a hyperlink anyway) — the hyperlink is then simply left unresolved (`None`), same forgiving
772/// posture as the rest of this reader.
773fn parse_auto_shape(reader: &mut Reader, context: Option<&SlideContext>) -> Result<AutoShape> {
774    let mut id: u32 = 0;
775    let mut name = String::new();
776    let mut properties = ShapeProperties::new();
777    let mut text_body = None;
778    let mut placeholder = None;
779    let mut is_text_box = false;
780    let mut hyperlink = None;
781
782    loop {
783        match reader.read_event()? {
784            Event::Eof => break,
785            Event::End(end) if end.local_name().as_ref() == b"sp" => break,
786
787            // `<p:nvSpPr>` is a transparent wrapper for `<p:cNvPr>`/ `<p:cNvSpPr>`/`<p:nvPr>` — its
788            // own Start/End carry nothing to capture, so the loop simply falls through into its
789            // children.
790            Event::Start(start) if start.local_name().as_ref() == b"nvSpPr" => {}
791            Event::End(end) if end.local_name().as_ref() == b"nvSpPr" => {}
792
793            Event::Empty(start) if start.local_name().as_ref() == b"cNvPr" => {
794                id = attribute_by_qname(&start, b"id")
795                    .and_then(|value| value.parse().ok())
796                    .unwrap_or(0);
797                name = attribute_by_qname(&start, b"name").unwrap_or_default();
798            }
799            Event::Start(start) if start.local_name().as_ref() == b"cNvPr" => {
800                id = attribute_by_qname(&start, b"id")
801                    .and_then(|value| value.parse().ok())
802                    .unwrap_or(0);
803                name = attribute_by_qname(&start, b"name").unwrap_or_default();
804                // Real PowerPoint always nests an `<a:extLst>` (creation id) here, alongside an
805                // optional `<a:hlinkClick>` — the latter is now resolved, the former still skipped
806                // whole.
807                hyperlink = parse_cnv_pr_hyperlink(reader, context)?;
808            }
809
810            Event::Empty(start) if start.local_name().as_ref() == b"cNvSpPr" => {
811                is_text_box = attribute_by_qname(&start, b"txBox").as_deref() == Some("1");
812            }
813            Event::Start(start) if start.local_name().as_ref() == b"cNvSpPr" => {
814                is_text_box = attribute_by_qname(&start, b"txBox").as_deref() == Some("1");
815                // Can carry `<a:spLocks../>` — not modeled.
816                skip_subtree(reader)?;
817            }
818
819            Event::Empty(start) if start.local_name().as_ref() == b"nvPr" => {}
820            Event::Start(start) if start.local_name().as_ref() == b"nvPr" => {
821                placeholder = parse_placeholder_wrapper(reader)?;
822            }
823
824            Event::Empty(start) if start.local_name().as_ref() == b"spPr" => {}
825            Event::Start(start) if start.local_name().as_ref() == b"spPr" => {
826                properties = drawing::read_shape_properties(reader)?;
827            }
828
829            Event::Start(start) if start.local_name().as_ref() == b"txBody" => {
830                text_body = Some(drawing::read_text_body(reader)?);
831            }
832
833            // `<p:style>` and anything else this crate doesn't model.
834            Event::Start(_) => skip_subtree(reader)?,
835            Event::Empty(_) => {}
836
837            _ => {}
838        }
839    }
840
841    Ok(AutoShape {
842        id,
843        name,
844        properties,
845        text_body,
846        placeholder,
847        is_text_box,
848        hyperlink,
849    })
850}
851
852/// Parses `<p:cNvPr>`'s remaining content (a reader positioned right after its own opening tag and
853/// `id`/`name`/`descr` attributes were already consumed by the caller), looking for an optional
854/// `<a:hlinkClick>` and resolving it to a [`SlideHyperlinkTarget`] against `context` (`None` if no
855/// relationship context, or no matching relationship, is available). Consumes up to and including
856/// `<p:cNvPr>`'s own closing tag.
857fn parse_cnv_pr_hyperlink(
858    reader: &mut Reader,
859    context: Option<&SlideContext>,
860) -> Result<Option<SlideHyperlinkTarget>> {
861    let mut hyperlink = None;
862
863    loop {
864        match reader.read_event()? {
865            Event::Eof => break,
866            Event::End(end) if end.local_name().as_ref() == b"cNvPr" => break,
867
868            Event::Empty(start) if start.local_name().as_ref() == b"hlinkClick" => {
869                hyperlink = resolve_hyperlink_target(&start, context);
870            }
871            Event::Start(start) if start.local_name().as_ref() == b"hlinkClick" => {
872                hyperlink = resolve_hyperlink_target(&start, context);
873                // Can nest `<a:extLst>` — not modeled.
874                skip_subtree(reader)?;
875            }
876
877            Event::Start(_) => skip_subtree(reader)?,
878            Event::Empty(_) => {}
879            _ => {}
880        }
881    }
882
883    Ok(hyperlink)
884}
885
886/// Resolves an `<a:hlinkClick>` into a [`SlideHyperlinkTarget`], covering both an external URL and
887/// an in-package slide jump. Three cases, checked in order:
888///
889/// 1. No `r:id` at all, but `action="ppaction://hlinkshowjump?jump=.."` — one of the four
890///    relative-jump variants, resolved purely from the `action` string, no relationship lookup
891///    needed.
892/// 2. `r:id` present, `action="ppaction://hlinksldjump"`, and the relationship it resolves to has
893///    [`SLIDE_RELATIONSHIP_TYPE`] — an internal slide jump; its relative target
894///    (`"./slides/slideN.xml"`) is parsed back into a 0-based [`SlideHyperlinkTarget::Slide`]
895///    index.
896/// 3. `r:id` present, anything else (or no `context`/no matching relationship at all) —
897///    [`SlideHyperlinkTarget::External`], same as every hyperlink previously; falls back to `None`
898///    when unresolvable, same forgiving posture as the rest of this reader (a shape with an
899///    unresolvable hyperlink still reads back, just without one).
900fn resolve_hyperlink_target(
901    start: &BytesStart,
902    context: Option<&SlideContext>,
903) -> Option<SlideHyperlinkTarget> {
904    let action = attribute_by_qname(start, b"action");
905    let relationship_id = attribute_by_qname(start, b"r:id");
906
907    if relationship_id.is_none() {
908        return match action.as_deref() {
909            Some("ppaction://hlinkshowjump?jump=nextslide") => {
910                Some(SlideHyperlinkTarget::NextSlide)
911            }
912            Some("ppaction://hlinkshowjump?jump=previousslide") => {
913                Some(SlideHyperlinkTarget::PreviousSlide)
914            }
915            Some("ppaction://hlinkshowjump?jump=firstslide") => {
916                Some(SlideHyperlinkTarget::FirstSlide)
917            }
918            Some("ppaction://hlinkshowjump?jump=lastslide") => {
919                Some(SlideHyperlinkTarget::LastSlide)
920            }
921            _ => None,
922        };
923    }
924
925    let relationship = context?.relationships.by_id(&relationship_id?)?;
926
927    if action.as_deref() == Some("ppaction://hlinksldjump")
928        && relationship.rel_type == SLIDE_RELATIONSHIP_TYPE
929    {
930        if let Some(index) = parse_slide_relationship_target(&relationship.target) {
931            return Some(SlideHyperlinkTarget::Slide(index));
932        }
933    }
934
935    Some(SlideHyperlinkTarget::External(relationship.target.clone()))
936}
937
938/// Parses a slide relationship's own relative target (written by this crate's own writer as
939/// `"./slides/slide{N}.xml"`, `N` 1-based) back into a 0-based slide index — `None` if it doesn't
940/// match that exact shape (e.g. a hand-authored or differently-structured package).
941fn parse_slide_relationship_target(target: &str) -> Option<usize> {
942    let number = target
943        .strip_prefix("../slides/slide")?
944        .strip_suffix(".xml")?;
945    let slide_number: usize = number.parse().ok()?;
946    slide_number.checked_sub(1)
947}
948
949/// Parses `<p:nvPr>`'s content (a reader positioned right after its own opening tag was consumed)
950/// looking for an optional `<p:ph type=".." idx="..">` (`CT_Placeholder`) child, returning `None`
951/// when absent — e.g. `<p:nvPr/>` was already handled as `Event::Empty` by the caller and never
952/// reaches this function at all.
953fn parse_placeholder_wrapper(reader: &mut Reader) -> Result<Option<Placeholder>> {
954    let mut placeholder = None;
955
956    loop {
957        match reader.read_event()? {
958            Event::Eof => break,
959            Event::End(end) if end.local_name().as_ref() == b"nvPr" => break,
960
961            Event::Empty(start) if start.local_name().as_ref() == b"ph" => {
962                placeholder = Some(parse_placeholder_attributes(&start));
963            }
964            Event::Start(start) if start.local_name().as_ref() == b"ph" => {
965                placeholder = Some(parse_placeholder_attributes(&start));
966                // `<p:ph>` can carry an `<p:extLst>` child — not modeled.
967                skip_subtree(reader)?;
968            }
969
970            Event::Start(_) => skip_subtree(reader)?,
971            Event::Empty(_) => {}
972            _ => {}
973        }
974    }
975
976    Ok(placeholder)
977}
978
979/// Reads a `<p:ph>` element's `type`/`idx` attributes into a [`Placeholder`]. `type` defaults to
980/// `"obj"` when absent, matching `CT_Placeholder`'s own schema default.
981fn parse_placeholder_attributes(start: &BytesStart) -> Placeholder {
982    let kind = attribute_by_qname(start, b"type")
983        .map(|token| PlaceholderKind::from_xml_token(&token))
984        .unwrap_or(PlaceholderKind::Object);
985    let mut placeholder = Placeholder::new(kind);
986    if let Some(index) = attribute_by_qname(start, b"idx").and_then(|value| value.parse().ok()) {
987        placeholder = placeholder.with_index(index);
988    }
989    placeholder
990}
991
992/// Parses `<p:pic>`'s content (`CT_Picture`) from a reader positioned right after the caller
993/// already consumed its own opening tag, returning either [`Shape::Picture`] or — when its own
994/// `<p:nvPr>` carries an `<a:videoFile>`/`<a:audioFile>` reference — [`Shape::Media`]. Resolves the
995/// relevant relationship(s) against `context` to fetch the actual media bytes (and, for a picture,
996/// guesses its format from the resolved part name's extension).
997fn parse_pic_shape(reader: &mut Reader, context: &SlideContext) -> Result<Shape> {
998    let mut id: u32 = 0;
999    let mut name = String::new();
1000    let mut description = String::new();
1001    let mut embed_relationship_id: Option<String> = None;
1002    let mut link_relationship_id: Option<String> = None;
1003    let mut media_relationship_id: Option<String> = None;
1004    let mut properties = ShapeProperties::new();
1005
1006    loop {
1007        match reader.read_event()? {
1008            Event::Eof => break,
1009            Event::End(end) if end.local_name().as_ref() == b"pic" => break,
1010
1011            Event::Start(start) if start.local_name().as_ref() == b"nvPicPr" => {}
1012            Event::End(end) if end.local_name().as_ref() == b"nvPicPr" => {}
1013
1014            Event::Empty(start) if start.local_name().as_ref() == b"cNvPr" => {
1015                id = attribute_by_qname(&start, b"id")
1016                    .and_then(|value| value.parse().ok())
1017                    .unwrap_or(0);
1018                name = attribute_by_qname(&start, b"name").unwrap_or_default();
1019                description = attribute_by_qname(&start, b"descr").unwrap_or_default();
1020            }
1021            Event::Start(start) if start.local_name().as_ref() == b"cNvPr" => {
1022                id = attribute_by_qname(&start, b"id")
1023                    .and_then(|value| value.parse().ok())
1024                    .unwrap_or(0);
1025                name = attribute_by_qname(&start, b"name").unwrap_or_default();
1026                description = attribute_by_qname(&start, b"descr").unwrap_or_default();
1027                // Can nest `<a:hlinkClick>`/`<a:extLst>` — not modeled.
1028                skip_subtree(reader)?;
1029            }
1030
1031            Event::Empty(start) if start.local_name().as_ref() == b"cNvPicPr" => {}
1032            Event::Start(start) if start.local_name().as_ref() == b"cNvPicPr" => {
1033                // `<a:picLocks../>` — not modeled.
1034                skip_subtree(reader)?;
1035            }
1036
1037            Event::Empty(start) if start.local_name().as_ref() == b"nvPr" => {}
1038            Event::Start(start) if start.local_name().as_ref() == b"nvPr" => {
1039                // A picture can itself be a placeholder (e.g. a notes slide's `sldImg`) — not
1040                // modeled on `Picture`/`SlideMedia`. A video/audio clip, however, carries its own
1041                // media relationship right here (`<a:videoFile>`/`<a:audioFile>`).
1042                media_relationship_id = parse_media_reference(reader)?;
1043            }
1044
1045            Event::Start(start) if start.local_name().as_ref() == b"blipFill" => {
1046                let (embed, link) = parse_blip_fill_embed(reader)?;
1047                embed_relationship_id = embed;
1048                link_relationship_id = link;
1049            }
1050
1051            Event::Empty(start) if start.local_name().as_ref() == b"spPr" => {}
1052            Event::Start(start) if start.local_name().as_ref() == b"spPr" => {
1053                properties = drawing::read_shape_properties(reader)?;
1054            }
1055
1056            Event::Start(_) => skip_subtree(reader)?,
1057            Event::Empty(_) => {}
1058            _ => {}
1059        }
1060    }
1061
1062    // Real position/size is `<a:xfrm>`'s own off/ext — never optional for a PresentationML picture
1063    // or media clip (see `Picture`'s doc comment) — pulled out of the parsed `transform` either
1064    // way.
1065    let offset_emu = properties
1066        .transform
1067        .as_ref()
1068        .and_then(|transform| transform.offset)
1069        .unwrap_or((0, 0));
1070    let extent_emu = properties
1071        .transform
1072        .as_ref()
1073        .and_then(|transform| transform.extent)
1074        .unwrap_or((0, 0));
1075
1076    if let Some(media_relationship_id) = media_relationship_id {
1077        let relationship = context
1078            .relationships
1079            .by_id(&media_relationship_id)
1080            .ok_or_else(|| Error::MissingMediaRelationship(media_relationship_id.clone()))?;
1081        let media_part_name =
1082            resolve_relative_target(context.slide_part_name, &relationship.target);
1083        let media_part = context
1084            .package
1085            .part(&media_part_name)
1086            .ok_or_else(|| Error::MissingMediaPart(media_part_name.clone()))?;
1087        let format = MediaFormat::from_extension(&media_part_name)
1088            .ok_or_else(|| Error::UnsupportedMediaFormat(media_part_name.clone()))?;
1089
1090        let poster_image = embed_relationship_id.and_then(|poster_relationship_id| {
1091            let relationship = context.relationships.by_id(&poster_relationship_id)?;
1092            let poster_part_name =
1093                resolve_relative_target(context.slide_part_name, &relationship.target);
1094            let poster_part = context.package.part(&poster_part_name)?;
1095            let poster_format = PictureFormat::from_extension(&poster_part_name)?;
1096            Some((poster_part.data.clone(), poster_format))
1097        });
1098
1099        return Ok(Shape::Media(SlideMedia {
1100            id,
1101            name,
1102            data: media_part.data.clone(),
1103            format,
1104            offset_emu,
1105            extent_emu,
1106            poster_image,
1107        }));
1108    }
1109
1110    properties.transform = None;
1111
1112    let relationship_id =
1113        embed_relationship_id.ok_or_else(|| Error::MissingImageRelationship(String::new()))?;
1114    let relationship = context
1115        .relationships
1116        .by_id(&relationship_id)
1117        .ok_or_else(|| Error::MissingImageRelationship(relationship_id.clone()))?;
1118    let image_part_name = resolve_relative_target(context.slide_part_name, &relationship.target);
1119    let image_part = context
1120        .package
1121        .part(&image_part_name)
1122        .ok_or_else(|| Error::MissingImagePart(image_part_name.clone()))?;
1123    let format = PictureFormat::from_extension(&image_part_name)
1124        .ok_or_else(|| Error::UnsupportedImageFormat(image_part_name.clone()))?;
1125
1126    // The rest (geometry/fill/line) is kept as `shape_properties` only when it differs from the
1127    // fixed default rectangle this crate's writer always produces for a plain picture.
1128    let is_default_shape = matches!(
1129        properties.geometry,
1130        None | Some(Geometry::Preset(PresetShape::Rectangle))
1131    ) && properties.fill.is_none()
1132        && properties.line.is_none();
1133    let shape_properties = if is_default_shape {
1134        None
1135    } else {
1136        Some(properties)
1137    };
1138
1139    // `r:link`'s own relationship, when present, targets the external source directly
1140    // (`TargetMode::External` — its `target` *is* the URL/path, no part to resolve), unlike
1141    // `r:embed`'s internal, in-package relationship above.
1142    let external_link = link_relationship_id
1143        .and_then(|id| context.relationships.by_id(&id))
1144        .map(|relationship| relationship.target.clone());
1145
1146    Ok(Shape::Picture(Picture {
1147        id,
1148        name,
1149        data: image_part.data.clone(),
1150        format,
1151        offset_emu,
1152        extent_emu,
1153        description,
1154        shape_properties,
1155        external_link,
1156    }))
1157}
1158
1159/// Parses `<p:nvPr>`'s content (a reader positioned right after its own opening tag was consumed)
1160/// looking for an `<a:videoFile r:link=".">`/ `<a:audioFile r:link=".">`, returning its
1161/// relationship id — `None` for an ordinary picture (or a placeholder picture, e.g. a notes slide's
1162/// `sldImg`, neither of which carry either element). Consumes up to and including `<p:nvPr>`'s own
1163/// closing tag.
1164fn parse_media_reference(reader: &mut Reader) -> Result<Option<String>> {
1165    let mut relationship_id = None;
1166
1167    loop {
1168        match reader.read_event()? {
1169            Event::Eof => break,
1170            Event::End(end) if end.local_name().as_ref() == b"nvPr" => break,
1171
1172            Event::Empty(start) if start.local_name().as_ref() == b"videoFile" => {
1173                relationship_id = attribute_by_qname(&start, b"r:link");
1174            }
1175            Event::Empty(start) if start.local_name().as_ref() == b"audioFile" => {
1176                relationship_id = attribute_by_qname(&start, b"r:link");
1177            }
1178
1179            Event::Start(_) => skip_subtree(reader)?,
1180            Event::Empty(_) => {}
1181            _ => {}
1182        }
1183    }
1184
1185    Ok(relationship_id)
1186}
1187
1188/// Parses `<p:blipFill>`'s content (a reader positioned right after its own opening tag was
1189/// consumed) looking for `<a:blip>`'s `r:embed`/`r:link` relationship ids — `(embed, link)`.
1190/// `<a:srcRect>`/`<a:stretch>` are skipped, not modeled.
1191fn parse_blip_fill_embed(reader: &mut Reader) -> Result<(Option<String>, Option<String>)> {
1192    let mut embed_relationship_id = None;
1193    let mut link_relationship_id = None;
1194
1195    loop {
1196        match reader.read_event()? {
1197            Event::Eof => break,
1198            Event::End(end) if end.local_name().as_ref() == b"blipFill" => break,
1199
1200            Event::Empty(start) if start.local_name().as_ref() == b"blip" => {
1201                embed_relationship_id = attribute_by_qname(&start, b"r:embed");
1202                link_relationship_id = attribute_by_qname(&start, b"r:link");
1203            }
1204            Event::Start(start) if start.local_name().as_ref() == b"blip" => {
1205                embed_relationship_id = attribute_by_qname(&start, b"r:embed");
1206                link_relationship_id = attribute_by_qname(&start, b"r:link");
1207                // Can nest `<a:extLst>` (e.g. `a14:useLocalDpi`) — not modeled.
1208                skip_subtree(reader)?;
1209            }
1210
1211            Event::Start(_) => skip_subtree(reader)?,
1212            Event::Empty(_) => {}
1213            _ => {}
1214        }
1215    }
1216
1217    Ok((embed_relationship_id, link_relationship_id))
1218}
1219
1220/// Parses `<p:graphicFrame>`'s content (`CT_GraphicalObjectFrame`), from a reader positioned right
1221/// after the caller already consumed its own opening tag. Wraps either a `<c:chart r:id="..">` or
1222/// an `<a:tbl>` — disambiguated by `<a:graphicData uri="..">`'s own `uri` attribute
1223/// (`CHART_NAMESPACE`/`TABLE_NAMESPACE`), the same discriminator real PowerPoint itself uses. Note
1224/// the frame's own transform element is `<p:xfrm>`, not `<a:xfrm>` — confirmed against real
1225/// fixtures covering both charts and tables — so it's parsed directly rather than via
1226/// `drawing::read_shape_properties` (which only knows about the `a:`-prefixed fragment).
1227fn parse_graphic_frame_shape(reader: &mut Reader, context: &SlideContext) -> Result<Shape> {
1228    let mut id: u32 = 0;
1229    let mut name = String::new();
1230    let mut offset_emu: (i64, i64) = (0, 0);
1231    let mut extent_emu: (i64, i64) = (0, 0);
1232
1233    loop {
1234        match reader.read_event()? {
1235            Event::Eof => break,
1236            Event::End(end) if end.local_name().as_ref() == b"graphicFrame" => break,
1237
1238            Event::Start(start) if start.local_name().as_ref() == b"nvGraphicFramePr" => {}
1239            Event::End(end) if end.local_name().as_ref() == b"nvGraphicFramePr" => {}
1240
1241            Event::Empty(start) if start.local_name().as_ref() == b"cNvPr" => {
1242                id = attribute_by_qname(&start, b"id")
1243                    .and_then(|value| value.parse().ok())
1244                    .unwrap_or(0);
1245                name = attribute_by_qname(&start, b"name").unwrap_or_default();
1246            }
1247            Event::Start(start) if start.local_name().as_ref() == b"cNvPr" => {
1248                id = attribute_by_qname(&start, b"id")
1249                    .and_then(|value| value.parse().ok())
1250                    .unwrap_or(0);
1251                name = attribute_by_qname(&start, b"name").unwrap_or_default();
1252                skip_subtree(reader)?;
1253            }
1254
1255            Event::Empty(start) if start.local_name().as_ref() == b"cNvGraphicFramePr" => {}
1256            Event::Start(start) if start.local_name().as_ref() == b"cNvGraphicFramePr" => {
1257                // `<a:graphicFrameLocks../>` — not modeled.
1258                skip_subtree(reader)?;
1259            }
1260
1261            Event::Empty(start) if start.local_name().as_ref() == b"nvPr" => {}
1262            Event::Start(start) if start.local_name().as_ref() == b"nvPr" => {
1263                skip_subtree(reader)?;
1264            }
1265
1266            Event::Start(start) if start.local_name().as_ref() == b"xfrm" => {
1267                let (parsed_offset, parsed_extent) = parse_frame_transform(reader)?;
1268                offset_emu = parsed_offset;
1269                extent_emu = parsed_extent;
1270            }
1271
1272            // `<a:graphic>` is a transparent wrapper — fall through to its `<a:graphicData>` child.
1273            Event::Start(start) if start.local_name().as_ref() == b"graphic" => {}
1274            Event::End(end) if end.local_name().as_ref() == b"graphic" => {}
1275
1276            // `<a:graphicData uri="..">`'s `uri` decides which payload follows — the rest of the
1277            // frame (everything up through its own `</p:graphicFrame>`) is delegated to a dedicated
1278            // parser per kind, each responsible for consuming up to that same end tag (mirrors
1279            // every other shape-kind parser's "consume up to my own closing tag" contract).
1280            Event::Start(start) if start.local_name().as_ref() == b"graphicData" => {
1281                let uri = attribute_by_qname(&start, b"uri");
1282                return match uri.as_deref() {
1283                    Some(TABLE_NAMESPACE) => Ok(Shape::Table(parse_table(
1284                        reader, id, name, offset_emu, extent_emu,
1285                    )?)),
1286                    _ => Ok(Shape::Chart(Box::new(parse_chart(
1287                        reader, context, id, name, offset_emu, extent_emu,
1288                    )?))),
1289                };
1290            }
1291
1292            Event::Start(_) => skip_subtree(reader)?,
1293            Event::Empty(_) => {}
1294            _ => {}
1295        }
1296    }
1297
1298    // No `<a:graphicData>` was found at all (a malformed or genuinely empty frame) — defensive
1299    // fallback, same forgiving posture this reader takes elsewhere for optional/unexpected content:
1300    // an empty table rather than a hard error, since nothing about a bare `<p:graphicFrame>` is
1301    // inherently invalid to have read this far.
1302    Ok(Shape::Table(SlideTable {
1303        id,
1304        name,
1305        offset_emu,
1306        extent_emu,
1307        column_widths_emu: Vec::new(),
1308        rows: Vec::new(),
1309        style_id: None,
1310        style_first_row: false,
1311        style_first_column: false,
1312        style_last_row: false,
1313        style_last_column: false,
1314        style_band_rows: false,
1315        style_band_columns: false,
1316    }))
1317}
1318
1319/// Parses a `<c:chart r:id="..">` payload (the reader positioned right after `<a:graphicData>`'s
1320/// own opening tag was consumed, `uri` already resolved to the chart namespace by the caller),
1321/// through to `</p:graphicFrame>`.
1322fn parse_chart(
1323    reader: &mut Reader,
1324    context: &SlideContext,
1325    id: u32,
1326    name: String,
1327    offset_emu: (i64, i64),
1328    extent_emu: (i64, i64),
1329) -> Result<SlideChart> {
1330    let mut chart_relationship_id: Option<String> = None;
1331
1332    loop {
1333        match reader.read_event()? {
1334            Event::Eof => break,
1335            Event::End(end) if end.local_name().as_ref() == b"graphicFrame" => break,
1336            Event::End(end) if end.local_name().as_ref() == b"graphicData" => {}
1337
1338            Event::Empty(start) if start.local_name().as_ref() == b"chart" => {
1339                chart_relationship_id = attribute_by_qname(&start, b"r:id");
1340            }
1341            Event::Start(start) if start.local_name().as_ref() == b"chart" => {
1342                chart_relationship_id = attribute_by_qname(&start, b"r:id");
1343                skip_subtree(reader)?;
1344            }
1345
1346            Event::Start(_) => skip_subtree(reader)?,
1347            Event::Empty(_) => {}
1348            _ => {}
1349        }
1350    }
1351
1352    let relationship_id =
1353        chart_relationship_id.ok_or_else(|| Error::MissingChartRelationship(String::new()))?;
1354    let relationship = context
1355        .relationships
1356        .by_id(&relationship_id)
1357        .ok_or_else(|| Error::MissingChartRelationship(relationship_id.clone()))?;
1358    let chart_part_name = resolve_relative_target(context.slide_part_name, &relationship.target);
1359    let chart_part = context
1360        .package
1361        .part(&chart_part_name)
1362        .ok_or_else(|| Error::MissingChartPart(chart_part_name.clone()))?;
1363    let chart_xml = std::str::from_utf8(&chart_part.data)
1364        .map_err(|error| Error::InvalidUtf8(chart_part_name.clone(), error))?;
1365    let chart_space = chart::read_chart_space(chart_xml)?;
1366
1367    Ok(SlideChart {
1368        id,
1369        name,
1370        chart_space,
1371        offset_emu,
1372        extent_emu,
1373    })
1374}
1375
1376/// Parses an `<a:tbl>` payload (`CT_Table`, the reader positioned right after `<a:graphicData>`'s
1377/// own opening tag was consumed, `uri` already resolved to the table namespace by the caller),
1378/// through to `</p:graphicFrame>`. Confirmed against real fixtures.
1379fn parse_table(
1380    reader: &mut Reader,
1381    id: u32,
1382    name: String,
1383    offset_emu: (i64, i64),
1384    extent_emu: (i64, i64),
1385) -> Result<SlideTable> {
1386    let mut column_widths_emu = Vec::new();
1387    let mut rows = Vec::new();
1388    let mut style_id = None;
1389    let mut style_first_row = false;
1390    let mut style_first_column = false;
1391    let mut style_last_row = false;
1392    let mut style_last_column = false;
1393    let mut style_band_rows = false;
1394    let mut style_band_columns = false;
1395
1396    loop {
1397        match reader.read_event()? {
1398            Event::Eof => break,
1399            Event::End(end) if end.local_name().as_ref() == b"graphicFrame" => break,
1400            Event::End(end) if end.local_name().as_ref() == b"graphicData" => {}
1401
1402            Event::Start(start) if start.local_name().as_ref() == b"tbl" => {}
1403            Event::End(end) if end.local_name().as_ref() == b"tbl" => {}
1404
1405            // `<a:tblPr>` — wraps `<a:tableStyleId>` and carries the six style-application toggles
1406            // `firstRow`/`firstCol`/`lastRow`/`lastCol`/`bandRow`/`bandCol` (see
1407            // `SlideTable::style_first_row`'s doc comment — without these,
1408            // `style_id`/`SlideTableStyle` has no visible effect in real PowerPoint). The fixed
1409            // built-in GUID this crate's writer always falls back to resolves to `None`, see
1410            // `TABLE_STYLE_GUID`'s own doc comment here.
1411            Event::Empty(start) if start.local_name().as_ref() == b"tblPr" => {
1412                style_first_row = attribute_by_qname(&start, b"firstRow").as_deref() == Some("1");
1413                style_first_column =
1414                    attribute_by_qname(&start, b"firstCol").as_deref() == Some("1");
1415                style_last_row = attribute_by_qname(&start, b"lastRow").as_deref() == Some("1");
1416                style_last_column = attribute_by_qname(&start, b"lastCol").as_deref() == Some("1");
1417                style_band_rows = attribute_by_qname(&start, b"bandRow").as_deref() == Some("1");
1418                style_band_columns = attribute_by_qname(&start, b"bandCol").as_deref() == Some("1");
1419            }
1420            Event::Start(start) if start.local_name().as_ref() == b"tblPr" => {
1421                style_first_row = attribute_by_qname(&start, b"firstRow").as_deref() == Some("1");
1422                style_first_column =
1423                    attribute_by_qname(&start, b"firstCol").as_deref() == Some("1");
1424                style_last_row = attribute_by_qname(&start, b"lastRow").as_deref() == Some("1");
1425                style_last_column = attribute_by_qname(&start, b"lastCol").as_deref() == Some("1");
1426                style_band_rows = attribute_by_qname(&start, b"bandRow").as_deref() == Some("1");
1427                style_band_columns = attribute_by_qname(&start, b"bandCol").as_deref() == Some("1");
1428                style_id = parse_table_style_id(reader)?;
1429            }
1430
1431            Event::Start(start) if start.local_name().as_ref() == b"tblGrid" => {}
1432            Event::End(end) if end.local_name().as_ref() == b"tblGrid" => {}
1433            Event::Empty(start) if start.local_name().as_ref() == b"gridCol" => {
1434                if let Some(width) =
1435                    attribute_by_qname(&start, b"w").and_then(|value| value.parse().ok())
1436                {
1437                    column_widths_emu.push(width);
1438                }
1439            }
1440            Event::Start(start) if start.local_name().as_ref() == b"gridCol" => {
1441                if let Some(width) =
1442                    attribute_by_qname(&start, b"w").and_then(|value| value.parse().ok())
1443                {
1444                    column_widths_emu.push(width);
1445                }
1446                // Can carry an `<a:extLst>` (Office-internal column id) — not modeled.
1447                skip_subtree(reader)?;
1448            }
1449
1450            Event::Start(start) if start.local_name().as_ref() == b"tr" => {
1451                rows.push(parse_table_row(reader, &start)?);
1452            }
1453
1454            Event::Start(_) => skip_subtree(reader)?,
1455            Event::Empty(_) => {}
1456            _ => {}
1457        }
1458    }
1459
1460    Ok(SlideTable {
1461        id,
1462        name,
1463        offset_emu,
1464        extent_emu,
1465        column_widths_emu,
1466        rows,
1467        style_id,
1468        style_first_row,
1469        style_first_column,
1470        style_last_row,
1471        style_last_column,
1472        style_band_rows,
1473        style_band_columns,
1474    })
1475}
1476
1477/// Parses `<a:tblPr>`'s content looking for `<a:tableStyleId>`'s text, assuming `<a:tblPr>`'s own
1478/// `Start` was just consumed by the caller — consumes up to and including `<a:tblPr>`'s own closing
1479/// tag. Returns `None` both when no `<a:tableStyleId>` is present and when its text matches the
1480/// fixed built-in GUID this crate's writer falls back to (see `TABLE_STYLE_GUID`'s own doc
1481/// comment).
1482fn parse_table_style_id(reader: &mut Reader) -> Result<Option<String>> {
1483    let mut style_id = None;
1484    let mut in_style_id = false;
1485
1486    loop {
1487        match reader.read_event()? {
1488            Event::Start(start) if start.local_name().as_ref() == b"tableStyleId" => {
1489                in_style_id = true
1490            }
1491            Event::End(end) if end.local_name().as_ref() == b"tableStyleId" => in_style_id = false,
1492            Event::Text(text) if in_style_id => {
1493                let decoded = xml_core::decode_text(&text)?;
1494                if decoded != TABLE_STYLE_GUID {
1495                    style_id = Some(decoded);
1496                }
1497            }
1498
1499            Event::Start(_) => skip_subtree(reader)?,
1500            Event::End(end) if end.local_name().as_ref() == b"tblPr" => break,
1501            Event::Eof => break,
1502            _ => {}
1503        }
1504    }
1505
1506    Ok(style_id)
1507}
1508
1509/// Parses `<a:tr h="..">`'s content (`CT_TableRow`) from a reader positioned right after its own
1510/// opening tag was consumed (`start` is that same opening tag, for its `h` attribute).
1511fn parse_table_row(reader: &mut Reader, start: &BytesStart) -> Result<TableRow> {
1512    let height_emu = attribute_by_qname(start, b"h")
1513        .and_then(|value| value.parse().ok())
1514        .unwrap_or(0);
1515    let mut cells = Vec::new();
1516
1517    loop {
1518        match reader.read_event()? {
1519            Event::Eof => break,
1520            Event::End(end) if end.local_name().as_ref() == b"tr" => break,
1521
1522            Event::Start(start) if start.local_name().as_ref() == b"tc" => {
1523                cells.push(parse_table_cell(reader, &start)?);
1524            }
1525            Event::Empty(start) if start.local_name().as_ref() == b"tc" => {
1526                cells.push(TableCell {
1527                    horizontal_span: attribute_by_qname(&start, b"gridSpan")
1528                        .and_then(|value| value.parse().ok()),
1529                    vertical_span: attribute_by_qname(&start, b"rowSpan")
1530                        .and_then(|value| value.parse().ok()),
1531                    horizontal_merge: attribute_by_qname(&start, b"hMerge").as_deref() == Some("1"),
1532                    vertical_merge: attribute_by_qname(&start, b"vMerge").as_deref() == Some("1"),
1533                    text_body: None,
1534                    properties: None,
1535                });
1536            }
1537
1538            Event::Start(_) => skip_subtree(reader)?,
1539            Event::Empty(_) => {}
1540            _ => {}
1541        }
1542    }
1543
1544    Ok(TableRow { height_emu, cells })
1545}
1546
1547/// Parses `<a:tc>`'s content (`CT_TableCell`) from a reader positioned right after its own opening
1548/// tag was consumed (`start` is that same opening tag, for its
1549/// `gridSpan`/`rowSpan`/`hMerge`/`vMerge` attributes — see [`TableCell`]'s own doc comment for why
1550/// these live on `<a:tc>` itself rather than a nested properties element).
1551fn parse_table_cell(reader: &mut Reader, start: &BytesStart) -> Result<TableCell> {
1552    let horizontal_span =
1553        attribute_by_qname(start, b"gridSpan").and_then(|value| value.parse().ok());
1554    let vertical_span = attribute_by_qname(start, b"rowSpan").and_then(|value| value.parse().ok());
1555    let horizontal_merge = attribute_by_qname(start, b"hMerge").as_deref() == Some("1");
1556    let vertical_merge = attribute_by_qname(start, b"vMerge").as_deref() == Some("1");
1557    let mut text_body = None;
1558    let mut properties = None;
1559
1560    loop {
1561        match reader.read_event()? {
1562            Event::Eof => break,
1563            Event::End(end) if end.local_name().as_ref() == b"tc" => break,
1564
1565            Event::Start(start) if start.local_name().as_ref() == b"txBody" => {
1566                text_body = Some(drawing::read_text_body(reader)?);
1567            }
1568
1569            // Cell margins/anchor/borders/fill (see [`TableCellProperties`]'s own doc comment).
1570            Event::Start(start) if start.local_name().as_ref() == b"tcPr" => {
1571                properties = Some(parse_table_cell_properties(reader, &start)?);
1572            }
1573            Event::Empty(start) if start.local_name().as_ref() == b"tcPr" => {
1574                properties = Some(parse_table_cell_properties_attributes(&start));
1575            }
1576
1577            Event::Start(_) => skip_subtree(reader)?,
1578            Event::Empty(_) => {}
1579            _ => {}
1580        }
1581    }
1582
1583    Ok(TableCell {
1584        text_body,
1585        horizontal_span,
1586        vertical_span,
1587        horizontal_merge,
1588        vertical_merge,
1589        properties,
1590    })
1591}
1592
1593/// Parses `<a:tcPr>`'s own attributes (`marL`/`marT`/`marR`/`marB`/ `anchor`) into a
1594/// [`TableCellProperties`] with every child field left unset — the `<a:tcPr/>` self-closing case,
1595/// which still carries attributes worth keeping even with no border/fill children.
1596fn parse_table_cell_properties_attributes(start: &BytesStart) -> TableCellProperties {
1597    TableCellProperties {
1598        margin_left_emu: attribute_by_qname(start, b"marL").and_then(|value| value.parse().ok()),
1599        margin_top_emu: attribute_by_qname(start, b"marT").and_then(|value| value.parse().ok()),
1600        margin_right_emu: attribute_by_qname(start, b"marR").and_then(|value| value.parse().ok()),
1601        margin_bottom_emu: attribute_by_qname(start, b"marB").and_then(|value| value.parse().ok()),
1602        anchor: attribute_by_qname(start, b"anchor")
1603            .and_then(|value| parse_table_cell_anchor(&value)),
1604        ..TableCellProperties::default()
1605    }
1606}
1607
1608/// Parses `<a:tcPr>`'s element children (`<a:lnL>`/`<a:lnR>`/`<a:lnT>`/ `<a:lnB>` then the trailing
1609/// `EG_FillProperties` choice, point 8), assuming `<a:tcPr>`'s own `Start` was just consumed by the
1610/// caller — consumes up to and including `<a:tcPr>`'s own closing tag.
1611/// `<a:lnTlToBr>`/`<a:lnBlToTr>`/`<a:cell3D>` aren't modeled, see [`TableCellProperties`]'s own doc
1612/// comment.
1613fn parse_table_cell_properties(
1614    reader: &mut Reader,
1615    start: &BytesStart,
1616) -> Result<TableCellProperties> {
1617    let mut properties = parse_table_cell_properties_attributes(start);
1618
1619    loop {
1620        match reader.read_event()? {
1621            Event::Eof => break,
1622            Event::End(end) if end.local_name().as_ref() == b"tcPr" => break,
1623
1624            Event::Empty(start) if start.local_name().as_ref() == b"lnL" => {
1625                properties.border_left = Some(drawing::read_line(reader, &start, false)?);
1626            }
1627            Event::Start(start) if start.local_name().as_ref() == b"lnL" => {
1628                properties.border_left = Some(drawing::read_line(reader, &start, true)?);
1629            }
1630            Event::Empty(start) if start.local_name().as_ref() == b"lnR" => {
1631                properties.border_right = Some(drawing::read_line(reader, &start, false)?);
1632            }
1633            Event::Start(start) if start.local_name().as_ref() == b"lnR" => {
1634                properties.border_right = Some(drawing::read_line(reader, &start, true)?);
1635            }
1636            Event::Empty(start) if start.local_name().as_ref() == b"lnT" => {
1637                properties.border_top = Some(drawing::read_line(reader, &start, false)?);
1638            }
1639            Event::Start(start) if start.local_name().as_ref() == b"lnT" => {
1640                properties.border_top = Some(drawing::read_line(reader, &start, true)?);
1641            }
1642            Event::Empty(start) if start.local_name().as_ref() == b"lnB" => {
1643                properties.border_bottom = Some(drawing::read_line(reader, &start, false)?);
1644            }
1645            Event::Start(start) if start.local_name().as_ref() == b"lnB" => {
1646                properties.border_bottom = Some(drawing::read_line(reader, &start, true)?);
1647            }
1648
1649            // The trailing `EG_FillProperties` choice — a bare sibling of `lnL`/`lnR`/`lnT`/`lnB`
1650            // above (no `<a:fill>` wrapper, unlike `CT_TableStyleCellStyle`'s own
1651            // `<a:tcStyle><a:fill>`), so `drawing::read_fill_choice` is used instead of
1652            // `drawing::read_fill` — see that function's own doc comment for why (it needs the
1653            // already-matched `start`/`is_start` rather than scanning forward itself, which would
1654            // silently skip past `lnL`/etc. if any came after it).
1655            Event::Start(start) if drawing::is_fill_choice_element(&start) => {
1656                properties.fill = Some(drawing::read_fill_choice(reader, &start, true)?);
1657            }
1658            Event::Empty(start) if drawing::is_fill_choice_element(&start) => {
1659                properties.fill = Some(drawing::read_fill_choice(reader, &start, false)?);
1660            }
1661
1662            Event::Start(_) => skip_subtree(reader)?,
1663            Event::Empty(_) => {}
1664            _ => {}
1665        }
1666    }
1667
1668    Ok(properties)
1669}
1670
1671/// `ST_TextAnchoringType`'s own token parsing, duplicated locally — see `writer.rs`'s
1672/// `table_cell_anchor_token` for why (same `pub(crate)`-to- `drawing`-only visibility on the type's
1673/// own `from_xml_token`).
1674fn parse_table_cell_anchor(token: &str) -> Option<TextAnchor> {
1675    Some(match token {
1676        "t" => TextAnchor::Top,
1677        "ctr" => TextAnchor::Center,
1678        "b" => TextAnchor::Bottom,
1679        "just" => TextAnchor::Justified,
1680        "dist" => TextAnchor::Distributed,
1681        _ => return None,
1682    })
1683}
1684
1685/// Parses `<p:xfrm>`'s `<a:off>`/`<a:ext>` children (a reader positioned right after `<p:xfrm>`'s
1686/// own opening tag was consumed), returning `((x, y), (cx, cy))` in EMUs.
1687fn parse_frame_transform(reader: &mut Reader) -> Result<((i64, i64), (i64, i64))> {
1688    let mut offset = (0i64, 0i64);
1689    let mut extent = (0i64, 0i64);
1690
1691    loop {
1692        match reader.read_event()? {
1693            Event::Eof => break,
1694            Event::End(end) if end.local_name().as_ref() == b"xfrm" => break,
1695
1696            Event::Empty(start) if start.local_name().as_ref() == b"off" => {
1697                let x = attribute_by_qname(&start, b"x")
1698                    .and_then(|value| value.parse().ok())
1699                    .unwrap_or(0);
1700                let y = attribute_by_qname(&start, b"y")
1701                    .and_then(|value| value.parse().ok())
1702                    .unwrap_or(0);
1703                offset = (x, y);
1704            }
1705            Event::Empty(start) if start.local_name().as_ref() == b"ext" => {
1706                let cx = attribute_by_qname(&start, b"cx")
1707                    .and_then(|value| value.parse().ok())
1708                    .unwrap_or(0);
1709                let cy = attribute_by_qname(&start, b"cy")
1710                    .and_then(|value| value.parse().ok())
1711                    .unwrap_or(0);
1712                extent = (cx, cy);
1713            }
1714
1715            Event::Start(_) => skip_subtree(reader)?,
1716            Event::Empty(_) => {}
1717            _ => {}
1718        }
1719    }
1720
1721    Ok((offset, extent))
1722}
1723
1724/// Parses a `ppt/notesSlides/notesSlideN.xml` part (root element `<p:notes>`, `CT_NotesSlide` —
1725/// note this differs from the part's own file name, confirmed against a real notes-bearing `.pptx`
1726/// fixture) back into the [`TextBody`] this crate's writer originally put in the `body` placeholder
1727/// (`idx="1"`). The `sldImg`/`sldNum` placeholder shapes real PowerPoint also writes are parsed
1728/// (via [`parse_auto_shape`], same as any other `<p:sp>`) but discarded — this crate never renders
1729/// slide thumbnails.
1730fn parse_notes_slide_xml(xml: &str) -> Result<TextBody> {
1731    let mut reader = Reader::from_xml_str(xml);
1732    let mut body_text: Option<TextBody> = None;
1733
1734    loop {
1735        match reader.read_event()? {
1736            Event::Eof => break,
1737            Event::Start(start) if start.local_name().as_ref() == b"sp" => {
1738                let shape = parse_auto_shape(&mut reader, None)?;
1739                let is_body_placeholder = matches!(
1740                    shape
1741                        .placeholder
1742                        .as_ref()
1743                        .map(|placeholder| &placeholder.kind),
1744                    Some(PlaceholderKind::Body)
1745                );
1746                if is_body_placeholder {
1747                    if let Some(text_body) = shape.text_body {
1748                        body_text = Some(text_body);
1749                    }
1750                }
1751            }
1752            _ => {}
1753        }
1754    }
1755
1756    Ok(body_text.unwrap_or_default())
1757}
1758
1759/// Resolves and parses the slide master's own theme (`ppt/theme/theme1.xml` via
1760/// `ppt/slideMasters/slideMaster1.xml`'s own `.rels`), populating [`Presentation::theme`]. Returns
1761/// `None` (rather than erroring) if the master/theme chain can't be resolved for any reason — this
1762/// crate never hard-fails on the master/layout/theme boilerplate it doesn't otherwise validate (see
1763/// this module's own top-level doc comment), and a missing theme is no different: the writer will
1764/// just fall back to [`Theme::office_default`] next time this presentation is written.
1765fn read_presentation_theme(
1766    package: &Package,
1767    presentation_relationships: &Relationships,
1768) -> Result<Option<Theme>> {
1769    let Some(master_relationship) = presentation_relationships
1770        .iter()
1771        .find(|relationship| relationship.rel_type == SLIDE_MASTER_RELATIONSHIP_TYPE)
1772    else {
1773        return Ok(None);
1774    };
1775    // `ppt/presentation.xml`'s relationships are relative to `ppt/`, same convention as the slide
1776    // relationships resolved in `read_from`.
1777    let master_part_name = format!("/ppt/{}", master_relationship.target);
1778    let Some(master_part) = package.part(&master_part_name) else {
1779        return Ok(None);
1780    };
1781    let Some(theme_relationship) = master_part
1782        .relationships
1783        .iter()
1784        .find(|relationship| relationship.rel_type == THEME_RELATIONSHIP_TYPE)
1785    else {
1786        return Ok(None);
1787    };
1788    let theme_part_name = resolve_relative_target(&master_part_name, &theme_relationship.target);
1789    let Some(theme_part) = package.part(&theme_part_name) else {
1790        return Ok(None);
1791    };
1792    let xml = std::str::from_utf8(&theme_part.data)
1793        .map_err(|error| Error::InvalidUtf8(theme_part_name.clone(), error))?;
1794    Ok(Some(parse_theme_xml(xml)?))
1795}
1796
1797/// Resolves one embedded-font variant's raw `r:id` (if any) to its actual `.fntdata` bytes via
1798/// `ppt/presentation.xml`'s own relationships. Returns `None` both when `raw_relationship_id`
1799/// itself is `None` and when the relationship/part it points at can't be resolved (tolerant, same
1800/// posture as the rest of this crate's reader).
1801fn resolve_embedded_font_variant(
1802    package: &Package,
1803    presentation_relationships: &Relationships,
1804    raw_relationship_id: &Option<String>,
1805) -> Option<Vec<u8>> {
1806    let relationship_id = raw_relationship_id.as_ref()?;
1807    let relationship = presentation_relationships.by_id(relationship_id)?;
1808    let part_name = format!("/ppt/{}", relationship.target);
1809    package.part(&part_name).map(|part| part.data.clone())
1810}
1811
1812/// Resolves and parses `docProps/core.xml`/`app.xml`/`custom.xml` off the package root
1813/// relationships (`_rels/.rels`) into a [`DocumentProperties`]. Tolerant of any part/relationship
1814/// being missing, in which case the matching fields simply stay at their defaults.
1815fn read_document_properties(package: &Package) -> Result<DocumentProperties> {
1816    let mut properties = DocumentProperties::new();
1817
1818    if let Some(relationship) = package
1819        .relationships()
1820        .iter()
1821        .find(|relationship| relationship.rel_type == CORE_PROPERTIES_RELATIONSHIP_TYPE)
1822    {
1823        let part_name = format!("/{}", relationship.target);
1824        if let Some(part) = package.part(&part_name) {
1825            let xml = std::str::from_utf8(&part.data)
1826                .map_err(|error| Error::InvalidUtf8(part_name.clone(), error))?;
1827            let core_properties = parse_core_properties_xml(xml)?;
1828            properties.title = core_properties.title;
1829            properties.author = core_properties.author;
1830            properties.subject = core_properties.subject;
1831            properties.keywords = core_properties.keywords;
1832        }
1833    }
1834
1835    if let Some(relationship) = package
1836        .relationships()
1837        .iter()
1838        .find(|relationship| relationship.rel_type == EXTENDED_PROPERTIES_RELATIONSHIP_TYPE)
1839    {
1840        let part_name = format!("/{}", relationship.target);
1841        if let Some(part) = package.part(&part_name) {
1842            let xml = std::str::from_utf8(&part.data)
1843                .map_err(|error| Error::InvalidUtf8(part_name.clone(), error))?;
1844            let (company, manager, hyperlink_base) = parse_extended_properties_xml(xml)?;
1845            properties.company = company;
1846            properties.manager = manager;
1847            properties.hyperlink_base = hyperlink_base;
1848        }
1849    }
1850
1851    if let Some(relationship) = package
1852        .relationships()
1853        .iter()
1854        .find(|relationship| relationship.rel_type == CUSTOM_PROPERTIES_RELATIONSHIP_TYPE)
1855    {
1856        let part_name = format!("/{}", relationship.target);
1857        if let Some(part) = package.part(&part_name) {
1858            let xml = std::str::from_utf8(&part.data)
1859                .map_err(|error| Error::InvalidUtf8(part_name.clone(), error))?;
1860            properties.custom_properties = parse_custom_properties_xml(xml)?;
1861        }
1862    }
1863
1864    Ok(properties)
1865}
1866
1867/// The four `docProps/core.xml` fields [`parse_core_properties_xml`] collects — grouped into a
1868/// struct instead of a same-typed 4-tuple (clippy's `type_complexity`; four `Option<String>`s in a
1869/// row is also exactly the kind of positional tuple that's easy to transpose).
1870struct ParsedCoreProperties {
1871    title: Option<String>,
1872    author: Option<String>,
1873    subject: Option<String>,
1874    keywords: Option<String>,
1875}
1876
1877/// Parses `docProps/core.xml` into title/author/subject/keywords — an exact mirror of
1878/// `excel-ooxml`'s own `parse_core_properties_xml` (buffered text accumulation, `GeneralRef` entity
1879/// resolution included).
1880fn parse_core_properties_xml(xml: &str) -> Result<ParsedCoreProperties> {
1881    let mut reader = Reader::from_xml_str(xml);
1882    let mut title = None;
1883    let mut author = None;
1884    let mut subject = None;
1885    let mut keywords = None;
1886    let mut in_element: Option<&'static str> = None;
1887    let mut buffer = String::new();
1888
1889    loop {
1890        match reader.read_event()? {
1891            Event::Eof => break,
1892            Event::Start(start) if start.local_name().as_ref() == b"title" => {
1893                in_element = Some("title");
1894                buffer.clear();
1895            }
1896            Event::Start(start) if start.local_name().as_ref() == b"creator" => {
1897                in_element = Some("creator");
1898                buffer.clear();
1899            }
1900            Event::Start(start) if start.local_name().as_ref() == b"subject" => {
1901                in_element = Some("subject");
1902                buffer.clear();
1903            }
1904            Event::Start(start) if start.local_name().as_ref() == b"keywords" => {
1905                in_element = Some("keywords");
1906                buffer.clear();
1907            }
1908            Event::Text(text) if in_element.is_some() => {
1909                buffer.push_str(&xml_core::decode_text(&text)?);
1910            }
1911            Event::GeneralRef(reference) if in_element.is_some() => {
1912                if let Some(resolved) = xml_core::decode_general_ref(&reference)? {
1913                    buffer.push_str(&resolved);
1914                }
1915            }
1916            Event::End(end)
1917                if in_element.is_some()
1918                    && matches!(
1919                        end.local_name().as_ref(),
1920                        b"title" | b"creator" | b"subject" | b"keywords"
1921                    ) =>
1922            {
1923                let text = std::mem::take(&mut buffer);
1924                match in_element.take() {
1925                    Some("title") => title = Some(text),
1926                    Some("creator") => author = Some(text),
1927                    Some("subject") => subject = Some(text),
1928                    Some("keywords") => keywords = Some(text),
1929                    _ => {}
1930                }
1931            }
1932            _ => {}
1933        }
1934    }
1935
1936    Ok(ParsedCoreProperties {
1937        title,
1938        author,
1939        subject,
1940        keywords,
1941    })
1942}
1943
1944/// Parses `docProps/app.xml` into `(Company, Manager, HyperlinkBase)` — an exact mirror of
1945/// `excel-ooxml`'s own `parse_extended_properties_xml`.
1946fn parse_extended_properties_xml(
1947    xml: &str,
1948) -> Result<(Option<String>, Option<String>, Option<String>)> {
1949    let mut reader = Reader::from_xml_str(xml);
1950    let mut company = None;
1951    let mut manager = None;
1952    let mut hyperlink_base = None;
1953    let mut in_element: Option<&'static str> = None;
1954    let mut buffer = String::new();
1955
1956    loop {
1957        match reader.read_event()? {
1958            Event::Eof => break,
1959            Event::Start(start) if start.local_name().as_ref() == b"Company" => {
1960                in_element = Some("Company");
1961                buffer.clear();
1962            }
1963            Event::Start(start) if start.local_name().as_ref() == b"Manager" => {
1964                in_element = Some("Manager");
1965                buffer.clear();
1966            }
1967            Event::Start(start) if start.local_name().as_ref() == b"HyperlinkBase" => {
1968                in_element = Some("HyperlinkBase");
1969                buffer.clear();
1970            }
1971            Event::Text(text) if in_element.is_some() => {
1972                buffer.push_str(&xml_core::decode_text(&text)?);
1973            }
1974            Event::GeneralRef(reference) if in_element.is_some() => {
1975                if let Some(resolved) = xml_core::decode_general_ref(&reference)? {
1976                    buffer.push_str(&resolved);
1977                }
1978            }
1979            Event::End(end)
1980                if in_element.is_some()
1981                    && matches!(
1982                        end.local_name().as_ref(),
1983                        b"Company" | b"Manager" | b"HyperlinkBase"
1984                    ) =>
1985            {
1986                let text = std::mem::take(&mut buffer);
1987                match in_element.take() {
1988                    Some("Company") => company = Some(text),
1989                    Some("Manager") => manager = Some(text),
1990                    Some("HyperlinkBase") => hyperlink_base = Some(text),
1991                    _ => {}
1992                }
1993            }
1994            _ => {}
1995        }
1996    }
1997
1998    Ok((company, manager, hyperlink_base))
1999}
2000
2001/// Parses `docProps/custom.xml` (OPC custom properties) into `(name, value)` pairs, in document
2002/// order — an exact mirror of `excel-ooxml`'s own `parse_custom_properties_xml` (including which 4
2003/// [`CustomPropertyValue`] variants are decoded).
2004fn parse_custom_properties_xml(xml: &str) -> Result<Vec<(String, CustomPropertyValue)>> {
2005    let mut reader = Reader::from_xml_str(xml);
2006    let mut custom_properties = Vec::new();
2007    let mut current_name: Option<String> = None;
2008    let mut current_variant: Option<Vec<u8>> = None;
2009    let mut buffer = String::new();
2010
2011    loop {
2012        match reader.read_event()? {
2013            Event::Eof => break,
2014
2015            Event::Start(start) if start.local_name().as_ref() == b"property" => {
2016                current_name = attribute_by_qname(&start, b"name");
2017            }
2018
2019            Event::Start(start) | Event::Empty(start)
2020                if current_name.is_some()
2021                    && matches!(
2022                        start.local_name().as_ref(),
2023                        b"lpwstr" | b"bool" | b"i4" | b"r8"
2024                    ) =>
2025            {
2026                current_variant = Some(start.local_name().as_ref().to_vec());
2027                buffer.clear();
2028            }
2029
2030            Event::Text(text) if current_variant.is_some() => {
2031                buffer.push_str(&xml_core::decode_text(&text)?);
2032            }
2033
2034            Event::GeneralRef(reference) if current_variant.is_some() => {
2035                if let Some(resolved) = xml_core::decode_general_ref(&reference)? {
2036                    buffer.push_str(&resolved);
2037                }
2038            }
2039
2040            Event::End(end) => {
2041                if let Some(variant) = current_variant.take() {
2042                    if end.local_name().as_ref() == variant.as_slice() {
2043                        if let Some(name) = current_name.clone() {
2044                            let value = std::mem::take(&mut buffer);
2045                            let parsed = match variant.as_slice() {
2046                                b"lpwstr" => Some(CustomPropertyValue::Text(value)),
2047                                b"bool" => {
2048                                    Some(CustomPropertyValue::Bool(value == "true" || value == "1"))
2049                                }
2050                                b"i4" => value.parse::<i32>().ok().map(CustomPropertyValue::Int),
2051                                b"r8" => value.parse::<f64>().ok().map(CustomPropertyValue::Number),
2052                                _ => None,
2053                            };
2054                            if let Some(value) = parsed {
2055                                custom_properties.push((name, value));
2056                            }
2057                        }
2058                    } else {
2059                        current_variant = Some(variant);
2060                    }
2061                } else if end.local_name().as_ref() == b"property" {
2062                    current_name = None;
2063                }
2064            }
2065
2066            _ => {}
2067        }
2068    }
2069
2070    Ok(custom_properties)
2071}
2072
2073/// Parses `ppt/tableStyles.xml` (`CT_TableStyleList`) into custom [`SlideTableStyle`]s. Skips the
2074/// root's own `def` attribute entirely (this crate's writer always falls back to the same fixed
2075/// built-in GUID, see `TABLE_STYLE_GUID`'s own doc comment).
2076fn parse_table_styles_xml(xml: &str) -> Result<Vec<SlideTableStyle>> {
2077    let mut styles = Vec::new();
2078
2079    let mut reader = Reader::from_xml_str(xml);
2080    loop {
2081        match reader.read_event()? {
2082            Event::Eof => break,
2083            Event::Start(start) if start.local_name().as_ref() == b"tblStyle" => {
2084                let id = attribute_by_qname(&start, b"styleId").unwrap_or_default();
2085                let name = attribute_by_qname(&start, b"styleName").unwrap_or_default();
2086                styles.push(parse_table_style(&mut reader, id, name)?);
2087            }
2088            Event::Empty(start) if start.local_name().as_ref() == b"tblStyle" => {
2089                let id = attribute_by_qname(&start, b"styleId").unwrap_or_default();
2090                let name = attribute_by_qname(&start, b"styleName").unwrap_or_default();
2091                styles.push(SlideTableStyle {
2092                    id,
2093                    name,
2094                    ..Default::default()
2095                });
2096            }
2097            _ => {}
2098        }
2099    }
2100
2101    Ok(styles)
2102}
2103
2104/// Parses one `<a:tblStyle>`'s content, assuming its own `Start` was just consumed by the caller —
2105/// consumes up to and including its own closing tag.
2106fn parse_table_style(reader: &mut Reader, id: String, name: String) -> Result<SlideTableStyle> {
2107    let mut style = SlideTableStyle {
2108        id,
2109        name,
2110        ..Default::default()
2111    };
2112
2113    loop {
2114        match reader.read_event()? {
2115            Event::Start(start) => {
2116                let wrapper_name = start.local_name().as_ref().to_vec();
2117                let part = Some(parse_table_style_part(reader, &wrapper_name)?);
2118                match wrapper_name.as_slice() {
2119                    b"wholeTbl" => style.whole_table = part,
2120                    b"band1H" => style.band1_horizontal = part,
2121                    b"band2H" => style.band2_horizontal = part,
2122                    b"band1V" => style.band1_vertical = part,
2123                    b"band2V" => style.band2_vertical = part,
2124                    b"firstRow" => style.first_row = part,
2125                    b"lastRow" => style.last_row = part,
2126                    b"firstCol" => style.first_column = part,
2127                    b"lastCol" => style.last_column = part,
2128                    // `neCell`/`nwCell`/`seCell`/`swCell` (single-corner cells) — not modeled, see
2129                    // `SlideTableStyle`'s own doc comment for scope.
2130                    _ => {}
2131                }
2132            }
2133            Event::Empty(_) => {}
2134            Event::End(end) if end.local_name().as_ref() == b"tblStyle" => break,
2135            Event::Eof => break,
2136            _ => {}
2137        }
2138    }
2139
2140    Ok(style)
2141}
2142
2143/// Parses one table-part element's content (`<a:wholeTbl>`/`<a:band1H>`/ etc. —
2144/// `CT_TableStyleTextStyle` + `CT_TableStyleCellStyle` combined, see
2145/// [`crate::model::TableStylePart`]'s own doc comment), assuming its own `Start` was just consumed
2146/// by the caller (`wrapper_local_name` is that same element's local name) — consumes up to and
2147/// including its own closing tag. Only breaks on an `End` matching `wrapper_local_name` itself, not
2148/// just any `End` — `<a:tcTxStyle>`'s own closing tag can arrive as a "stray" event here
2149/// (`drawing::read_color` doesn't always consume it, depending on whether it found a color — see
2150/// that function's own doc comment), and must be safely ignored rather than mistaken for this
2151/// element's own end, the same pattern already used by [`parse_slide_background`] around
2152/// `drawing::read_fill`.
2153fn parse_table_style_part(
2154    reader: &mut Reader,
2155    wrapper_local_name: &[u8],
2156) -> Result<TableStylePart> {
2157    let mut part = TableStylePart::new();
2158
2159    loop {
2160        match reader.read_event()? {
2161            Event::Empty(start) if start.local_name().as_ref() == b"tcTxStyle" => {
2162                part.bold = attribute_by_qname(&start, b"b").as_deref() == Some("on");
2163                part.italic = attribute_by_qname(&start, b"i").as_deref() == Some("on");
2164            }
2165            Event::Start(start) if start.local_name().as_ref() == b"tcTxStyle" => {
2166                part.bold = attribute_by_qname(&start, b"b").as_deref() == Some("on");
2167                part.italic = attribute_by_qname(&start, b"i").as_deref() == Some("on");
2168                part.text_color = drawing::read_color(reader)?;
2169            }
2170
2171            Event::Start(start) if start.local_name().as_ref() == b"tcStyle" => {
2172                part.fill = parse_table_style_cell_fill(reader)?;
2173            }
2174            Event::Empty(start) if start.local_name().as_ref() == b"tcStyle" => {}
2175
2176            Event::Start(_) => skip_subtree(reader)?,
2177            Event::Empty(_) => {}
2178            Event::End(end) if end.local_name().as_ref() == wrapper_local_name => break,
2179            Event::Eof => break,
2180            _ => {}
2181        }
2182    }
2183
2184    Ok(part)
2185}
2186
2187/// Parses `<a:tcStyle>`'s content looking for `<a:fill>`'s own `EG_FillProperties` choice, assuming
2188/// `<a:tcStyle>`'s own `Start` was just consumed by the caller — consumes up to and including
2189/// `<a:tcStyle>`'s own closing tag. `<a:tcBdr>`/`<a:cell3D>` are skipped (not modeled, see
2190/// `TableStylePart`'s own doc comment).
2191fn parse_table_style_cell_fill(reader: &mut Reader) -> Result<Option<Fill>> {
2192    let mut fill = None;
2193
2194    loop {
2195        match reader.read_event()? {
2196            Event::Start(start) if start.local_name().as_ref() == b"fill" => {
2197                fill = drawing::read_fill(reader)?;
2198            }
2199            Event::Empty(start) if start.local_name().as_ref() == b"fill" => {}
2200
2201            Event::Start(_) => skip_subtree(reader)?,
2202            Event::Empty(_) => {}
2203            Event::End(end) if end.local_name().as_ref() == b"tcStyle" => break,
2204            Event::Eof => break,
2205            _ => {}
2206        }
2207    }
2208
2209    Ok(fill)
2210}
2211
2212/// Parses `ppt/theme/theme1.xml` (`CT_OfficeStyleSheet`) into a [`Theme`] —
2213/// `clrScheme`/`fontScheme` only, `fmtScheme` is always ignored (this crate never customizes it
2214/// either way, see [`Theme`]'s own doc comment).
2215fn parse_theme_xml(xml: &str) -> Result<Theme> {
2216    let mut reader = Reader::from_xml_str(xml);
2217    let mut name = String::new();
2218    let mut colors = ColorScheme::office_default();
2219    let mut fonts = FontScheme::office_default();
2220
2221    loop {
2222        match reader.read_event()? {
2223            Event::Eof => break,
2224
2225            Event::Start(start) if start.local_name().as_ref() == b"theme" => {
2226                if let Some(value) = attribute_by_qname(&start, b"name") {
2227                    name = value;
2228                }
2229            }
2230            Event::Start(start) if start.local_name().as_ref() == b"clrScheme" => {
2231                colors = parse_color_scheme(&mut reader)?;
2232            }
2233            Event::Start(start) if start.local_name().as_ref() == b"fontScheme" => {
2234                fonts = parse_font_scheme(&mut reader)?;
2235            }
2236
2237            _ => {}
2238        }
2239    }
2240
2241    Ok(Theme {
2242        name,
2243        colors,
2244        fonts,
2245    })
2246}
2247
2248/// Parses `<a:clrScheme>`'s 12 color slots (a reader positioned right after its own opening tag was
2249/// consumed), stopping at `</a:clrScheme>`. Each slot's one child is either `<a:srgbClr val="..">`
2250/// (used directly) or `<a:sysClr val=".." lastClr="..">` (its `lastClr` fallback RGB is used
2251/// instead — mirrors `word_ooxml::ColorScheme`'s own reader, see that type's doc comment for why
2252/// the live system-color binding itself isn't preserved).
2253fn parse_color_scheme(reader: &mut Reader) -> Result<ColorScheme> {
2254    let mut colors = ColorScheme::office_default();
2255
2256    loop {
2257        match reader.read_event()? {
2258            Event::Eof => break,
2259            Event::End(end) if end.local_name().as_ref() == b"clrScheme" => break,
2260
2261            Event::Start(start) => {
2262                let slot = start.local_name().as_ref().to_vec();
2263                if let Some(value) = read_color_slot_value(reader, &slot)? {
2264                    assign_color_slot(&mut colors, &slot, value);
2265                }
2266            }
2267
2268            _ => {}
2269        }
2270    }
2271
2272    Ok(colors)
2273}
2274
2275/// Reads a single color slot's value (`<a:dk1>`, `<a:accent1>`..), a reader positioned right after
2276/// that wrapper's own opening tag was consumed. Consumes up to and including the matching end tag
2277/// for `wrapper_local_name`.
2278fn read_color_slot_value(reader: &mut Reader, wrapper_local_name: &[u8]) -> Result<Option<String>> {
2279    let mut value = None;
2280
2281    loop {
2282        match reader.read_event()? {
2283            Event::Eof => break,
2284            Event::End(end) if end.local_name().as_ref() == wrapper_local_name => break,
2285
2286            Event::Empty(start) | Event::Start(start) => match start.local_name().as_ref() {
2287                b"srgbClr" => value = attribute_by_qname(&start, b"val"),
2288                b"sysClr" => {
2289                    value = attribute_by_qname(&start, b"lastClr")
2290                        .or_else(|| attribute_by_qname(&start, b"val"))
2291                }
2292                _ => {}
2293            },
2294
2295            _ => {}
2296        }
2297    }
2298
2299    Ok(value)
2300}
2301
2302/// Assigns a parsed color value to the matching field of `colors`, by its `<a:clrScheme>` child
2303/// element name.
2304fn assign_color_slot(colors: &mut ColorScheme, slot: &[u8], value: String) {
2305    match slot {
2306        b"dk1" => colors.dark1 = value,
2307        b"lt1" => colors.light1 = value,
2308        b"dk2" => colors.dark2 = value,
2309        b"lt2" => colors.light2 = value,
2310        b"accent1" => colors.accent1 = value,
2311        b"accent2" => colors.accent2 = value,
2312        b"accent3" => colors.accent3 = value,
2313        b"accent4" => colors.accent4 = value,
2314        b"accent5" => colors.accent5 = value,
2315        b"accent6" => colors.accent6 = value,
2316        b"hlink" => colors.hyperlink = value,
2317        b"folHlink" => colors.followed_hyperlink = value,
2318        _ => {}
2319    }
2320}
2321
2322/// Parses `<a:fontScheme>`'s `majorFont`/`minorFont` (a reader positioned right after its own
2323/// opening tag was consumed), stopping at `</a:fontScheme>`. Only each font collection's
2324/// `<a:latin>` typeface is captured, mirrors `word_ooxml::FontScheme`'s own reader.
2325fn parse_font_scheme(reader: &mut Reader) -> Result<FontScheme> {
2326    let mut fonts = FontScheme::office_default();
2327
2328    loop {
2329        match reader.read_event()? {
2330            Event::Eof => break,
2331            Event::End(end) if end.local_name().as_ref() == b"fontScheme" => break,
2332
2333            Event::Start(start) if start.local_name().as_ref() == b"majorFont" => {
2334                if let Some(typeface) = parse_font_collection_latin(reader, b"majorFont")? {
2335                    fonts.major_latin = typeface;
2336                }
2337            }
2338            Event::Start(start) if start.local_name().as_ref() == b"minorFont" => {
2339                if let Some(typeface) = parse_font_collection_latin(reader, b"minorFont")? {
2340                    fonts.minor_latin = typeface;
2341                }
2342            }
2343
2344            _ => {}
2345        }
2346    }
2347
2348    Ok(fonts)
2349}
2350
2351/// Reads a `<a:majorFont>`/`<a:minorFont>` font collection's `<a:latin>` typeface, ignoring
2352/// `ea`/`cs`/per-script fallback children. Consumes up to and including the matching end tag for
2353/// `wrapper_local_name`.
2354fn parse_font_collection_latin(
2355    reader: &mut Reader,
2356    wrapper_local_name: &[u8],
2357) -> Result<Option<String>> {
2358    let mut typeface = None;
2359
2360    loop {
2361        match reader.read_event()? {
2362            Event::Eof => break,
2363            Event::End(end) if end.local_name().as_ref() == wrapper_local_name => break,
2364
2365            Event::Empty(start) | Event::Start(start)
2366                if start.local_name().as_ref() == b"latin" =>
2367            {
2368                typeface = attribute_by_qname(&start, b"typeface");
2369            }
2370
2371            Event::Start(_) => skip_subtree(reader)?,
2372            Event::Empty(_) => {}
2373            _ => {}
2374        }
2375    }
2376
2377    Ok(typeface)
2378}
2379
2380/// Parses `ppt/commentAuthors.xml` (`CT_CommentAuthorList`) into an `authorId -> (name, initials)`
2381/// map. **Not grounded on a real fixture** — see `model.rs`'s top-level doc comment.
2382fn parse_comment_authors_xml(xml: &str) -> Result<HashMap<u32, (String, String)>> {
2383    let mut authors = HashMap::new();
2384    let mut reader = Reader::from_xml_str(xml);
2385
2386    loop {
2387        match reader.read_event()? {
2388            Event::Eof => break,
2389            Event::Empty(start) | Event::Start(start)
2390                if start.local_name().as_ref() == b"cmAuthor" =>
2391            {
2392                if let Some(id) =
2393                    attribute_by_qname(&start, b"id").and_then(|value| value.parse().ok())
2394                {
2395                    let name = attribute_by_qname(&start, b"name").unwrap_or_default();
2396                    let initials = attribute_by_qname(&start, b"initials").unwrap_or_default();
2397                    authors.insert(id, (name, initials));
2398                }
2399            }
2400            _ => {}
2401        }
2402    }
2403
2404    Ok(authors)
2405}
2406
2407/// Parses one `ppt/comments/commentN.xml` (`CT_CommentList`) into this slide's [`SlideComment`]s,
2408/// resolving each `<p:cm authorId="..">` against `authors` (an unresolvable `authorId` — no
2409/// matching entry in `ppt/commentAuthors.xml` — falls back to an empty author name/initials rather
2410/// than erroring, same forgiving posture as the rest of this reader). **Not grounded on a real
2411/// fixture** — see `model.rs`'s top-level doc comment.
2412fn parse_comments_xml(
2413    xml: &str,
2414    authors: &HashMap<u32, (String, String)>,
2415) -> Result<Vec<SlideComment>> {
2416    let mut comments = Vec::new();
2417    let mut reader = Reader::from_xml_str(xml);
2418
2419    loop {
2420        match reader.read_event()? {
2421            Event::Eof => break,
2422            Event::Start(start) if start.local_name().as_ref() == b"cm" => {
2423                let author_id: u32 = attribute_by_qname(&start, b"authorId")
2424                    .and_then(|value| value.parse().ok())
2425                    .unwrap_or(0);
2426                let (author, initials) = authors.get(&author_id).cloned().unwrap_or_default();
2427                let date = attribute_by_qname(&start, b"dt").unwrap_or_default();
2428                comments.push(parse_comment_body(&mut reader, author, initials, date)?);
2429            }
2430            _ => {}
2431        }
2432    }
2433
2434    Ok(comments)
2435}
2436
2437/// Parses one `<p:cm>`'s own `<p:pos>`/`<p:text>` content, a reader positioned right after its own
2438/// opening tag was consumed. Stops at `</p:cm>`.
2439fn parse_comment_body(
2440    reader: &mut Reader,
2441    author: String,
2442    initials: String,
2443    date: String,
2444) -> Result<SlideComment> {
2445    let mut position_emu = (0i64, 0i64);
2446    let mut text = String::new();
2447
2448    loop {
2449        match reader.read_event()? {
2450            Event::Eof => break,
2451            Event::End(end) if end.local_name().as_ref() == b"cm" => break,
2452
2453            Event::Empty(start) if start.local_name().as_ref() == b"pos" => {
2454                position_emu = read_xy_attributes(&start, b"x", b"y");
2455            }
2456            Event::Start(start) if start.local_name().as_ref() == b"text" => {
2457                text = read_element_text(reader)?;
2458            }
2459
2460            Event::Start(_) => skip_subtree(reader)?,
2461            Event::Empty(_) => {}
2462            _ => {}
2463        }
2464    }
2465
2466    Ok(SlideComment {
2467        author,
2468        initials,
2469        date,
2470        position_emu,
2471        text,
2472    })
2473}
2474
2475/// Reads plain text content up to the next `Event::End` (a reader positioned right after a leaf
2476/// text element's own opening tag was consumed, e.g. `<p:text>`) — decodes
2477/// `Event::Text`/`Event::GeneralRef` the same way run text is decoded elsewhere in this workspace
2478/// (`xml_core::decode_text`/`decode_general_ref`), so a comment containing `&`/`<`/`>`/a numeric
2479/// character reference round-trips correctly.
2480fn read_element_text(reader: &mut Reader) -> Result<String> {
2481    let mut text = String::new();
2482
2483    loop {
2484        match reader.read_event()? {
2485            Event::Eof | Event::End(_) => break,
2486            Event::Text(bytes_text) => {
2487                text.push_str(&xml_core::decode_text(&bytes_text)?);
2488            }
2489            Event::GeneralRef(bytes_ref) => {
2490                if let Some(decoded) = xml_core::decode_general_ref(&bytes_ref)? {
2491                    text.push_str(&decoded);
2492                }
2493            }
2494            _ => {}
2495        }
2496    }
2497
2498    Ok(text)
2499}
2500
2501/// Reads the decoded value of an attribute matched by its full qualified name (including any
2502/// namespace prefix, e.g. `b"r:id"`), not just its local name — unlike `opc::relationship`'s own
2503/// attribute lookup, this matters here because a single element (`<p:sldId id="256" r:id="rId2"/>`)
2504/// can carry both an unprefixed `id` and a prefixed `r:id` attribute, which share the same *local*
2505/// name and would otherwise be ambiguous.
2506fn attribute_by_qname(start: &BytesStart, qname: &[u8]) -> Option<String> {
2507    start
2508        .attributes()
2509        .flatten()
2510        .find(|attribute| attribute.key.as_ref() == qname)
2511        .and_then(|attribute| {
2512            xml_core::decode_attribute_value(attribute.value.as_ref())
2513                .ok()
2514                .flatten()
2515        })
2516}
2517
2518/// Skips an already-opened element's entire subtree, including its own closing tag — same helper
2519/// `drawing::reader` keeps privately for the same purpose (gracefully ignoring an unmodeled child
2520/// element rather than failing the whole document).
2521fn skip_subtree(reader: &mut Reader) -> Result<()> {
2522    let mut depth: u32 = 0;
2523    loop {
2524        match reader.read_event()? {
2525            Event::Start(_) => depth += 1,
2526            Event::End(_) => {
2527                if depth == 0 {
2528                    return Ok(());
2529                }
2530                depth -= 1;
2531            }
2532            Event::Eof => return Ok(()),
2533            _ => {}
2534        }
2535    }
2536}