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