rust_docx/parse/
relationships.rs1use std::collections::HashMap;
2
3use crate::package::{OoxmlPackage, OoxmlPart};
4use crate::parse::context::ContentTypeLookup;
5use crate::xml::{decode_xml_entities, get_attribute, get_attribute_ref};
6
7pub fn mime_by_extension(extension: &str) -> Option<&'static str> {
9 match extension.to_ascii_lowercase().as_str() {
10 "png" => Some("image/png"),
11 "jpg" | "jpeg" => Some("image/jpeg"),
12 "gif" => Some("image/gif"),
13 "bmp" => Some("image/bmp"),
14 "dib" => Some("image/bmp"),
15 "tif" | "tiff" => Some("image/tiff"),
16 "pict" | "pct" | "pic" => Some("image/x-pict"),
17 "wdp" | "hdp" | "jxr" => Some("image/vnd.ms-photo"),
18 "emz" => Some("image/x-emz"),
19 "wmz" => Some("image/x-wmz"),
20 "wmf" => Some("image/wmf"),
21 "emf" => Some("image/emf"),
22 "webp" => Some("image/webp"),
23 "svg" => Some("image/svg+xml"),
24 "svgz" => Some("image/svg+xml"),
25 "ico" => Some("image/x-icon"),
26 "eps" | "ps" => Some("application/postscript"),
27 _ => None,
28 }
29}
30
31pub fn resolve_part_path(base_part_name: &str, target: &str) -> String {
33 if target.is_empty() {
34 return String::new();
35 }
36
37 if target
38 .chars()
39 .next()
40 .is_some_and(|ch| ch.is_ascii_alphabetic())
41 && target.contains(':')
42 {
43 return target.to_string();
44 }
45
46 if target.starts_with('#') {
47 return target.to_string();
48 }
49
50 if target.starts_with('/') {
51 return target[1..].to_string();
52 }
53
54 let mut base_segments: Vec<&str> = base_part_name.split('/').collect();
55 if !base_segments.is_empty() {
56 base_segments.pop();
57 }
58
59 let mut output: Vec<String> = base_segments.into_iter().map(str::to_string).collect();
60 for segment in target.split('/') {
61 if segment.is_empty() || segment == "." {
62 continue;
63 }
64 if segment == ".." {
65 output.pop();
66 continue;
67 }
68 output.push(segment.to_string());
69 }
70
71 output.join("/")
72}
73
74pub fn relationship_part_name_for_part(part_name: &str) -> String {
76 let mut segments: Vec<&str> = part_name.split('/').collect();
77 let file_name = segments.pop().unwrap_or("");
78 let folder = segments.join("/");
79 if folder.is_empty() {
80 format!("_rels/{file_name}.rels")
81 } else {
82 format!("{folder}/_rels/{file_name}.rels")
83 }
84}
85
86pub fn parse_part_relationships(pkg: &OoxmlPackage, part_name: &str) -> HashMap<String, String> {
88 parse_relationships_from_parts(&pkg.parts, part_name)
89}
90
91pub fn parse_relationships_from_parts(
93 parts: &HashMap<String, OoxmlPart>,
94 part_name: &str,
95) -> HashMap<String, String> {
96 let mut map = HashMap::new();
97 let relationships_part_name = relationship_part_name_for_part(part_name);
98 let Some(relationships_part) = parts.get(&relationships_part_name) else {
99 return map;
100 };
101
102 for tag in find_opening_tags(&relationships_part.content, "Relationship") {
103 let id = get_attribute(&tag, "Id");
104 let target = get_attribute_ref(&tag, "Target").map(decode_xml_entities);
105 if let (Some(id), Some(target)) = (id, target) {
106 let resolved = if get_attribute_ref(&tag, "TargetMode") == Some("External") {
107 target
108 } else {
109 resolve_part_path(part_name, &target)
110 };
111 map.insert(id, resolved);
112 }
113 }
114
115 map
116}
117
118pub fn extension_from_part_name(part_name: &str) -> Option<String> {
120 let last_dot = part_name.rfind('.')?;
121 if last_dot == part_name.len() - 1 {
122 return None;
123 }
124 Some(part_name[last_dot + 1..].to_ascii_lowercase())
125}
126
127pub fn content_type_for_part(part_name: &str, content_types: &ContentTypeLookup) -> Option<String> {
129 if let Some(override_type) = content_types
130 .override_by_part_name
131 .get(part_name)
132 .or_else(|| {
133 content_types
134 .override_by_part_name
135 .get(&format!("/{part_name}"))
136 })
137 {
138 return Some(override_type.clone());
139 }
140
141 let extension = extension_from_part_name(part_name)?;
142 content_types
143 .default_by_extension
144 .get(&extension)
145 .cloned()
146 .or_else(|| mime_by_extension(&extension).map(str::to_string))
147}
148
149pub fn bytes_to_base64(bytes: &[u8]) -> String {
151 super::util::bytes_to_base64(bytes)
152}
153
154fn find_opening_tags(xml: &str, local_name: &str) -> Vec<String> {
155 let open_prefix = format!("<{local_name}");
156 let mut tags = Vec::new();
157 let mut index = 0;
158
159 while index < xml.len() {
160 let Some(tag_start) = find_case_insensitive_at(xml, index, &open_prefix) else {
161 break;
162 };
163
164 if !has_word_boundary_after(xml, tag_start + open_prefix.len()) {
165 index = tag_start + 1;
166 continue;
167 }
168
169 let Some(relative_gt) = xml[tag_start..].find('>') else {
170 break;
171 };
172
173 let tag_end = tag_start + relative_gt + 1;
174 tags.push(xml[tag_start..tag_end].to_string());
175 index = tag_end;
176 }
177
178 tags
179}
180
181fn has_word_boundary_after(xml: &str, index: usize) -> bool {
182 xml.as_bytes()
183 .get(index)
184 .is_none_or(|byte| !byte.is_ascii_alphanumeric() && *byte != b'_')
185}
186
187fn find_case_insensitive_at(haystack: &str, start: usize, needle: &str) -> Option<usize> {
188 crate::xml::find_ignore_ascii_case(haystack, start, needle)
189}