Skip to main content

rust_docx/parse/
relationships.rs

1use 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
7/// Mirrors TypeScript `MIME_BY_EXTENSION`.
8pub 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
31/// Mirrors TypeScript `resolvePartPath`.
32pub 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
74/// Mirrors TypeScript `relationshipPartNameForPart`.
75pub 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
86/// Mirrors TypeScript `parsePartRelationships`.
87pub fn parse_part_relationships(pkg: &OoxmlPackage, part_name: &str) -> HashMap<String, String> {
88    parse_relationships_from_parts(&pkg.parts, part_name)
89}
90
91/// Mirrors TypeScript `parseRelationshipsFromParts`.
92pub 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
118/// Mirrors TypeScript `extensionFromPartName`.
119pub 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
127/// Mirrors TypeScript `contentTypeForPart`.
128pub 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
149/// Mirrors TypeScript `bytesToBase64`.
150pub 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}