Skip to main content

extend_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        "wmf" => Some("image/wmf"),
15        "emf" => Some("image/emf"),
16        "webp" => Some("image/webp"),
17        "svg" => Some("image/svg+xml"),
18        _ => None,
19    }
20}
21
22/// Mirrors TypeScript `resolvePartPath`.
23pub fn resolve_part_path(base_part_name: &str, target: &str) -> String {
24    if target.is_empty() {
25        return String::new();
26    }
27
28    if target
29        .chars()
30        .next()
31        .is_some_and(|ch| ch.is_ascii_alphabetic())
32        && target.contains(':')
33    {
34        return target.to_string();
35    }
36
37    if target.starts_with('#') {
38        return target.to_string();
39    }
40
41    if target.starts_with('/') {
42        return target[1..].to_string();
43    }
44
45    let mut base_segments: Vec<&str> = base_part_name.split('/').collect();
46    if !base_segments.is_empty() {
47        base_segments.pop();
48    }
49
50    let mut output: Vec<String> = base_segments.into_iter().map(str::to_string).collect();
51    for segment in target.split('/') {
52        if segment.is_empty() || segment == "." {
53            continue;
54        }
55        if segment == ".." {
56            output.pop();
57            continue;
58        }
59        output.push(segment.to_string());
60    }
61
62    output.join("/")
63}
64
65/// Mirrors TypeScript `relationshipPartNameForPart`.
66pub fn relationship_part_name_for_part(part_name: &str) -> String {
67    let mut segments: Vec<&str> = part_name.split('/').collect();
68    let file_name = segments.pop().unwrap_or("");
69    let folder = segments.join("/");
70    if folder.is_empty() {
71        format!("_rels/{file_name}.rels")
72    } else {
73        format!("{folder}/_rels/{file_name}.rels")
74    }
75}
76
77/// Mirrors TypeScript `parsePartRelationships`.
78pub fn parse_part_relationships(pkg: &OoxmlPackage, part_name: &str) -> HashMap<String, String> {
79    parse_relationships_from_parts(&pkg.parts, part_name)
80}
81
82/// Mirrors TypeScript `parseRelationshipsFromParts`.
83pub fn parse_relationships_from_parts(
84    parts: &HashMap<String, OoxmlPart>,
85    part_name: &str,
86) -> HashMap<String, String> {
87    let mut map = HashMap::new();
88    let relationships_part_name = relationship_part_name_for_part(part_name);
89    let Some(relationships_part) = parts.get(&relationships_part_name) else {
90        return map;
91    };
92
93    for tag in find_opening_tags(&relationships_part.content, "Relationship") {
94        let id = get_attribute(&tag, "Id");
95        let target = get_attribute_ref(&tag, "Target").map(decode_xml_entities);
96        if let (Some(id), Some(target)) = (id, target) {
97            let resolved = if get_attribute_ref(&tag, "TargetMode") == Some("External") {
98                target
99            } else {
100                resolve_part_path(part_name, &target)
101            };
102            map.insert(id, resolved);
103        }
104    }
105
106    map
107}
108
109/// Mirrors TypeScript `extensionFromPartName`.
110pub fn extension_from_part_name(part_name: &str) -> Option<String> {
111    let last_dot = part_name.rfind('.')?;
112    if last_dot == part_name.len() - 1 {
113        return None;
114    }
115    Some(part_name[last_dot + 1..].to_ascii_lowercase())
116}
117
118/// Mirrors TypeScript `contentTypeForPart`.
119pub fn content_type_for_part(part_name: &str, content_types: &ContentTypeLookup) -> Option<String> {
120    if let Some(override_type) = content_types
121        .override_by_part_name
122        .get(part_name)
123        .or_else(|| {
124            content_types
125                .override_by_part_name
126                .get(&format!("/{part_name}"))
127        })
128    {
129        return Some(override_type.clone());
130    }
131
132    let extension = extension_from_part_name(part_name)?;
133    content_types
134        .default_by_extension
135        .get(&extension)
136        .cloned()
137        .or_else(|| mime_by_extension(&extension).map(str::to_string))
138}
139
140/// Mirrors TypeScript `bytesToBase64`.
141pub fn bytes_to_base64(bytes: &[u8]) -> String {
142    super::util::bytes_to_base64(bytes)
143}
144
145fn find_opening_tags(xml: &str, local_name: &str) -> Vec<String> {
146    let open_prefix = format!("<{local_name}");
147    let mut tags = Vec::new();
148    let mut index = 0;
149
150    while index < xml.len() {
151        let Some(tag_start) = find_case_insensitive_at(xml, index, &open_prefix) else {
152            break;
153        };
154
155        if !has_word_boundary_after(xml, tag_start + open_prefix.len()) {
156            index = tag_start + 1;
157            continue;
158        }
159
160        let Some(relative_gt) = xml[tag_start..].find('>') else {
161            break;
162        };
163
164        let tag_end = tag_start + relative_gt + 1;
165        tags.push(xml[tag_start..tag_end].to_string());
166        index = tag_end;
167    }
168
169    tags
170}
171
172fn has_word_boundary_after(xml: &str, index: usize) -> bool {
173    xml.as_bytes()
174        .get(index)
175        .is_none_or(|byte| !byte.is_ascii_alphanumeric() && *byte != b'_')
176}
177
178fn find_case_insensitive_at(haystack: &str, start: usize, needle: &str) -> Option<usize> {
179    crate::xml::find_ignore_ascii_case(haystack, start, needle)
180}