Skip to main content

pptxboss_core/
properties.rs

1//! Package metadata: the Core Properties part (ECMA-376 Part 2, clause 11)
2//! and the Extended Properties part (Part 1, clause 22.2). Both are read
3//! leniently: unknown elements are skipped, values are trimmed, and an
4//! empty element counts as absent.
5
6use crate::mce::children;
7use crate::xml::{Event, Ns, Reader, Start, XmlError};
8
9/// `docProps/core.xml`: Dublin Core and OPC properties, as strings.
10#[derive(Clone, Debug, Default, PartialEq, Eq)]
11pub struct CoreProperties {
12    pub title: Option<String>,
13    pub subject: Option<String>,
14    pub creator: Option<String>,
15    pub keywords: Option<String>,
16    pub description: Option<String>,
17    pub last_modified_by: Option<String>,
18    pub revision: Option<String>,
19    /// W3CDTF timestamp as written, e.g. `2024-05-01T10:00:00Z`.
20    pub created: Option<String>,
21    pub modified: Option<String>,
22    pub last_printed: Option<String>,
23    pub category: Option<String>,
24    pub content_status: Option<String>,
25    pub language: Option<String>,
26    pub identifier: Option<String>,
27    pub version: Option<String>,
28}
29
30impl CoreProperties {
31    pub fn parse(xml: &[u8]) -> Result<Self, XmlError> {
32        let mut reader = Reader::new(xml);
33        root(&mut reader, xml)?;
34        let mut props = Self::default();
35        children(&mut reader, &mut |reader, child| {
36            let slot = match (child.name.ns, child.name.local) {
37                (Ns::Dc, b"title") => &mut props.title,
38                (Ns::Dc, b"subject") => &mut props.subject,
39                (Ns::Dc, b"creator") => &mut props.creator,
40                (Ns::Dc, b"description") => &mut props.description,
41                (Ns::Dc, b"language") => &mut props.language,
42                (Ns::Dc, b"identifier") => &mut props.identifier,
43                (Ns::Cp, b"keywords") => &mut props.keywords,
44                (Ns::Cp, b"lastModifiedBy") => &mut props.last_modified_by,
45                (Ns::Cp, b"revision") => &mut props.revision,
46                (Ns::Cp, b"lastPrinted") => &mut props.last_printed,
47                (Ns::Cp, b"category") => &mut props.category,
48                (Ns::Cp, b"contentStatus") => &mut props.content_status,
49                (Ns::Cp, b"version") => &mut props.version,
50                (Ns::Dcterms, b"created") => &mut props.created,
51                (Ns::Dcterms, b"modified") => &mut props.modified,
52                _ => return reader.skip_element(),
53            };
54            *slot = text_of(reader)?;
55            Ok(())
56        })?;
57        Ok(props)
58    }
59
60    /// True when no property carries a value.
61    pub fn is_empty(&self) -> bool {
62        *self == Self::default()
63    }
64}
65
66/// `docProps/app.xml`: what the writing application recorded.
67#[derive(Clone, Debug, Default, PartialEq, Eq)]
68pub struct AppProperties {
69    pub application: Option<String>,
70    pub app_version: Option<String>,
71    pub company: Option<String>,
72    pub manager: Option<String>,
73    pub template: Option<String>,
74    pub presentation_format: Option<String>,
75    pub slides: Option<u64>,
76    pub notes: Option<u64>,
77    pub hidden_slides: Option<u64>,
78    pub words: Option<u64>,
79    pub paragraphs: Option<u64>,
80    pub multimedia_clips: Option<u64>,
81    /// Editing time in minutes.
82    pub total_time: Option<u64>,
83    /// `TitlesOfParts`: font names, the theme, then one entry per slide title.
84    pub titles_of_parts: Vec<String>,
85}
86
87impl AppProperties {
88    pub fn parse(xml: &[u8]) -> Result<Self, XmlError> {
89        let mut reader = Reader::new(xml);
90        root(&mut reader, xml)?;
91        let mut props = Self::default();
92        children(&mut reader, &mut |reader, child| {
93            if child.name.ns != Ns::Ep {
94                return reader.skip_element();
95            }
96            let text_slot = match child.name.local {
97                b"Application" => Some(&mut props.application),
98                b"AppVersion" => Some(&mut props.app_version),
99                b"Company" => Some(&mut props.company),
100                b"Manager" => Some(&mut props.manager),
101                b"Template" => Some(&mut props.template),
102                b"PresentationFormat" => Some(&mut props.presentation_format),
103                _ => None,
104            };
105            if let Some(slot) = text_slot {
106                *slot = text_of(reader)?;
107                return Ok(());
108            }
109            let count_slot = match child.name.local {
110                b"Slides" => Some(&mut props.slides),
111                b"Notes" => Some(&mut props.notes),
112                b"HiddenSlides" => Some(&mut props.hidden_slides),
113                b"Words" => Some(&mut props.words),
114                b"Paragraphs" => Some(&mut props.paragraphs),
115                b"MMClips" => Some(&mut props.multimedia_clips),
116                b"TotalTime" => Some(&mut props.total_time),
117                _ => None,
118            };
119            if let Some(slot) = count_slot {
120                *slot = text_of(reader)?.and_then(|text| text.parse().ok());
121                return Ok(());
122            }
123            if child.name.local == b"TitlesOfParts" {
124                return collect_strings(reader, &mut props.titles_of_parts);
125            }
126            reader.skip_element()
127        })?;
128        Ok(props)
129    }
130}
131
132/// Consumes the root start tag or fails when the part has none.
133fn root<'a>(reader: &mut Reader<'a>, xml: &[u8]) -> Result<Start<'a>, XmlError> {
134    loop {
135        match reader.next()? {
136            Event::Start(start) => return Ok(start),
137            Event::Eof => {
138                return Err(XmlError {
139                    offset: xml.len(),
140                    msg: "no root element",
141                })
142            }
143            _ => {}
144        }
145    }
146}
147
148/// The trimmed text of the current element, None when blank.
149fn text_of(reader: &mut Reader<'_>) -> Result<Option<String>, XmlError> {
150    let mut text = String::new();
151    reader.text_content(&mut text)?;
152    let trimmed = text.trim();
153    Ok(match trimmed.is_empty() {
154        true => None,
155        false => Some(trimmed.to_string()),
156    })
157}
158
159/// Every `vt:lpstr` (or other leaf) under the current element, in order.
160fn collect_strings(reader: &mut Reader<'_>, out: &mut Vec<String>) -> Result<(), XmlError> {
161    children(reader, &mut |reader, child| {
162        if child.name.is(Ns::Vt, b"vector") || child.name.is(Ns::Vt, b"variant") {
163            return collect_strings(reader, out);
164        }
165        if let Some(text) = text_of(reader)? {
166            out.push(text);
167        }
168        Ok(())
169    })
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn core_properties_read_dublin_core_and_opc_elements() {
178        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
179<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
180<dc:title>Quarterly &amp; review</dc:title><dc:creator>Ada</dc:creator><cp:lastModifiedBy>Bob</cp:lastModifiedBy><cp:revision>7</cp:revision>
181<dcterms:created xsi:type="dcterms:W3CDTF">2024-05-01T10:00:00Z</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">2024-06-02T11:30:00Z</dcterms:modified>
182<dc:description></dc:description><cp:keywords> tags </cp:keywords><cp:unknown>x</cp:unknown></cp:coreProperties>"#;
183        let props = CoreProperties::parse(xml).unwrap();
184        assert_eq!(props.title.as_deref(), Some("Quarterly & review"));
185        assert_eq!(props.creator.as_deref(), Some("Ada"));
186        assert_eq!(props.last_modified_by.as_deref(), Some("Bob"));
187        assert_eq!(props.revision.as_deref(), Some("7"));
188        assert_eq!(props.created.as_deref(), Some("2024-05-01T10:00:00Z"));
189        assert_eq!(props.modified.as_deref(), Some("2024-06-02T11:30:00Z"));
190        assert_eq!(props.description, None);
191        assert_eq!(props.keywords.as_deref(), Some("tags"));
192        assert!(!props.is_empty());
193        assert!(CoreProperties::parse(br#"<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"/>"#).unwrap().is_empty());
194    }
195
196    #[test]
197    fn app_properties_read_counts_and_titles_of_parts() {
198        let xml = br#"<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
199<TotalTime>12</TotalTime><Words>345</Words><Application>Microsoft Macintosh PowerPoint</Application><PresentationFormat>Widescreen</PresentationFormat><Paragraphs>40</Paragraphs><Slides>7</Slides><Notes>2</Notes><HiddenSlides>0</HiddenSlides><MMClips>0</MMClips>
200<HeadingPairs><vt:vector size="4" baseType="variant"><vt:variant><vt:lpstr>Theme</vt:lpstr></vt:variant><vt:variant><vt:i4>1</vt:i4></vt:variant><vt:variant><vt:lpstr>Slide Titles</vt:lpstr></vt:variant><vt:variant><vt:i4>2</vt:i4></vt:variant></vt:vector></HeadingPairs>
201<TitlesOfParts><vt:vector size="3" baseType="lpstr"><vt:lpstr>Office Theme</vt:lpstr><vt:lpstr>Intro</vt:lpstr><vt:lpstr>Numbers</vt:lpstr></vt:vector></TitlesOfParts>
202<Company>ACME</Company><AppVersion>16.0000</AppVersion></Properties>"#;
203        let props = AppProperties::parse(xml).unwrap();
204        assert_eq!(props.total_time, Some(12));
205        assert_eq!(props.words, Some(345));
206        assert_eq!(props.slides, Some(7));
207        assert_eq!(props.notes, Some(2));
208        assert_eq!(props.hidden_slides, Some(0));
209        assert_eq!(
210            props.application.as_deref(),
211            Some("Microsoft Macintosh PowerPoint")
212        );
213        assert_eq!(props.presentation_format.as_deref(), Some("Widescreen"));
214        assert_eq!(props.company.as_deref(), Some("ACME"));
215        assert_eq!(props.app_version.as_deref(), Some("16.0000"));
216        assert_eq!(props.titles_of_parts, ["Office Theme", "Intro", "Numbers"]);
217    }
218}