Skip to main content

pptx_parse/
relationships.rs

1use serde::{Deserialize, Serialize};
2
3use crate::xml::{ParseBudget, parse_xml};
4use crate::{PptxError, xml::XmlElement};
5
6pub mod relationship_types {
7    pub const SLIDE: &str = "/slide";
8    pub const SLIDE_LAYOUT: &str = "/slideLayout";
9    pub const SLIDE_MASTER: &str = "/slideMaster";
10    pub const THEME: &str = "/theme";
11    pub const IMAGE: &str = "/image";
12    pub const CHART: &str = "/chart";
13    pub const NOTES_SLIDE: &str =
14        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide";
15    pub const TABLE_STYLES: &str = "/tableStyles";
16
17    pub const COMMENTS: &str =
18        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
19    pub const COMMENT_AUTHORS: &str =
20        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/commentAuthors";
21    pub const MODERN_COMMENTS: &str =
22        "http://schemas.microsoft.com/office/2018/10/relationships/comments";
23    pub const MODERN_AUTHORS: &str =
24        "http://schemas.microsoft.com/office/2018/10/relationships/authors";
25}
26
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub enum TargetMode {
30    #[default]
31    Internal,
32    External,
33}
34
35#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub struct Relationship {
38    pub id: String,
39    pub relationship_type: String,
40    pub target: String,
41    pub target_mode: TargetMode,
42    pub resolved_target: Option<String>,
43}
44
45impl Relationship {
46    pub fn has_type(&self, suffix: &str) -> bool {
47        self.relationship_type.ends_with(suffix)
48    }
49
50    pub fn is_type(&self, relationship_type: &str) -> bool {
51        self.relationship_type == relationship_type
52    }
53}
54
55pub(crate) fn parse_relationships(
56    xml: &[u8],
57    relationship_part: &str,
58    source: &str,
59    budget: &mut ParseBudget<'_>,
60) -> Result<Vec<Relationship>, PptxError> {
61    let root = parse_xml(xml, relationship_part, budget)?;
62    let mut relationships = Vec::new();
63    for element in root.children_named("Relationship") {
64        budget.charge_relationship(relationship_part)?;
65        relationships.push(parse_relationship(element, source)?);
66    }
67    Ok(relationships)
68}
69
70fn parse_relationship(element: &XmlElement, source: &str) -> Result<Relationship, PptxError> {
71    let id = element.attribute("Id").unwrap_or_default().to_owned();
72    let relationship_type = element.attribute("Type").unwrap_or_default().to_owned();
73    let target = element.attribute("Target").unwrap_or_default().to_owned();
74    let target_mode = if element.attribute("TargetMode") == Some("External") {
75        TargetMode::External
76    } else {
77        TargetMode::Internal
78    };
79    let resolved_target = match target_mode {
80        TargetMode::External => None,
81        TargetMode::Internal => Some(resolve_target(source, &target)?),
82    };
83    Ok(Relationship {
84        id,
85        relationship_type,
86        target,
87        target_mode,
88        resolved_target,
89    })
90}
91
92pub(crate) fn resolve_target(source: &str, target: &str) -> Result<String, PptxError> {
93    if target.contains("://") || target.starts_with("mailto:") || target.starts_with("data:") {
94        return Err(PptxError::InvalidRelationship {
95            source_part: source.to_owned(),
96            target: target.to_owned(),
97        });
98    }
99    let target = target
100        .split('#')
101        .next()
102        .unwrap_or_default()
103        .replace('\\', "/");
104    let mut segments = Vec::new();
105    if !target.starts_with('/')
106        && let Some((directory, _)) = source.rsplit_once('/')
107    {
108        segments.extend(directory.split('/').filter(|segment| !segment.is_empty()));
109    }
110    for segment in target.trim_start_matches('/').split('/') {
111        match segment {
112            "" | "." => {}
113            ".." => {
114                if segments.pop().is_none() {
115                    return Err(PptxError::InvalidRelationship {
116                        source_part: source.to_owned(),
117                        target: target.to_owned(),
118                    });
119                }
120            }
121            segment => segments.push(segment),
122        }
123    }
124    if segments.is_empty() {
125        return Err(PptxError::InvalidRelationship {
126            source_part: source.to_owned(),
127            target: target.to_owned(),
128        });
129    }
130    Ok(segments.join("/"))
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::ParseLimits;
137
138    #[test]
139    fn resolves_internal_targets_without_fetching_external_targets() {
140        assert_eq!(
141            resolve_target("ppt/slides/slide1.xml", "../slideLayouts/slideLayout1.xml").unwrap(),
142            "ppt/slideLayouts/slideLayout1.xml"
143        );
144        assert!(resolve_target("ppt/presentation.xml", "../../escape.xml").is_err());
145
146        let limits = ParseLimits::default();
147        let mut budget = ParseBudget::new(&limits);
148        let relationships = parse_relationships(
149            br#"<Relationships><Relationship Id="rId1" Type="hyperlink" Target="https://example.invalid" TargetMode="External"/></Relationships>"#,
150            "ppt/slides/_rels/slide1.xml.rels",
151            "ppt/slides/slide1.xml",
152            &mut budget,
153        )
154        .unwrap();
155        assert_eq!(relationships[0].target_mode, TargetMode::External);
156        assert_eq!(relationships[0].resolved_target, None);
157    }
158}