1use 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#[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 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 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 & 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 & 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}