Skip to main content

pptx_parse/
inventory.rs

1//! Source locations of a slide part's modeled content and an inventory of what the model leaves
2//! out, found while parsing by walking the part's XML the way the parser does and retained on
3//! the package.
4
5use crate::model::ShapeElements;
6use crate::relationships::Relationship;
7use crate::xml::{XmlElement, alternate_content_branch};
8
9/// A slide part's shapes, in the order and nesting of [`crate::Slide::shapes`], and the
10/// shape-tree children the model does not represent.
11#[derive(Clone, Debug, Default, PartialEq, Eq)]
12pub struct SlideSource {
13    pub shapes: Vec<SourceShape>,
14    pub omitted: Vec<OmittedElement>,
15}
16
17/// Where one modeled shape sits in its part, and the source metadata the model does not keep.
18#[derive(Clone, Debug, Default, PartialEq, Eq)]
19pub struct SourceShape {
20    /// Element-child ordinals from the part's root element.
21    pub path: Vec<u32>,
22    /// The element's qualified name, such as `p:sp`.
23    pub element: String,
24    /// `cNvPr/@id`.
25    pub id: u32,
26    /// `cNvPr/@title`.
27    pub title: Option<String>,
28    /// `cNvPr/@descr`.
29    pub description: Option<String>,
30    /// What a picture plays, when it is a media frame.
31    pub media: Option<MediaKind>,
32    /// `a:graphicData/@uri` of a graphic frame.
33    pub graphic_uri: Option<String>,
34    /// The part's relationships the element references, in document order; none for a group.
35    pub relationship_ids: Vec<String>,
36    /// Paragraph children the model does not represent.
37    pub omitted_inlines: Vec<OmittedInline>,
38    pub children: Vec<SourceShape>,
39    /// Children of a group the model does not represent.
40    pub omitted: Vec<OmittedElement>,
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum MediaKind {
45    Video,
46    Audio,
47}
48
49/// A shape-tree child the model does not represent.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct OmittedElement {
52    pub path: Vec<u32>,
53    /// The qualified name of the element, or of the branch content an unreadable
54    /// `mc:AlternateContent` holds.
55    pub element: String,
56    /// Modeled siblings that precede it.
57    pub position: usize,
58    /// `cNvPr/@name` and `cNvPr/@descr` when the element carries non-visual properties.
59    pub name: Option<String>,
60    pub description: Option<String>,
61    /// `cNvPr/@hidden`.
62    pub hidden: bool,
63    /// The part's relationships the element references, in document order.
64    pub relationship_ids: Vec<String>,
65}
66
67/// A paragraph child the model does not represent, such as an equation.
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct OmittedInline {
70    pub path: Vec<u32>,
71    pub element: String,
72    /// The table cell holding the paragraph, as (row, cell).
73    pub cell: Option<(usize, usize)>,
74    /// Index of the paragraph among its text body's `a:p` elements.
75    pub paragraph: usize,
76    /// Modeled runs (`a:r`, `a:fld`, `a:br`) of the paragraph that precede it.
77    pub position: usize,
78}
79
80/// A slide's notes page, beyond the notes text the model keeps.
81#[derive(Clone, Debug, PartialEq, Eq)]
82pub struct NotesSource {
83    pub part_path: String,
84    /// Shapes with text other than the notes body and the page's header, footer, date,
85    /// slide-number and slide-image placeholders.
86    pub other_text_shapes: usize,
87}
88
89/// The inventory of a parsed slide part's root element.
90pub(crate) fn slide_source(
91    root: &XmlElement,
92    relationships: &[Relationship],
93    elements: ShapeElements,
94) -> SlideSource {
95    let mut source = SlideSource::default();
96    let Some((common_index, common)) = child_named(root, "cSld") else {
97        return source;
98    };
99    let Some((tree_index, tree)) = child_named(common, "spTree") else {
100        return source;
101    };
102    let walk = Walk {
103        elements,
104        relationships,
105    };
106    walk.children(
107        tree,
108        &[common_index, tree_index],
109        &mut source.shapes,
110        &mut source.omitted,
111    );
112    source
113}
114
115/// The inventory of a parsed notes page's root element.
116pub(crate) fn notes_source(root: &XmlElement, part_path: &str) -> NotesSource {
117    let other_text_shapes = root
118        .child("cSld")
119        .and_then(|common| common.child("spTree"))
120        .map_or(0, |tree| {
121            tree.descendants_named("sp")
122                .into_iter()
123                .filter(|shape| !is_notes_decoration(shape) && has_text(shape))
124                .count()
125        });
126    NotesSource {
127        part_path: part_path.to_owned(),
128        other_text_shapes,
129    }
130}
131
132fn is_notes_decoration(shape: &XmlElement) -> bool {
133    let placeholder = shape
134        .child("nvSpPr")
135        .and_then(|properties| properties.child("nvPr"))
136        .and_then(|properties| properties.child("ph"));
137    placeholder.is_some_and(|placeholder| {
138        matches!(
139            placeholder.attribute("type"),
140            Some("body" | "sldImg" | "hdr" | "ftr" | "dt" | "sldNum")
141        )
142    })
143}
144
145fn has_text(shape: &XmlElement) -> bool {
146    shape.child("txBody").is_some_and(|body| {
147        body.descendants_named("t")
148            .iter()
149            .any(|text| !text.text_content().is_empty())
150    })
151}
152
153fn child_named<'a>(parent: &'a XmlElement, name: &str) -> Option<(u32, &'a XmlElement)> {
154    parent
155        .child_elements()
156        .enumerate()
157        .find(|(_, child)| child.local_name() == name)
158        .map(|(index, child)| (index as u32, child))
159}
160
161fn extend(path: &[u32], index: usize) -> Vec<u32> {
162    let mut path = path.to_vec();
163    path.push(index as u32);
164    path
165}
166
167/// Mirrors the parser's shape-tree walk over one part.
168struct Walk<'a> {
169    elements: ShapeElements,
170    relationships: &'a [Relationship],
171}
172
173impl Walk<'_> {
174    /// `mc:AlternateContent` contributes the shapes of the branch it reads, and elements the
175    /// model counts as shapes become [`SourceShape`]s.
176    fn children(
177        &self,
178        parent: &XmlElement,
179        path: &[u32],
180        shapes: &mut Vec<SourceShape>,
181        omitted: &mut Vec<OmittedElement>,
182    ) {
183        for (index, child) in parent.child_elements().enumerate() {
184            let child_path = extend(path, index);
185            let local = child.local_name();
186            if local == "AlternateContent" {
187                match branch_with_index(child) {
188                    Some((branch_index, branch)) => {
189                        self.children(branch, &extend(&child_path, branch_index), shapes, omitted)
190                    }
191                    None => omitted.push(self.omitted(child, child_path, shapes.len())),
192                }
193                continue;
194            }
195            if self.elements.contains(local) {
196                shapes.push(self.shape(child, child_path));
197            } else if !matches!(local, "nvGrpSpPr" | "grpSpPr" | "extLst") {
198                omitted.push(self.omitted(child, child_path, shapes.len()));
199            }
200        }
201    }
202
203    fn omitted(&self, element: &XmlElement, path: Vec<u32>, position: usize) -> OmittedElement {
204        let properties = element.descendants_named("cNvPr").into_iter().next();
205        OmittedElement {
206            path,
207            element: content_name(element).to_owned(),
208            position,
209            name: properties
210                .and_then(|properties| properties.attribute("name"))
211                .filter(|name| !name.is_empty())
212                .map(str::to_owned),
213            description: properties
214                .and_then(|properties| properties.attribute("descr"))
215                .map(str::to_owned),
216            hidden: properties
217                .and_then(|properties| properties.attribute("hidden"))
218                .is_some_and(|value| matches!(value, "1" | "true" | "on")),
219            relationship_ids: self.relationship_ids(element),
220        }
221    }
222
223    fn shape(&self, element: &XmlElement, path: Vec<u32>) -> SourceShape {
224        let non_visual = element.child_elements().find(|child| {
225            matches!(
226                child.local_name(),
227                "nvSpPr" | "nvCxnSpPr" | "nvPicPr" | "nvGraphicFramePr" | "nvGrpSpPr"
228            )
229        });
230        let properties = non_visual.and_then(|value| value.child("cNvPr"));
231        let mut shape = SourceShape {
232            element: element.name.clone(),
233            id: properties
234                .and_then(|value| value.attribute("id"))
235                .and_then(|value| value.parse().ok())
236                .unwrap_or_default(),
237            title: properties
238                .and_then(|value| value.attribute("title"))
239                .map(str::to_owned),
240            description: properties
241                .and_then(|value| value.attribute("descr"))
242                .map(str::to_owned),
243            media: non_visual
244                .and_then(|value| value.child("nvPr"))
245                .and_then(media_kind),
246            ..SourceShape::default()
247        };
248        match element.local_name() {
249            "sp" | "cxnSp" => {
250                if let Some((index, body)) = child_named(element, "txBody") {
251                    text_body_inlines(
252                        body,
253                        &extend(&path, index as usize),
254                        None,
255                        &mut shape.omitted_inlines,
256                    );
257                }
258            }
259            "graphicFrame" => {
260                let graphic = child_named(element, "graphic");
261                let data = graphic.and_then(|(_, graphic)| child_named(graphic, "graphicData"));
262                shape.graphic_uri = data
263                    .and_then(|(_, data)| data.attribute("uri"))
264                    .map(str::to_owned);
265                if let (Some((graphic_index, _)), Some((data_index, data))) = (graphic, data)
266                    && let Some((table_index, table)) = child_named(data, "tbl")
267                {
268                    let table_path =
269                        [path.as_slice(), &[graphic_index, data_index, table_index]].concat();
270                    table_inlines(table, &table_path, &mut shape.omitted_inlines);
271                }
272            }
273            "grpSp" => self.children(element, &path, &mut shape.children, &mut shape.omitted),
274            _ => {}
275        }
276        if element.local_name() != "grpSp" {
277            shape.relationship_ids = self.relationship_ids(element);
278        }
279        shape.path = path;
280        shape
281    }
282
283    /// Values of namespaced attributes, anywhere in `element`, that name one of the part's
284    /// relationships.
285    fn relationship_ids(&self, element: &XmlElement) -> Vec<String> {
286        let mut ids = Vec::new();
287        let mut pending = vec![element];
288        while let Some(current) = pending.pop() {
289            for (name, value) in &current.attributes {
290                if name.contains(':')
291                    && !name.starts_with("xmlns")
292                    && !ids.contains(value)
293                    && self
294                        .relationships
295                        .iter()
296                        .any(|relationship| &relationship.id == value)
297                {
298                    ids.push(value.clone());
299                }
300            }
301            let children: Vec<&XmlElement> = current.child_elements().collect();
302            pending.extend(children.into_iter().rev());
303        }
304        ids
305    }
306}
307
308fn branch_with_index(alternate: &XmlElement) -> Option<(usize, &XmlElement)> {
309    let branch = alternate_content_branch(alternate)?;
310    alternate
311        .child_elements()
312        .enumerate()
313        .find(|(_, child)| std::ptr::eq(*child, branch))
314}
315
316/// An element's name, or for `mc:AlternateContent` the name of its first choice's content.
317fn content_name(element: &XmlElement) -> &str {
318    let content = if element.local_name() == "AlternateContent" {
319        element
320            .child_elements()
321            .flat_map(XmlElement::child_elements)
322            .next()
323            .unwrap_or(element)
324    } else {
325        element
326    };
327    &content.name
328}
329
330fn media_kind(properties: &XmlElement) -> Option<MediaKind> {
331    let mut media = None;
332    for child in properties.child_elements() {
333        match child.local_name() {
334            "videoFile" | "quickTimeFile" => return Some(MediaKind::Video),
335            "audioFile" | "audioCd" | "wavAudioFile" => media = Some(MediaKind::Audio),
336            "extLst" if media.is_none() && !child.descendants_named("media").is_empty() => {
337                media = Some(MediaKind::Video);
338            }
339            _ => {}
340        }
341    }
342    media
343}
344
345fn table_inlines(table: &XmlElement, path: &[u32], output: &mut Vec<OmittedInline>) {
346    let rows = table
347        .child_elements()
348        .enumerate()
349        .filter(|(_, child)| child.local_name() == "tr");
350    for (row_index, (row_ordinal, row)) in rows.enumerate() {
351        let cells = row
352            .child_elements()
353            .enumerate()
354            .filter(|(_, child)| child.local_name() == "tc");
355        for (cell_index, (cell_ordinal, cell)) in cells.enumerate() {
356            if let Some((body_index, body)) = child_named(cell, "txBody") {
357                let body_path =
358                    [path, &[row_ordinal as u32, cell_ordinal as u32, body_index]].concat();
359                text_body_inlines(body, &body_path, Some((row_index, cell_index)), output);
360            }
361        }
362    }
363}
364
365fn text_body_inlines(
366    body: &XmlElement,
367    path: &[u32],
368    cell: Option<(usize, usize)>,
369    output: &mut Vec<OmittedInline>,
370) {
371    let paragraphs = body
372        .child_elements()
373        .enumerate()
374        .filter(|(_, child)| child.local_name() == "p");
375    for (paragraph, (paragraph_ordinal, element)) in paragraphs.enumerate() {
376        let mut runs = 0;
377        for (index, child) in element.child_elements().enumerate() {
378            match child.local_name() {
379                "r" | "fld" | "br" => runs += 1,
380                "pPr" | "endParaRPr" => {}
381                _ => {
382                    output.push(OmittedInline {
383                        path: [path, &[paragraph_ordinal as u32, index as u32]].concat(),
384                        element: content_name(child).to_owned(),
385                        cell,
386                        paragraph,
387                        position: runs,
388                    });
389                }
390            }
391        }
392    }
393}