Skip to main content

easydoc_reader/extractor/
image.rs

1//! Image extraction helpers for DOCX archives.
2//!
3//! Parses `word/_rels/document.xml.rels` to build a `relId -> media part path`
4//! mapping, and reads raw image bytes from the ZIP archive.
5
6use std::borrow::Cow;
7use std::collections::HashMap;
8use std::io::Read;
9
10use easydoc_core::{DocError, Result};
11use quick_xml::Reader as XmlReader;
12use quick_xml::events::Event;
13
14/// Parsed relationship mapping from `word/_rels/document.xml.rels`.
15///
16/// Maps relationship IDs (e.g. `rId5`) to their targets for both image and
17/// hyperlink relationship types.  Image targets are resolved to ZIP entry
18/// paths (e.g. `word/media/image1.png`), while hyperlink targets are stored
19/// as-is (typically an external URL).
20pub struct Relationships {
21    /// Image relationships: rId -> ZIP entry path (e.g. `word/media/image1.png`).
22    rels: HashMap<String, String>,
23    /// Hyperlink relationships: rId -> URL (e.g. `https://example.com`).
24    hyperlinks: HashMap<String, String>,
25}
26
27/// Internal discriminant for relationship types we care about.
28enum RelType {
29    Image,
30    Hyperlink,
31    Other,
32}
33
34impl Relationships {
35    /// Parses relationship XML into image and hyperlink mappings.
36    ///
37    /// Expects the standard OOXML relationships XML format:
38    /// ```xml
39    /// <Relationships>
40    ///   <Relationship Id="rId5" Target="media/image1.png" Type="...image" />
41    ///   <Relationship Id="rId10" Target="https://example.com"
42    ///                 Type="...hyperlink" TargetMode="External" />
43    /// </Relationships>
44    /// ```
45    ///
46    /// Image targets are resolved to ZIP entry paths (prepended with `word/`).
47    /// Hyperlink targets are stored as-is (typically external URLs).
48    ///
49    /// # Errors
50    ///
51    /// Returns [`DocError::Format`] on XML parse failure.
52    pub fn parse(rels_xml: &str) -> Result<Self> {
53        let mut rels = HashMap::new();
54        let mut hyperlinks = HashMap::new();
55        let mut reader = XmlReader::from_reader(rels_xml.as_bytes());
56        reader.config_mut().trim_text(true);
57        let mut buf = Vec::new();
58
59        loop {
60            match reader.read_event_into(&mut buf) {
61                Ok(Event::Eof) => break,
62                Ok(Event::Empty(ref tag)) => {
63                    let name = tag.name();
64                    if name.as_ref() == b"Relationship" {
65                        let mut id = None;
66                        let mut target = None;
67                        let mut rel_type = RelType::Other;
68
69                        for attr in tag.attributes().flatten() {
70                            match attr.key.as_ref() {
71                                b"Id" => {
72                                    id = attr
73                                        .normalized_value(quick_xml::XmlVersion::Implicit1_0)
74                                        .ok()
75                                        .map(Cow::into_owned);
76                                }
77                                b"Target" => {
78                                    target = attr
79                                        .normalized_value(quick_xml::XmlVersion::Implicit1_0)
80                                        .ok()
81                                        .map(Cow::into_owned);
82                                }
83                                b"Type" => {
84                                    if let Ok(val) =
85                                        attr.normalized_value(quick_xml::XmlVersion::Implicit1_0)
86                                    {
87                                        if val.ends_with("/image") {
88                                            rel_type = RelType::Image;
89                                        } else if val.ends_with("/hyperlink") {
90                                            rel_type = RelType::Hyperlink;
91                                        }
92                                    }
93                                }
94                                _ => {}
95                            }
96                        }
97
98                        if let (Some(id), Some(target)) = (id, target) {
99                            match rel_type {
100                                RelType::Image => {
101                                    // Target is relative like "media/image1.png";
102                                    // prepend "word/" to form the ZIP entry name.
103                                    let full_path = if target.starts_with("media/") {
104                                        format!("word/{target}")
105                                    } else {
106                                        target
107                                    };
108                                    rels.insert(id, full_path);
109                                }
110                                RelType::Hyperlink => {
111                                    // Hyperlink targets are typically external URLs
112                                    // (TargetMode="External"). Store as-is.
113                                    hyperlinks.insert(id, target);
114                                }
115                                RelType::Other => {}
116                            }
117                        }
118                    }
119                }
120                Err(e) => {
121                    return Err(DocError::Format(format!(
122                        "XML parse error in relationships: {e}"
123                    )));
124                }
125                _ => {}
126            }
127            buf.clear();
128        }
129
130        Ok(Self { rels, hyperlinks })
131    }
132
133    /// Resolves a relationship ID to its media part path in the ZIP archive.
134    ///
135    /// Returns `None` if the relationship ID is not found among image
136    /// relationships.  This method is kept for backward compatibility;
137    /// prefer [`resolve_image`](Self::resolve_image) for new code.
138    #[must_use]
139    pub fn resolve(&self, rel_id: &str) -> Option<&str> {
140        self.resolve_image(rel_id)
141    }
142
143    /// Resolves a relationship ID to its image part path in the ZIP archive.
144    ///
145    /// Returns `None` if the relationship ID is not found among image
146    /// relationships.
147    #[must_use]
148    pub fn resolve_image(&self, rel_id: &str) -> Option<&str> {
149        self.rels.get(rel_id).map(String::as_str)
150    }
151
152    /// Resolves a relationship ID to its hyperlink URL.
153    ///
154    /// Returns `None` if the relationship ID is not found among hyperlink
155    /// relationships.
156    #[must_use]
157    pub fn resolve_hyperlink(&self, rel_id: &str) -> Option<&str> {
158        self.hyperlinks.get(rel_id).map(String::as_str)
159    }
160
161    /// Returns the number of image relationships parsed (for diagnostics).
162    #[must_use]
163    pub fn len(&self) -> usize {
164        self.rels.len() + self.hyperlinks.len()
165    }
166
167    /// Returns `true` if no relationships were parsed.
168    #[must_use]
169    pub fn is_empty(&self) -> bool {
170        self.rels.is_empty() && self.hyperlinks.is_empty()
171    }
172}
173
174/// Reads the raw bytes of a ZIP entry by name.
175///
176/// # Errors
177///
178/// Returns [`DocError::Zip`] if the entry is not found or cannot be read.
179pub fn read_zip_part<R: Read + std::io::Seek>(
180    archive: &mut zip::ZipArchive<R>,
181    part_name: &str,
182) -> Result<Vec<u8>> {
183    let mut file = archive
184        .by_name(part_name)
185        .map_err(|e| DocError::Zip(format!("entry '{part_name}' not found: {e}")))?;
186    let mut buf = Vec::new();
187    file.read_to_end(&mut buf)?;
188    Ok(buf)
189}
190
191/// Extracts the file extension (lowercase, without dot) from a filename.
192///
193/// Returns `None` if the filename has no extension.
194#[must_use]
195pub fn extension_from_filename(name: &str) -> Option<String> {
196    std::path::Path::new(name)
197        .extension()
198        .and_then(|s| s.to_str())
199        .map(str::to_lowercase)
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn parse_typical_rels() {
208        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
209<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
210  <Relationship Id="rId1" Target="styles.xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"/>
211  <Relationship Id="rId5" Target="media/image1.png" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"/>
212  <Relationship Id="rId6" Target="media/image2.jpeg" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"/>
213</Relationships>"#;
214
215        let rels = Relationships::parse(xml).unwrap();
216        assert_eq!(rels.len(), 2);
217        assert_eq!(rels.resolve("rId5"), Some("word/media/image1.png"));
218        assert_eq!(rels.resolve("rId6"), Some("word/media/image2.jpeg"));
219        // Non-image relationship should be filtered out.
220        assert_eq!(rels.resolve("rId1"), None);
221    }
222
223    #[test]
224    fn parse_empty_rels() {
225        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
226<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
227</Relationships>"#;
228
229        let rels = Relationships::parse(xml).unwrap();
230        assert!(rels.is_empty());
231    }
232
233    #[test]
234    fn resolve_unknown_relid() {
235        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
236<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
237</Relationships>"#;
238
239        let rels = Relationships::parse(xml).unwrap();
240        assert_eq!(rels.resolve("rId999"), None);
241    }
242
243    #[test]
244    fn extension_from_filename_png() {
245        assert_eq!(
246            extension_from_filename("image1.png"),
247            Some("png".to_owned())
248        );
249    }
250
251    #[test]
252    fn extension_from_filename_jpeg() {
253        assert_eq!(
254            extension_from_filename("photo.jpeg"),
255            Some("jpeg".to_owned())
256        );
257    }
258
259    #[test]
260    fn extension_from_filename_uppercase() {
261        assert_eq!(
262            extension_from_filename("image1.PNG"),
263            Some("png".to_owned())
264        );
265    }
266
267    #[test]
268    fn extension_from_filename_no_extension() {
269        assert_eq!(extension_from_filename("README"), None);
270    }
271
272    #[test]
273    fn extension_from_filename_dotfile() {
274        assert_eq!(extension_from_filename(".gitignore"), None);
275    }
276
277    #[test]
278    fn relationships_includes_hyperlinks() {
279        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
280<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
281  <Relationship Id="rId1" Target="styles.xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"/>
282  <Relationship Id="rId5" Target="media/image1.png" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"/>
283  <Relationship Id="rId10" Target="https://example.com" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" TargetMode="External"/>
284</Relationships>"#;
285
286        let rels = Relationships::parse(xml).unwrap();
287        // 1 image + 1 hyperlink = 2 total
288        assert_eq!(rels.len(), 2);
289        assert_eq!(rels.resolve_image("rId5"), Some("word/media/image1.png"));
290        assert_eq!(rels.resolve_hyperlink("rId10"), Some("https://example.com"));
291        // Non-existent IDs return None
292        assert_eq!(rels.resolve_hyperlink("rId999"), None);
293        assert_eq!(rels.resolve_image("rId10"), None);
294    }
295
296    #[test]
297    fn relationships_external_target_mode() {
298        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
299<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
300  <Relationship Id="rId3" Target="https://rust-lang.org" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" TargetMode="External"/>
301  <Relationship Id="rId4" Target="mailto:user@example.com" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" TargetMode="External"/>
302</Relationships>"#;
303
304        let rels = Relationships::parse(xml).unwrap();
305        assert_eq!(
306            rels.resolve_hyperlink("rId3"),
307            Some("https://rust-lang.org")
308        );
309        assert_eq!(
310            rels.resolve_hyperlink("rId4"),
311            Some("mailto:user@example.com")
312        );
313        // resolve (backward compat) should only find images
314        assert_eq!(rels.resolve("rId3"), None);
315    }
316
317    #[test]
318    fn relationships_backward_compat_resolve_is_alias() {
319        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
320<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
321  <Relationship Id="rId5" Target="media/image1.png" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"/>
322</Relationships>"#;
323
324        let rels = Relationships::parse(xml).unwrap();
325        // resolve and resolve_image should return the same thing
326        assert_eq!(rels.resolve("rId5"), rels.resolve_image("rId5"));
327    }
328}