Skip to main content

oxml_core/
custom_properties.rs

1//! Custom properties from `docProps/custom.xml`.
2
3use std::io::Write;
4
5use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
6use quick_xml::name::{Namespace, ResolveResult};
7use quick_xml::reader::NsReader;
8use quick_xml::{Reader, Writer, XmlVersion};
9
10use crate::error::{OxmlError, Result};
11use crate::raw_xml::{capture_element, capture_empty_element};
12use crate::xml::{extra_namespace_declarations, local_name};
13use crate::xml_text::read_element_text;
14
15const CUSTOM_PROPERTIES_NS: &str =
16    "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties";
17const VARIANT_TYPES_NS: &str =
18    "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes";
19
20/// A typed value stored in a custom document property.
21#[derive(Debug, Clone, PartialEq)]
22pub enum CustomPropertyValue {
23    /// An ANSI string (`vt:lpstr`).
24    Lpstr(String),
25    /// A Unicode string (`vt:lpwstr`).
26    Lpwstr(String),
27    /// A signed 32-bit integer (`vt:i4`).
28    I4(i32),
29    /// A 64-bit floating-point number (`vt:r8`).
30    R8(f64),
31    /// A Boolean (`vt:bool`).
32    Bool(bool),
33    /// An ISO 8601 file time (`vt:filetime`).
34    FileTime(String),
35    /// An explicitly empty value (`vt:empty`).
36    Empty,
37    /// An unsupported `vt:*` value preserved as its complete XML subtree.
38    Raw(Vec<u8>),
39}
40
41/// One property in `docProps/custom.xml`.
42#[derive(Debug, Clone, PartialEq)]
43pub struct CustomProperty {
44    pub fmtid: String,
45    pub pid: i32,
46    pub name: Option<String>,
47    pub value: CustomPropertyValue,
48}
49
50/// The ordered custom-property collection.
51#[derive(Debug, Clone, Default, PartialEq)]
52pub struct CustomProperties {
53    pub properties: Vec<CustomProperty>,
54    extra_namespaces: Vec<(String, String)>,
55}
56
57impl CustomProperties {
58    /// Parse a `docProps/custom.xml` part.
59    pub fn from_xml(xml: &[u8]) -> Result<Self> {
60        if !custom_property_namespaces_are_valid(xml) {
61            return Err(OxmlError::UnexpectedElement(
62                "custom properties namespace".to_owned(),
63            ));
64        }
65        let mut reader = Reader::from_reader(xml);
66        let mut properties = Self::default();
67        let mut root_open = false;
68        let mut root_closed = false;
69        let mut buf = Vec::new();
70
71        loop {
72            match reader.read_event_into(&mut buf) {
73                Ok(Event::Start(ref element)) => {
74                    let qualified_name = element.name();
75                    let name = local_name(qualified_name.as_ref());
76                    if name == b"Properties" {
77                        if root_open || root_closed {
78                            return Err(OxmlError::UnexpectedElement("Properties".to_owned()));
79                        }
80                        root_open = true;
81                        properties
82                            .extra_namespaces
83                            .extend(extra_namespace_declarations(element)?);
84                    } else if !root_open {
85                        return Err(OxmlError::UnexpectedElement(
86                            String::from_utf8_lossy(name).into_owned(),
87                        ));
88                    } else if name == b"property" {
89                        properties
90                            .properties
91                            .push(parse_property(&mut reader, element)?);
92                    } else {
93                        return Err(OxmlError::UnexpectedElement(
94                            String::from_utf8_lossy(name).into_owned(),
95                        ));
96                    }
97                }
98                Ok(Event::Empty(ref element)) => {
99                    let qualified_name = element.name();
100                    let name = local_name(qualified_name.as_ref());
101                    if name == b"Properties" {
102                        if root_open || root_closed {
103                            return Err(OxmlError::UnexpectedElement("Properties".to_owned()));
104                        }
105                        root_closed = true;
106                    } else if !root_open {
107                        return Err(OxmlError::UnexpectedElement(
108                            String::from_utf8_lossy(name).into_owned(),
109                        ));
110                    } else if name == b"property" {
111                        return Err(OxmlError::MissingElement(
112                            "custom property value".to_owned(),
113                        ));
114                    } else {
115                        return Err(OxmlError::UnexpectedElement(
116                            String::from_utf8_lossy(name).into_owned(),
117                        ));
118                    }
119                }
120                Ok(Event::End(ref element))
121                    if local_name(element.name().as_ref()) == b"Properties" =>
122                {
123                    if !root_open {
124                        return Err(OxmlError::UnexpectedElement("Properties".to_owned()));
125                    }
126                    root_open = false;
127                    root_closed = true;
128                }
129                Ok(Event::Eof) => break,
130                Err(error) => return Err(error.into()),
131                _ => {}
132            }
133            buf.clear();
134        }
135
136        if root_closed {
137            Ok(properties)
138        } else {
139            Err(OxmlError::MissingElement("Properties root".to_owned()))
140        }
141    }
142
143    /// Serialize a `docProps/custom.xml` part.
144    pub fn to_xml(&self) -> Result<Vec<u8>> {
145        let mut writer = Writer::new(Vec::new());
146        writer.write_event(Event::Decl(BytesDecl::new(
147            "1.0",
148            Some("UTF-8"),
149            Some("yes"),
150        )))?;
151
152        let mut root = BytesStart::new("Properties");
153        root.push_attribute(("xmlns", CUSTOM_PROPERTIES_NS));
154        root.push_attribute(("xmlns:vt", VARIANT_TYPES_NS));
155        for (name, value) in &self.extra_namespaces {
156            root.push_attribute((name.as_str(), value.as_str()));
157        }
158        writer.write_event(Event::Start(root))?;
159
160        for property in &self.properties {
161            write_property(&mut writer, property)?;
162        }
163
164        writer.write_event(Event::End(BytesEnd::new("Properties")))?;
165        Ok(writer.into_inner())
166    }
167}
168
169fn custom_property_namespaces_are_valid(xml: &[u8]) -> bool {
170    let mut reader = NsReader::from_reader(xml);
171    let mut buffer = Vec::new();
172    let mut depth = 0usize;
173    let mut saw_root = false;
174    loop {
175        let Ok((namespace, event)) = reader.read_resolved_event_into(&mut buffer) else {
176            return false;
177        };
178        let is_start = matches!(event, Event::Start(_));
179        if let Event::Start(ref element) | Event::Empty(ref element) = event {
180            let valid = match depth {
181                0 => {
182                    matches!(namespace, ResolveResult::Bound(Namespace(uri)) if uri == CUSTOM_PROPERTIES_NS.as_bytes())
183                        && local_name(element.name().as_ref()) == b"Properties"
184                }
185                1 => {
186                    matches!(namespace, ResolveResult::Bound(Namespace(uri)) if uri == CUSTOM_PROPERTIES_NS.as_bytes())
187                        && local_name(element.name().as_ref()) == b"property"
188                        && property_attributes_are_unqualified(&reader, element)
189                }
190                2 => {
191                    matches!(namespace, ResolveResult::Bound(Namespace(uri)) if uri == VARIANT_TYPES_NS.as_bytes())
192                }
193                _ => true,
194            };
195            if !valid {
196                return false;
197            }
198            saw_root = true;
199            if is_start {
200                let Some(next_depth) = depth.checked_add(1) else {
201                    return false;
202                };
203                depth = next_depth;
204            }
205        } else if matches!(event, Event::End(_)) {
206            depth = depth.saturating_sub(1);
207        } else if matches!(event, Event::Eof) {
208            return saw_root && depth == 0;
209        }
210        buffer.clear();
211    }
212}
213
214fn property_attributes_are_unqualified(reader: &NsReader<&[u8]>, element: &BytesStart<'_>) -> bool {
215    element.attributes().all(|attribute| {
216        let Ok(attribute) = attribute else {
217            return false;
218        };
219        let name = attribute.key.as_ref();
220        (name == b"xmlns" || name.starts_with(b"xmlns:"))
221            || matches!(
222                reader.resolver().resolve_attribute(attribute.key).0,
223                ResolveResult::Unbound
224            )
225    })
226}
227
228fn parse_property(reader: &mut Reader<&[u8]>, start: &BytesStart<'_>) -> Result<CustomProperty> {
229    let fmtid = required_attribute(start, b"fmtid")?;
230    let pid_text = required_attribute(start, b"pid")?;
231    let pid = pid_text.parse().map_err(|_| {
232        OxmlError::InvalidValue(format!(
233            "custom property pid must be an integer, got {pid_text:?}"
234        ))
235    })?;
236    let name = optional_attribute(start, b"name")?;
237    let mut value = None;
238    let mut buf = Vec::new();
239
240    loop {
241        match reader.read_event_into(&mut buf) {
242            Ok(Event::Start(ref element)) => {
243                if value.is_some() {
244                    return Err(OxmlError::InvalidValue(
245                        "custom property has more than one value".to_owned(),
246                    ));
247                }
248                value = Some(parse_value_element(reader, element)?);
249            }
250            Ok(Event::Empty(ref element)) => {
251                if value.is_some() {
252                    return Err(OxmlError::InvalidValue(
253                        "custom property has more than one value".to_owned(),
254                    ));
255                }
256                value = Some(parse_empty_value(element)?);
257            }
258            Ok(Event::End(ref element)) if local_name(element.name().as_ref()) == b"property" => {
259                break;
260            }
261            Ok(Event::Eof) => break,
262            Err(error) => return Err(error.into()),
263            _ => {}
264        }
265        buf.clear();
266    }
267
268    Ok(CustomProperty {
269        fmtid,
270        pid,
271        name,
272        value: value
273            .ok_or_else(|| OxmlError::MissingElement("custom property value".to_owned()))?,
274    })
275}
276
277fn parse_value_element(
278    reader: &mut Reader<&[u8]>,
279    element: &BytesStart<'_>,
280) -> Result<CustomPropertyValue> {
281    let qualified_name = element.name();
282    let name = local_name(qualified_name.as_ref());
283    match name {
284        b"lpstr" => Ok(CustomPropertyValue::Lpstr(read_element_text(
285            reader,
286            element.name(),
287        ))),
288        b"lpwstr" => Ok(CustomPropertyValue::Lpwstr(read_element_text(
289            reader,
290            element.name(),
291        ))),
292        b"i4" => {
293            let text = read_element_text(reader, element.name());
294            let value = text.trim().parse().map_err(|_| {
295                OxmlError::InvalidValue(format!("vt:i4 must be an integer, got {text:?}"))
296            })?;
297            Ok(CustomPropertyValue::I4(value))
298        }
299        b"r8" => {
300            let text = read_element_text(reader, element.name());
301            Ok(CustomPropertyValue::R8(parse_r8(&text)?))
302        }
303        b"bool" => {
304            let text = read_element_text(reader, element.name());
305            Ok(CustomPropertyValue::Bool(parse_bool(&text)?))
306        }
307        b"filetime" => Ok(CustomPropertyValue::FileTime(read_element_text(
308            reader,
309            element.name(),
310        ))),
311        b"empty" => {
312            reader.read_to_end_into(element.name(), &mut Vec::new())?;
313            Ok(CustomPropertyValue::Empty)
314        }
315        _ => Ok(CustomPropertyValue::Raw(capture_element(reader, element)?)),
316    }
317}
318
319fn parse_empty_value(element: &BytesStart<'_>) -> Result<CustomPropertyValue> {
320    match local_name(element.name().as_ref()) {
321        b"lpstr" => Ok(CustomPropertyValue::Lpstr(String::new())),
322        b"lpwstr" => Ok(CustomPropertyValue::Lpwstr(String::new())),
323        b"i4" => Err(OxmlError::InvalidValue(
324            "vt:i4 must be an integer, got an empty value".to_owned(),
325        )),
326        b"r8" => Err(OxmlError::InvalidValue(
327            "vt:r8 must be a number, got an empty value".to_owned(),
328        )),
329        b"bool" => Err(OxmlError::InvalidValue(
330            "vt:bool must be a Boolean, got an empty value".to_owned(),
331        )),
332        b"filetime" => Ok(CustomPropertyValue::FileTime(String::new())),
333        b"empty" => Ok(CustomPropertyValue::Empty),
334        _ => Ok(CustomPropertyValue::Raw(capture_empty_element(element)?)),
335    }
336}
337
338fn optional_attribute(element: &BytesStart<'_>, expected: &[u8]) -> Result<Option<String>> {
339    for attribute in element.attributes() {
340        let attribute = attribute?;
341        if local_name(attribute.key.as_ref()) == expected {
342            return Ok(Some(
343                attribute
344                    .decoded_and_normalized_value(XmlVersion::Implicit1_0, element.decoder())?
345                    .into_owned(),
346            ));
347        }
348    }
349    Ok(None)
350}
351
352fn required_attribute(element: &BytesStart<'_>, expected: &[u8]) -> Result<String> {
353    optional_attribute(element, expected)?.ok_or_else(|| {
354        OxmlError::MissingElement(format!(
355            "custom property {} attribute",
356            String::from_utf8_lossy(expected)
357        ))
358    })
359}
360
361fn parse_bool(text: &str) -> Result<bool> {
362    match text.trim() {
363        "true" | "1" => Ok(true),
364        "false" | "0" => Ok(false),
365        _ => Err(OxmlError::InvalidValue(format!(
366            "vt:bool must be a Boolean, got {text:?}"
367        ))),
368    }
369}
370
371fn parse_r8(text: &str) -> Result<f64> {
372    match text.trim() {
373        "INF" => Ok(f64::INFINITY),
374        "-INF" => Ok(f64::NEG_INFINITY),
375        "NaN" => Ok(f64::NAN),
376        value => value
377            .parse()
378            .map_err(|_| OxmlError::InvalidValue(format!("vt:r8 must be a number, got {text:?}"))),
379    }
380}
381
382fn write_property(writer: &mut Writer<Vec<u8>>, property: &CustomProperty) -> Result<()> {
383    let mut start = BytesStart::new("property");
384    start.push_attribute(("fmtid", property.fmtid.as_str()));
385    let pid = property.pid.to_string();
386    start.push_attribute(("pid", pid.as_str()));
387    if let Some(name) = &property.name {
388        start.push_attribute(("name", name.as_str()));
389    }
390    writer.write_event(Event::Start(start))?;
391    write_value(writer, &property.value)?;
392    writer.write_event(Event::End(BytesEnd::new("property")))?;
393    Ok(())
394}
395
396fn write_value(writer: &mut Writer<Vec<u8>>, value: &CustomPropertyValue) -> Result<()> {
397    match value {
398        CustomPropertyValue::Lpstr(value) => write_text(writer, "vt:lpstr", value),
399        CustomPropertyValue::Lpwstr(value) => write_text(writer, "vt:lpwstr", value),
400        CustomPropertyValue::I4(value) => write_text(writer, "vt:i4", &value.to_string()),
401        CustomPropertyValue::R8(value) => write_text(writer, "vt:r8", r8_text(*value).as_ref()),
402        CustomPropertyValue::Bool(value) => write_text(writer, "vt:bool", &value.to_string()),
403        CustomPropertyValue::FileTime(value) => write_text(writer, "vt:filetime", value),
404        CustomPropertyValue::Empty => {
405            writer.write_event(Event::Empty(BytesStart::new("vt:empty")))?;
406            Ok(())
407        }
408        CustomPropertyValue::Raw(raw) => {
409            writer.get_mut().write_all(raw)?;
410            Ok(())
411        }
412    }
413}
414
415fn r8_text(value: f64) -> String {
416    if value.is_nan() {
417        "NaN".to_owned()
418    } else if value == f64::INFINITY {
419        "INF".to_owned()
420    } else if value == f64::NEG_INFINITY {
421        "-INF".to_owned()
422    } else {
423        value.to_string()
424    }
425}
426
427fn write_text(writer: &mut Writer<Vec<u8>>, tag: &str, value: &str) -> Result<()> {
428    writer.write_event(Event::Start(BytesStart::new(tag)))?;
429    writer.write_event(Event::Text(BytesText::new(value)))?;
430    writer.write_event(Event::End(BytesEnd::new(tag)))?;
431    Ok(())
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    const FMTID: &str = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}";
439
440    #[test]
441    fn custom_property_value_types_round_trip() {
442        let xml = format!(
443            r#"<Properties xmlns="{CUSTOM_PROPERTIES_NS}" xmlns:vt="{VARIANT_TYPES_NS}">
444<property fmtid="{FMTID}" pid="2" name="Wide &amp; text"><vt:lpwstr>hello &amp; goodbye</vt:lpwstr></property>
445<property fmtid="{FMTID}" pid="3" name="Narrow"><vt:lpstr>plain</vt:lpstr></property>
446<property fmtid="{FMTID}" pid="4" name="Count"><vt:i4>-42</vt:i4></property>
447<property fmtid="{FMTID}" pid="5" name="Ratio"><vt:r8>1.25</vt:r8></property>
448<property fmtid="{FMTID}" pid="6" name="Flag"><vt:bool>1</vt:bool></property>
449<property fmtid="{FMTID}" pid="7" name="When"><vt:filetime>2026-07-30T08:15:00Z</vt:filetime></property>
450<property fmtid="{FMTID}" pid="8" name="Nothing"><vt:empty/></property>
451</Properties>"#
452        );
453        let properties = CustomProperties::from_xml(xml.as_bytes()).unwrap();
454        assert_eq!(properties.properties.len(), 7);
455        assert_eq!(
456            properties.properties[0].name.as_deref(),
457            Some("Wide & text")
458        );
459        assert_eq!(
460            properties.properties[0].value,
461            CustomPropertyValue::Lpwstr("hello & goodbye".to_owned())
462        );
463        assert_eq!(properties.properties[2].value, CustomPropertyValue::I4(-42));
464        assert_eq!(
465            properties.properties[4].value,
466            CustomPropertyValue::Bool(true)
467        );
468        assert_eq!(properties.properties[6].value, CustomPropertyValue::Empty);
469
470        let output = properties.to_xml().unwrap();
471        assert_eq!(CustomProperties::from_xml(&output).unwrap(), properties);
472    }
473
474    #[test]
475    fn unknown_custom_property_value_is_preserved_verbatim() {
476        let xml = format!(
477            r#"<Properties xmlns="{CUSTOM_PROPERTIES_NS}" xmlns:v="{VARIANT_TYPES_NS}"><property fmtid="{FMTID}" pid="2" name="Unsigned"><v:ui4>4294967295</v:ui4></property></Properties>"#
478        );
479        let properties = CustomProperties::from_xml(xml.as_bytes()).unwrap();
480        let expected = br#"<v:ui4>4294967295</v:ui4>"#;
481        assert_eq!(
482            properties.properties[0].value,
483            CustomPropertyValue::Raw(expected.to_vec())
484        );
485        let output = properties.to_xml().unwrap();
486        assert!(
487            output
488                .windows(expected.len())
489                .any(|window| window == expected)
490        );
491        assert!(
492            std::str::from_utf8(&output)
493                .unwrap()
494                .contains(&format!(r#"xmlns:v="{VARIANT_TYPES_NS}""#))
495        );
496        assert_eq!(CustomProperties::from_xml(&output).unwrap(), properties);
497    }
498
499    #[test]
500    fn malformed_custom_properties_are_rejected() {
501        assert!(CustomProperties::from_xml(b"").is_err());
502        assert!(CustomProperties::from_xml(b"<Wrong/>").is_err());
503        assert!(CustomProperties::from_xml(b"<Properties>").is_err());
504
505        let missing_pid = format!(
506            r#"<Properties xmlns="{CUSTOM_PROPERTIES_NS}" xmlns:vt="{VARIANT_TYPES_NS}"><property fmtid="{FMTID}"><vt:i4>1</vt:i4></property></Properties>"#
507        );
508        assert!(CustomProperties::from_xml(missing_pid.as_bytes()).is_err());
509
510        let two_values = format!(
511            r#"<Properties xmlns="{CUSTOM_PROPERTIES_NS}" xmlns:vt="{VARIANT_TYPES_NS}"><property fmtid="{FMTID}" pid="2"><vt:i4>1</vt:i4><vt:i4>2</vt:i4></property></Properties>"#
512        );
513        assert!(CustomProperties::from_xml(two_values.as_bytes()).is_err());
514
515        for value in ["<vt:i4/>", "<vt:r8/>", "<vt:bool/>"] {
516            let empty_typed_value = format!(
517                r#"<Properties xmlns="{CUSTOM_PROPERTIES_NS}" xmlns:vt="{VARIANT_TYPES_NS}"><property fmtid="{FMTID}" pid="2">{value}</property></Properties>"#
518            );
519            assert!(CustomProperties::from_xml(empty_typed_value.as_bytes()).is_err());
520        }
521    }
522
523    #[test]
524    fn custom_property_projection_requires_expanded_names() {
525        let aliased = format!(
526            r#"<p:Properties xmlns:p="{CUSTOM_PROPERTIES_NS}" xmlns:v="{VARIANT_TYPES_NS}"><p:property fmtid="{FMTID}" pid="2" name="Tier"><v:lpwstr>Gold</v:lpwstr></p:property></p:Properties>"#
527        );
528        assert_eq!(
529            CustomProperties::from_xml(aliased.as_bytes())
530                .unwrap()
531                .properties[0]
532                .value,
533            CustomPropertyValue::Lpwstr("Gold".to_owned())
534        );
535        for foreign in [
536            r#"<x:Properties xmlns:x="urn:foreign"/>"#.to_owned(),
537            format!(
538                r#"<p:Properties xmlns:p="{CUSTOM_PROPERTIES_NS}" xmlns:v="{VARIANT_TYPES_NS}" xmlns:x="urn:foreign"><x:property fmtid="{FMTID}" pid="2"><v:i4>1</v:i4></x:property></p:Properties>"#
539            ),
540            format!(
541                r#"<p:Properties xmlns:p="{CUSTOM_PROPERTIES_NS}" xmlns:v="{VARIANT_TYPES_NS}" xmlns:x="urn:foreign"><p:property fmtid="{FMTID}" pid="2"><x:i4>1</x:i4></p:property></p:Properties>"#
542            ),
543            format!(
544                r#"<p:Properties xmlns:p="{CUSTOM_PROPERTIES_NS}" xmlns:v="{VARIANT_TYPES_NS}" xmlns:x="urn:foreign"><p:property x:fmtid="{FMTID}" x:pid="2"><v:i4>1</v:i4></p:property></p:Properties>"#
545            ),
546        ] {
547            assert!(CustomProperties::from_xml(foreign.as_bytes()).is_err());
548        }
549    }
550}