Skip to main content

oxml_opc/
relationship.rs

1//! Parsing and writing of `.rels` relationship files.
2
3use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, Event};
4use quick_xml::{Reader, Writer};
5
6use crate::error::{OpcError, Result};
7
8/// Well-known OOXML relationship types.
9pub mod rel_types {
10    // Package-level relationships.
11    pub const CORE_PROPERTIES: &str =
12        "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties";
13    pub const THUMBNAIL: &str =
14        "http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail";
15
16    // Shared officeDocument relationships.
17    pub const DOCUMENT: &str =
18        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument";
19    pub const EXTENDED_PROPERTIES: &str =
20        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties";
21    pub const CUSTOM_PROPERTIES: &str =
22        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties";
23    pub const STYLES: &str =
24        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles";
25    pub const NUMBERING: &str =
26        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering";
27    pub const HEADER: &str =
28        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
29    pub const FOOTER: &str =
30        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
31    pub const IMAGE: &str =
32        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
33    pub const SETTINGS: &str =
34        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings";
35    pub const FONT_TABLE: &str =
36        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable";
37    pub const THEME: &str =
38        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme";
39    pub const HYPERLINK: &str =
40        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
41    pub const FOOTNOTES: &str =
42        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes";
43    pub const ENDNOTES: &str =
44        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes";
45    pub const CHART: &str =
46        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart";
47    pub const PACKAGE: &str =
48        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package";
49
50    // SpreadsheetML relationships.
51    pub const WORKSHEET: &str =
52        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet";
53    pub const SHARED_STRINGS: &str =
54        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings";
55
56    // PresentationML relationships.
57    pub const SLIDE: &str =
58        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide";
59    pub const SLIDE_LAYOUT: &str =
60        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout";
61    pub const SLIDE_MASTER: &str =
62        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster";
63    pub const NOTES_SLIDE: &str =
64        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide";
65    pub const NOTES_MASTER: &str =
66        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster";
67    pub const PRES_PROPS: &str =
68        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/presProps";
69    pub const VIEW_PROPS: &str =
70        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/viewProps";
71    pub const TABLE_STYLES: &str =
72        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableStyles";
73    pub const HANDOUT_MASTER: &str =
74        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/handoutMaster";
75}
76
77/// A single relationship entry.
78#[derive(Debug, Clone, PartialEq)]
79pub struct Relationship {
80    pub id: String,
81    pub rel_type: String,
82    pub target: String,
83    pub target_mode: Option<String>,
84}
85
86/// A collection of relationships parsed from a `.rels` file.
87#[derive(Debug, Clone, Default)]
88pub struct Relationships {
89    pub items: Vec<Relationship>,
90    next_id: u32,
91}
92
93impl Relationships {
94    pub fn new() -> Self {
95        Self {
96            items: Vec::new(),
97            next_id: 1,
98        }
99    }
100
101    /// Parse from XML bytes.
102    pub fn from_xml(xml: &[u8]) -> Result<Self> {
103        let mut reader = Reader::from_reader(xml);
104        reader.config_mut().trim_text(true);
105
106        let mut items = Vec::new();
107        let mut max_id: u32 = 0;
108        let mut buf = Vec::new();
109
110        loop {
111            match reader.read_event_into(&mut buf) {
112                Ok(Event::Empty(ref e)) if e.name().as_ref() == b"Relationship" => {
113                    let mut id = None;
114                    let mut rel_type = None;
115                    let mut target = None;
116                    let mut target_mode = None;
117
118                    for attr in e.attributes() {
119                        let attr = attr?;
120                        match attr.key.as_ref() {
121                            b"Id" => {
122                                let val = std::str::from_utf8(&attr.value)?.to_string();
123                                // Extract numeric suffix for next_id tracking
124                                if let Some(num_str) = val.strip_prefix("rId")
125                                    && let Ok(n) = num_str.parse::<u32>()
126                                {
127                                    max_id = max_id.max(n);
128                                }
129                                id = Some(val);
130                            }
131                            b"Type" => {
132                                rel_type = Some(std::str::from_utf8(&attr.value)?.to_string());
133                            }
134                            b"Target" => {
135                                target = Some(std::str::from_utf8(&attr.value)?.to_string());
136                            }
137                            b"TargetMode" => {
138                                target_mode = Some(std::str::from_utf8(&attr.value)?.to_string());
139                            }
140                            _ => {}
141                        }
142                    }
143
144                    match (id, rel_type, target) {
145                        (Some(id), Some(rel_type), Some(target)) => {
146                            items.push(Relationship {
147                                id,
148                                rel_type,
149                                target,
150                                target_mode,
151                            });
152                        }
153                        _ => return Err(OpcError::InvalidRelationship),
154                    }
155                }
156                Ok(Event::Eof) => break,
157                Err(e) => return Err(e.into()),
158                _ => {}
159            }
160            buf.clear();
161        }
162
163        Ok(Relationships {
164            items,
165            next_id: next_relationship_number(max_id),
166        })
167    }
168
169    /// Serialize to XML bytes.
170    pub fn to_xml(&self) -> Result<Vec<u8>> {
171        let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2);
172
173        writer.write_event(Event::Decl(BytesDecl::new(
174            "1.0",
175            Some("UTF-8"),
176            Some("yes"),
177        )))?;
178
179        let mut rels_start = BytesStart::new("Relationships");
180        rels_start.push_attribute((
181            "xmlns",
182            "http://schemas.openxmlformats.org/package/2006/relationships",
183        ));
184        writer.write_event(Event::Start(rels_start))?;
185
186        for rel in &self.items {
187            let mut elem = BytesStart::new("Relationship");
188            elem.push_attribute(("Id", rel.id.as_str()));
189            elem.push_attribute(("Type", rel.rel_type.as_str()));
190            elem.push_attribute(("Target", rel.target.as_str()));
191            if let Some(ref mode) = rel.target_mode {
192                elem.push_attribute(("TargetMode", mode.as_str()));
193            }
194            writer.write_event(Event::Empty(elem))?;
195        }
196
197        writer.write_event(Event::End(BytesEnd::new("Relationships")))?;
198
199        Ok(writer.into_inner())
200    }
201
202    /// Find a relationship by its ID.
203    pub fn get_by_id(&self, id: &str) -> Option<&Relationship> {
204        self.items.iter().find(|r| r.id == id)
205    }
206
207    /// Find the first relationship matching a given type.
208    pub fn get_by_type(&self, rel_type: &str) -> Option<&Relationship> {
209        self.items.iter().find(|r| r.rel_type == rel_type)
210    }
211
212    /// Find all relationships matching a given type.
213    pub fn get_all_by_type(&self, rel_type: &str) -> Vec<&Relationship> {
214        self.items
215            .iter()
216            .filter(|r| r.rel_type == rel_type)
217            .collect()
218    }
219
220    /// Add a new relationship and return its generated ID.
221    pub fn add(&mut self, rel_type: &str, target: &str) -> String {
222        let id = self.allocate_id();
223        self.items.push(Relationship {
224            id: id.clone(),
225            rel_type: rel_type.to_string(),
226            target: target.to_string(),
227            target_mode: None,
228        });
229        id
230    }
231
232    /// Add an externally-targeted relationship (e.g. a hyperlink URL) and
233    /// return its generated ID.
234    pub fn add_external(&mut self, rel_type: &str, target: &str) -> String {
235        let id = self.allocate_id();
236        self.items.push(Relationship {
237            id: id.clone(),
238            rel_type: rel_type.to_string(),
239            target: target.to_string(),
240            target_mode: Some("External".to_string()),
241        });
242        id
243    }
244
245    /// Add a relationship with a specific ID.
246    ///
247    /// If a relationship with this ID already exists, it is replaced.
248    /// The `next_id` counter is updated to avoid future collisions.
249    pub fn add_with_id(&mut self, id: &str, rel_type: &str, target: &str) {
250        self.items.retain(|r| r.id != id);
251        self.items.push(Relationship {
252            id: id.to_string(),
253            rel_type: rel_type.to_string(),
254            target: target.to_string(),
255            target_mode: None,
256        });
257        if let Some(num) = id.strip_prefix("rId").and_then(|s| s.parse::<u32>().ok())
258            && num >= self.next_id
259        {
260            self.next_id = next_relationship_number(num);
261        }
262    }
263
264    fn allocate_id(&mut self) -> String {
265        let mut candidate = self.next_id;
266        let numeric_space = usize::try_from(u32::MAX).unwrap_or(usize::MAX);
267        let attempts = self.items.len().saturating_add(1).min(numeric_space);
268        for _ in 0..attempts {
269            let id = format!("rId{candidate}");
270            let next = next_relationship_number(candidate);
271            if self.items.iter().all(|relationship| relationship.id != id) {
272                self.next_id = next;
273                return id;
274            }
275            candidate = next;
276        }
277
278        for ordinal in 1u128.. {
279            let id = format!("rIdGenerated{ordinal}");
280            if self.items.iter().all(|relationship| relationship.id != id) {
281                return id;
282            }
283        }
284        unreachable!("the generated relationship id space is unbounded")
285    }
286}
287
288fn next_relationship_number(current: u32) -> u32 {
289    current.checked_add(1).unwrap_or(1).max(1)
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn relationship_and_content_type_constants_are_unique_and_well_formed() {
298        const PACKAGE_PREFIX: &str =
299            "http://schemas.openxmlformats.org/package/2006/relationships/";
300        const OFFICE_PREFIX: &str =
301            "http://schemas.openxmlformats.org/officeDocument/2006/relationships/";
302
303        let package_relationships = [rel_types::CORE_PROPERTIES, rel_types::THUMBNAIL];
304        let office_relationships = [
305            rel_types::DOCUMENT,
306            rel_types::STYLES,
307            rel_types::NUMBERING,
308            rel_types::HEADER,
309            rel_types::FOOTER,
310            rel_types::IMAGE,
311            rel_types::SETTINGS,
312            rel_types::FONT_TABLE,
313            rel_types::THEME,
314            rel_types::HYPERLINK,
315            rel_types::FOOTNOTES,
316            rel_types::ENDNOTES,
317            rel_types::CHART,
318            rel_types::PACKAGE,
319            rel_types::WORKSHEET,
320            rel_types::SHARED_STRINGS,
321            rel_types::EXTENDED_PROPERTIES,
322            rel_types::CUSTOM_PROPERTIES,
323            rel_types::SLIDE,
324            rel_types::SLIDE_LAYOUT,
325            rel_types::SLIDE_MASTER,
326            rel_types::NOTES_SLIDE,
327            rel_types::NOTES_MASTER,
328            rel_types::PRES_PROPS,
329            rel_types::VIEW_PROPS,
330            rel_types::TABLE_STYLES,
331            rel_types::HANDOUT_MASTER,
332        ];
333
334        let mut relationship_values = std::collections::HashSet::new();
335        for value in package_relationships {
336            assert!(value.starts_with(PACKAGE_PREFIX));
337            assert!(!value.chars().any(char::is_whitespace));
338            assert!(relationship_values.insert(value));
339        }
340        for value in office_relationships {
341            assert!(value.starts_with(OFFICE_PREFIX));
342            assert!(!value.chars().any(char::is_whitespace));
343            assert!(relationship_values.insert(value));
344        }
345
346        let content_type_values = [
347            crate::content_types::RELATIONSHIPS,
348            crate::content_types::XML,
349            crate::content_types::CORE_PROPERTIES,
350            crate::content_types::EXTENDED_PROPERTIES,
351            crate::content_types::CUSTOM_PROPERTIES,
352            crate::content_types::THEME,
353            crate::content_types::CHART,
354            crate::content_types::PRESENTATION,
355            crate::content_types::SLIDESHOW,
356            crate::content_types::SLIDE,
357            crate::content_types::SLIDE_LAYOUT,
358            crate::content_types::SLIDE_MASTER,
359            crate::content_types::NOTES_SLIDE,
360            crate::content_types::NOTES_MASTER,
361            crate::content_types::PRES_PROPS,
362            crate::content_types::VIEW_PROPS,
363            crate::content_types::TABLE_STYLES,
364            crate::content_types::HANDOUT_MASTER,
365            crate::content_types::WORKBOOK,
366            crate::content_types::EMBEDDED_WORKBOOK,
367            crate::content_types::WORKSHEET,
368            crate::content_types::SHARED_STRINGS,
369            crate::content_types::STYLES,
370        ];
371
372        let mut content_types = std::collections::HashSet::new();
373        for value in content_type_values {
374            let (kind, subtype) = value.split_once('/').expect("valid MIME type");
375            assert_eq!(kind, "application");
376            assert!(!subtype.is_empty());
377            assert!(!subtype.contains('/'));
378            assert!(!value.chars().any(char::is_whitespace));
379            assert!(content_types.insert(value));
380        }
381    }
382
383    #[test]
384    fn round_trip_relationships() {
385        let mut rels = Relationships::new();
386        rels.add(rel_types::DOCUMENT, "word/document.xml");
387        rels.add(rel_types::STYLES, "word/styles.xml");
388
389        let xml = rels.to_xml().unwrap();
390        let parsed = Relationships::from_xml(&xml).unwrap();
391
392        assert_eq!(parsed.items.len(), 2);
393        assert_eq!(parsed.items[0].id, "rId1");
394        assert_eq!(parsed.items[0].target, "word/document.xml");
395        assert_eq!(parsed.items[1].id, "rId2");
396    }
397
398    #[test]
399    fn find_by_type() {
400        let mut rels = Relationships::new();
401        rels.add(rel_types::DOCUMENT, "word/document.xml");
402        rels.add(rel_types::STYLES, "word/styles.xml");
403
404        let doc = rels.get_by_type(rel_types::DOCUMENT).unwrap();
405        assert_eq!(doc.target, "word/document.xml");
406    }
407
408    #[test]
409    fn high_numeric_relationship_ids_roll_over_without_collision() {
410        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
411<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
412  <Relationship Id="rId4294967294" Type="type-a" Target="a.xml"/>
413</Relationships>"#;
414        let mut relationships = Relationships::from_xml(xml).unwrap();
415        assert_eq!(relationships.add("type-b", "b.xml"), "rId4294967295");
416        assert_eq!(relationships.add("type-c", "c.xml"), "rId1");
417        assert_eq!(
418            relationships.add_external("type-d", "https://example.com"),
419            "rId2"
420        );
421        let ids = relationships
422            .items
423            .iter()
424            .map(|relationship| relationship.id.as_str())
425            .collect::<std::collections::HashSet<_>>();
426        assert_eq!(ids.len(), relationships.items.len());
427        assert!(!ids.contains("rId0"));
428    }
429
430    #[test]
431    fn parsed_u32_max_relationship_id_rolls_to_first_free_positive_id() {
432        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
433<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
434  <Relationship Id="rId4294967295" Type="type-max" Target="max.xml"/>
435  <Relationship Id="rId1" Type="type-one" Target="one.xml"/>
436</Relationships>"#;
437        let mut relationships = Relationships::from_xml(xml).unwrap();
438        assert_eq!(relationships.add("type-two", "two.xml"), "rId2");
439        relationships.add_with_id("rId4294967295", "type-max", "replacement.xml");
440        assert_eq!(relationships.add("type-three", "three.xml"), "rId3");
441    }
442}