Skip to main content

oxml_core/
app_properties.rs

1//! Application-specific properties from `docProps/app.xml`.
2
3use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
4use quick_xml::{Reader, Writer};
5use std::collections::HashSet;
6use std::io::Write;
7
8use crate::error::{OxmlError, Result};
9use crate::raw_xml::{capture_element, capture_empty_element};
10use crate::xml::{extra_namespace_declarations, local_name};
11use crate::xml_text::read_element_text;
12
13const EXTENDED_PROPERTIES_NS: &str =
14    "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties";
15const VARIANT_TYPES_NS: &str =
16    "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19enum KnownProperty {
20    Template,
21    Manager,
22    Company,
23    Pages,
24    Words,
25    Characters,
26    PresentationFormat,
27    Lines,
28    Paragraphs,
29    Slides,
30    Notes,
31    TotalTime,
32    HiddenSlides,
33    MultimediaClips,
34    ScaleCrop,
35    LinksUpToDate,
36    CharactersWithSpaces,
37    SharedDocument,
38    HyperlinksChanged,
39    Application,
40    ApplicationVersion,
41}
42
43const CANONICAL_ORDER: &[KnownProperty] = &[
44    KnownProperty::Template,
45    KnownProperty::Manager,
46    KnownProperty::Company,
47    KnownProperty::Pages,
48    KnownProperty::Words,
49    KnownProperty::Characters,
50    KnownProperty::PresentationFormat,
51    KnownProperty::Lines,
52    KnownProperty::Paragraphs,
53    KnownProperty::Slides,
54    KnownProperty::Notes,
55    KnownProperty::TotalTime,
56    KnownProperty::HiddenSlides,
57    KnownProperty::MultimediaClips,
58    KnownProperty::ScaleCrop,
59    KnownProperty::LinksUpToDate,
60    KnownProperty::CharactersWithSpaces,
61    KnownProperty::SharedDocument,
62    KnownProperty::HyperlinksChanged,
63    KnownProperty::Application,
64    KnownProperty::ApplicationVersion,
65];
66
67impl KnownProperty {
68    fn from_name(name: &[u8]) -> Option<Self> {
69        match name {
70            b"Template" => Some(Self::Template),
71            b"Manager" => Some(Self::Manager),
72            b"Company" => Some(Self::Company),
73            b"Pages" => Some(Self::Pages),
74            b"Words" => Some(Self::Words),
75            b"Characters" => Some(Self::Characters),
76            b"PresentationFormat" => Some(Self::PresentationFormat),
77            b"Lines" => Some(Self::Lines),
78            b"Paragraphs" => Some(Self::Paragraphs),
79            b"Slides" => Some(Self::Slides),
80            b"Notes" => Some(Self::Notes),
81            b"TotalTime" => Some(Self::TotalTime),
82            b"HiddenSlides" => Some(Self::HiddenSlides),
83            b"MMClips" => Some(Self::MultimediaClips),
84            b"ScaleCrop" => Some(Self::ScaleCrop),
85            b"LinksUpToDate" => Some(Self::LinksUpToDate),
86            b"CharactersWithSpaces" => Some(Self::CharactersWithSpaces),
87            b"SharedDoc" => Some(Self::SharedDocument),
88            b"HyperlinksChanged" => Some(Self::HyperlinksChanged),
89            b"Application" => Some(Self::Application),
90            b"AppVersion" => Some(Self::ApplicationVersion),
91            _ => None,
92        }
93    }
94
95    fn tag(self) -> &'static str {
96        match self {
97            Self::Template => "Template",
98            Self::Manager => "Manager",
99            Self::Company => "Company",
100            Self::Pages => "Pages",
101            Self::Words => "Words",
102            Self::Characters => "Characters",
103            Self::PresentationFormat => "PresentationFormat",
104            Self::Lines => "Lines",
105            Self::Paragraphs => "Paragraphs",
106            Self::Slides => "Slides",
107            Self::Notes => "Notes",
108            Self::TotalTime => "TotalTime",
109            Self::HiddenSlides => "HiddenSlides",
110            Self::MultimediaClips => "MMClips",
111            Self::ScaleCrop => "ScaleCrop",
112            Self::LinksUpToDate => "LinksUpToDate",
113            Self::CharactersWithSpaces => "CharactersWithSpaces",
114            Self::SharedDocument => "SharedDoc",
115            Self::HyperlinksChanged => "HyperlinksChanged",
116            Self::Application => "Application",
117            Self::ApplicationVersion => "AppVersion",
118        }
119    }
120}
121
122#[derive(Debug, Clone, PartialEq)]
123enum ChildOrder {
124    Known(KnownProperty),
125    Raw(usize),
126}
127
128/// Shared application properties from a Word or PowerPoint package.
129#[derive(Debug, Clone, Default)]
130pub struct AppProperties {
131    pub template: Option<String>,
132    pub manager: Option<String>,
133    pub company: Option<String>,
134    pub pages: Option<i32>,
135    pub words: Option<i32>,
136    pub characters: Option<i32>,
137    pub presentation_format: Option<String>,
138    pub lines: Option<i32>,
139    pub paragraphs: Option<i32>,
140    pub slides: Option<i32>,
141    pub notes: Option<i32>,
142    pub total_time: Option<i32>,
143    pub hidden_slides: Option<i32>,
144    pub multimedia_clips: Option<i32>,
145    pub scale_crop: Option<bool>,
146    pub links_up_to_date: Option<bool>,
147    pub characters_with_spaces: Option<i32>,
148    pub shared_document: Option<bool>,
149    pub hyperlinks_changed: Option<bool>,
150    pub application: Option<String>,
151    pub application_version: Option<String>,
152    child_order: Vec<ChildOrder>,
153    extra_xml: Vec<Vec<u8>>,
154    extra_namespaces: Vec<(String, String)>,
155}
156
157impl PartialEq for AppProperties {
158    fn eq(&self, other: &Self) -> bool {
159        self.template == other.template
160            && self.manager == other.manager
161            && self.company == other.company
162            && self.pages == other.pages
163            && self.words == other.words
164            && self.characters == other.characters
165            && self.presentation_format == other.presentation_format
166            && self.lines == other.lines
167            && self.paragraphs == other.paragraphs
168            && self.slides == other.slides
169            && self.notes == other.notes
170            && self.total_time == other.total_time
171            && self.hidden_slides == other.hidden_slides
172            && self.multimedia_clips == other.multimedia_clips
173            && self.scale_crop == other.scale_crop
174            && self.links_up_to_date == other.links_up_to_date
175            && self.characters_with_spaces == other.characters_with_spaces
176            && self.shared_document == other.shared_document
177            && self.hyperlinks_changed == other.hyperlinks_changed
178            && self.application == other.application
179            && self.application_version == other.application_version
180            && self.extra_xml == other.extra_xml
181            && self.extra_namespaces == other.extra_namespaces
182    }
183}
184
185impl AppProperties {
186    /// Parse a `docProps/app.xml` part.
187    pub fn from_xml(xml: &[u8]) -> Result<Self> {
188        let mut reader = Reader::from_reader(xml);
189        let mut properties = Self::default();
190        let mut seen = HashSet::new();
191        let mut root_open = false;
192        let mut root_closed = false;
193        let mut buf = Vec::new();
194
195        loop {
196            match reader.read_event_into(&mut buf) {
197                Ok(Event::Start(ref element)) => {
198                    let qualified_name = element.name();
199                    let name = local_name(qualified_name.as_ref());
200                    if name == b"Properties" {
201                        if root_open || root_closed {
202                            return Err(OxmlError::UnexpectedElement("Properties".to_owned()));
203                        }
204                        root_open = true;
205                        properties
206                            .extra_namespaces
207                            .extend(extra_namespace_declarations(element)?);
208                    } else if !root_open {
209                        return Err(OxmlError::UnexpectedElement(
210                            String::from_utf8_lossy(name).into_owned(),
211                        ));
212                    } else if let Some(property) = KnownProperty::from_name(name) {
213                        if !seen.insert(property) {
214                            return Err(OxmlError::InvalidValue(format!(
215                                "duplicate application property {}",
216                                property.tag()
217                            )));
218                        }
219                        let text = read_element_text(&mut reader, element.name());
220                        properties.set_text(property, text)?;
221                        properties.child_order.push(ChildOrder::Known(property));
222                    } else {
223                        let raw = capture_element(&mut reader, element)?;
224                        let index = properties.extra_xml.len();
225                        properties.extra_xml.push(raw);
226                        properties.child_order.push(ChildOrder::Raw(index));
227                    }
228                }
229                Ok(Event::Empty(ref element)) => {
230                    let qualified_name = element.name();
231                    let name = local_name(qualified_name.as_ref());
232                    if name == b"Properties" {
233                        if root_open || root_closed {
234                            return Err(OxmlError::UnexpectedElement("Properties".to_owned()));
235                        }
236                        root_closed = true;
237                    } else if !root_open {
238                        return Err(OxmlError::UnexpectedElement(
239                            String::from_utf8_lossy(name).into_owned(),
240                        ));
241                    } else if let Some(property) = KnownProperty::from_name(name) {
242                        if !seen.insert(property) {
243                            return Err(OxmlError::InvalidValue(format!(
244                                "duplicate application property {}",
245                                property.tag()
246                            )));
247                        }
248                        properties.set_text(property, String::new())?;
249                        properties.child_order.push(ChildOrder::Known(property));
250                    } else {
251                        let index = properties.extra_xml.len();
252                        properties.extra_xml.push(capture_empty_element(element)?);
253                        properties.child_order.push(ChildOrder::Raw(index));
254                    }
255                }
256                Ok(Event::End(ref element))
257                    if local_name(element.name().as_ref()) == b"Properties" =>
258                {
259                    if !root_open {
260                        return Err(OxmlError::UnexpectedElement("Properties".to_owned()));
261                    }
262                    root_open = false;
263                    root_closed = true;
264                }
265                Ok(Event::Eof) => break,
266                Err(error) => return Err(error.into()),
267                _ => {}
268            }
269            buf.clear();
270        }
271
272        if root_closed {
273            Ok(properties)
274        } else {
275            Err(OxmlError::MissingElement("Properties root".to_owned()))
276        }
277    }
278
279    /// Serialize a `docProps/app.xml` part.
280    pub fn to_xml(&self) -> Result<Vec<u8>> {
281        let mut writer = Writer::new(Vec::new());
282        writer.write_event(Event::Decl(BytesDecl::new(
283            "1.0",
284            Some("UTF-8"),
285            Some("yes"),
286        )))?;
287
288        let mut root = BytesStart::new("Properties");
289        root.push_attribute(("xmlns", EXTENDED_PROPERTIES_NS));
290        root.push_attribute(("xmlns:vt", VARIANT_TYPES_NS));
291        for (name, value) in &self.extra_namespaces {
292            root.push_attribute((name.as_str(), value.as_str()));
293        }
294        writer.write_event(Event::Start(root))?;
295
296        let mut written = HashSet::new();
297        for child in &self.child_order {
298            match child {
299                ChildOrder::Known(property) if written.insert(*property) => {
300                    self.write_property(&mut writer, *property)?;
301                }
302                ChildOrder::Known(_) => {}
303                ChildOrder::Raw(index) => {
304                    if let Some(raw) = self.extra_xml.get(*index) {
305                        writer.get_mut().write_all(raw)?;
306                    }
307                }
308            }
309        }
310        for property in CANONICAL_ORDER {
311            if written.insert(*property) {
312                self.write_property(&mut writer, *property)?;
313            }
314        }
315
316        writer.write_event(Event::End(BytesEnd::new("Properties")))?;
317        Ok(writer.into_inner())
318    }
319
320    fn set_text(&mut self, property: KnownProperty, text: String) -> Result<()> {
321        match property {
322            KnownProperty::Template => self.template = Some(text),
323            KnownProperty::Manager => self.manager = Some(text),
324            KnownProperty::Company => self.company = Some(text),
325            KnownProperty::PresentationFormat => self.presentation_format = Some(text),
326            KnownProperty::Application => self.application = Some(text),
327            KnownProperty::ApplicationVersion => self.application_version = Some(text),
328            KnownProperty::Pages => self.pages = Some(parse_i32(property, &text)?),
329            KnownProperty::Words => self.words = Some(parse_i32(property, &text)?),
330            KnownProperty::Characters => self.characters = Some(parse_i32(property, &text)?),
331            KnownProperty::Lines => self.lines = Some(parse_i32(property, &text)?),
332            KnownProperty::Paragraphs => self.paragraphs = Some(parse_i32(property, &text)?),
333            KnownProperty::Slides => self.slides = Some(parse_i32(property, &text)?),
334            KnownProperty::Notes => self.notes = Some(parse_i32(property, &text)?),
335            KnownProperty::TotalTime => self.total_time = Some(parse_i32(property, &text)?),
336            KnownProperty::HiddenSlides => self.hidden_slides = Some(parse_i32(property, &text)?),
337            KnownProperty::MultimediaClips => {
338                self.multimedia_clips = Some(parse_i32(property, &text)?)
339            }
340            KnownProperty::CharactersWithSpaces => {
341                self.characters_with_spaces = Some(parse_i32(property, &text)?)
342            }
343            KnownProperty::ScaleCrop => self.scale_crop = Some(parse_bool(property, &text)?),
344            KnownProperty::LinksUpToDate => {
345                self.links_up_to_date = Some(parse_bool(property, &text)?)
346            }
347            KnownProperty::SharedDocument => {
348                self.shared_document = Some(parse_bool(property, &text)?)
349            }
350            KnownProperty::HyperlinksChanged => {
351                self.hyperlinks_changed = Some(parse_bool(property, &text)?)
352            }
353        }
354        Ok(())
355    }
356
357    fn write_property(&self, writer: &mut Writer<Vec<u8>>, property: KnownProperty) -> Result<()> {
358        match property {
359            KnownProperty::Template => write_text(writer, property.tag(), self.template.as_deref()),
360            KnownProperty::Manager => write_text(writer, property.tag(), self.manager.as_deref()),
361            KnownProperty::Company => write_text(writer, property.tag(), self.company.as_deref()),
362            KnownProperty::PresentationFormat => {
363                write_text(writer, property.tag(), self.presentation_format.as_deref())
364            }
365            KnownProperty::Application => {
366                write_text(writer, property.tag(), self.application.as_deref())
367            }
368            KnownProperty::ApplicationVersion => {
369                write_text(writer, property.tag(), self.application_version.as_deref())
370            }
371            KnownProperty::Pages => write_i32(writer, property.tag(), self.pages),
372            KnownProperty::Words => write_i32(writer, property.tag(), self.words),
373            KnownProperty::Characters => write_i32(writer, property.tag(), self.characters),
374            KnownProperty::Lines => write_i32(writer, property.tag(), self.lines),
375            KnownProperty::Paragraphs => write_i32(writer, property.tag(), self.paragraphs),
376            KnownProperty::Slides => write_i32(writer, property.tag(), self.slides),
377            KnownProperty::Notes => write_i32(writer, property.tag(), self.notes),
378            KnownProperty::TotalTime => write_i32(writer, property.tag(), self.total_time),
379            KnownProperty::HiddenSlides => write_i32(writer, property.tag(), self.hidden_slides),
380            KnownProperty::MultimediaClips => {
381                write_i32(writer, property.tag(), self.multimedia_clips)
382            }
383            KnownProperty::CharactersWithSpaces => {
384                write_i32(writer, property.tag(), self.characters_with_spaces)
385            }
386            KnownProperty::ScaleCrop => write_bool(writer, property.tag(), self.scale_crop),
387            KnownProperty::LinksUpToDate => {
388                write_bool(writer, property.tag(), self.links_up_to_date)
389            }
390            KnownProperty::SharedDocument => {
391                write_bool(writer, property.tag(), self.shared_document)
392            }
393            KnownProperty::HyperlinksChanged => {
394                write_bool(writer, property.tag(), self.hyperlinks_changed)
395            }
396        }
397    }
398}
399
400fn parse_i32(property: KnownProperty, text: &str) -> Result<i32> {
401    text.trim().parse().map_err(|_| {
402        OxmlError::InvalidValue(format!(
403            "{} must be an integer, got {text:?}",
404            property.tag()
405        ))
406    })
407}
408
409fn parse_bool(property: KnownProperty, text: &str) -> Result<bool> {
410    match text.trim() {
411        "true" | "1" => Ok(true),
412        "false" | "0" => Ok(false),
413        _ => Err(OxmlError::InvalidValue(format!(
414            "{} must be a Boolean, got {text:?}",
415            property.tag()
416        ))),
417    }
418}
419
420fn write_text(writer: &mut Writer<Vec<u8>>, tag: &str, value: Option<&str>) -> Result<()> {
421    let Some(value) = value else {
422        return Ok(());
423    };
424    writer.write_event(Event::Start(BytesStart::new(tag)))?;
425    writer.write_event(Event::Text(BytesText::new(value)))?;
426    writer.write_event(Event::End(BytesEnd::new(tag)))?;
427    Ok(())
428}
429
430fn write_i32(writer: &mut Writer<Vec<u8>>, tag: &str, value: Option<i32>) -> Result<()> {
431    write_text(
432        writer,
433        tag,
434        value.map(|number| number.to_string()).as_deref(),
435    )
436}
437
438fn write_bool(writer: &mut Writer<Vec<u8>>, tag: &str, value: Option<bool>) -> Result<()> {
439    write_text(writer, tag, value.map(|flag| flag.to_string()).as_deref())
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn word_app_properties_round_trip_without_presentation_fields() {
448        let xml = br#"<?xml version="1.0"?>
449<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">
450  <Template>Normal.dotm</Template><Pages>3</Pages><Words>240</Words>
451  <Characters>1200</Characters><Lines>20</Lines><Paragraphs>8</Paragraphs>
452  <CharactersWithSpaces>1439</CharactersWithSpaces><Application>Word</Application>
453</Properties>"#;
454        let properties = AppProperties::from_xml(xml).unwrap();
455        assert_eq!(properties.pages, Some(3));
456        assert_eq!(properties.words, Some(240));
457        assert_eq!(properties.characters_with_spaces, Some(1439));
458        assert_eq!(properties.presentation_format, None);
459        assert_eq!(properties.slides, None);
460        assert_eq!(properties.notes, None);
461        assert_eq!(properties.hidden_slides, None);
462        assert_eq!(properties.multimedia_clips, None);
463
464        let output = properties.to_xml().unwrap();
465        let output_text = std::str::from_utf8(&output).unwrap();
466        for absent in [
467            "PresentationFormat",
468            "Slides",
469            "Notes",
470            "HiddenSlides",
471            "MMClips",
472        ] {
473            assert!(!output_text.contains(&format!("<{absent}>")));
474        }
475        assert_eq!(AppProperties::from_xml(&output).unwrap(), properties);
476    }
477
478    #[test]
479    fn powerpoint_app_properties_round_trip_without_word_fields() {
480        let xml = br#"<ep:Properties xmlns:ep="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">
481  <ep:PresentationFormat>On-screen Show (16:9)</ep:PresentationFormat>
482  <ep:Slides>12</ep:Slides><ep:Notes>2</ep:Notes><ep:HiddenSlides>1</ep:HiddenSlides>
483  <ep:MMClips>3</ep:MMClips><ep:ScaleCrop>1</ep:ScaleCrop>
484</ep:Properties>"#;
485        let properties = AppProperties::from_xml(xml).unwrap();
486        assert_eq!(
487            properties.presentation_format.as_deref(),
488            Some("On-screen Show (16:9)")
489        );
490        assert_eq!(properties.slides, Some(12));
491        assert_eq!(properties.scale_crop, Some(true));
492        assert_eq!(properties.pages, None);
493        assert_eq!(properties.words, None);
494        assert_eq!(properties.characters, None);
495        assert_eq!(properties.lines, None);
496        assert_eq!(properties.paragraphs, None);
497        assert_eq!(properties.characters_with_spaces, None);
498
499        let output = properties.to_xml().unwrap();
500        let output_text = std::str::from_utf8(&output).unwrap();
501        for absent in ["Pages", "Words", "Characters", "Lines", "Paragraphs"] {
502            assert!(!output_text.contains(&format!("<{absent}>")));
503        }
504        assert_eq!(AppProperties::from_xml(&output).unwrap(), properties);
505    }
506
507    #[test]
508    fn unknown_app_property_subtree_is_preserved_verbatim() {
509        let xml = br#"<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:x="urn:test"><Application>rdocx</Application><x:Future x:id="7"><x:value>one &amp; two</x:value></x:Future><AppVersion>1.0</AppVersion></Properties>"#;
510        let properties = AppProperties::from_xml(xml).unwrap();
511        let output = properties.to_xml().unwrap();
512        let raw = br#"<x:Future x:id="7"><x:value>one &amp; two</x:value></x:Future>"#;
513        assert!(output.windows(raw.len()).any(|window| window == raw));
514        let text = std::str::from_utf8(&output).unwrap();
515        assert!(text.find("<Application>").unwrap() < text.find("<x:Future").unwrap());
516        assert!(text.find("<x:Future").unwrap() < text.find("<AppVersion>").unwrap());
517    }
518
519    #[test]
520    fn newly_constructed_properties_round_trip_as_equal() {
521        let properties = AppProperties {
522            application: Some("rdocx".to_owned()),
523            pages: Some(2),
524            scale_crop: Some(false),
525            ..Default::default()
526        };
527
528        let output = properties.to_xml().unwrap();
529        assert_eq!(AppProperties::from_xml(&output).unwrap(), properties);
530    }
531
532    #[test]
533    fn malformed_app_property_roots_are_rejected() {
534        assert!(AppProperties::from_xml(b"").is_err());
535        assert!(AppProperties::from_xml(b"<Wrong><Pages>1</Pages></Wrong>").is_err());
536        assert!(AppProperties::from_xml(b"<Properties><Pages>1</Pages>").is_err());
537        assert!(AppProperties::from_xml(b"<Properties/><Properties/>").is_err());
538    }
539}