Skip to main content

linch_docx_rs/document/
image.rs

1//! Image support for DOCX documents
2//!
3//! Handles inline images via DrawingML (w:drawing > wp:inline > a:graphic > pic:pic).
4
5use crate::error::Result;
6use crate::xml::RawXmlNode;
7use quick_xml::events::{BytesEnd, BytesStart, Event};
8use quick_xml::Writer;
9
10/// An inline image in the document
11#[derive(Clone, Debug)]
12pub struct InlineImage {
13    /// Relationship ID referencing the image part
14    pub r_id: String,
15    /// Image width in EMU (English Metric Units, 914400 per inch)
16    pub width_emu: i64,
17    /// Image height in EMU
18    pub height_emu: i64,
19    /// Description / alt text
20    pub description: String,
21    /// Name
22    pub name: String,
23    /// The full raw XML of the drawing element (for round-trip preservation)
24    pub raw_xml: Option<RawXmlNode>,
25}
26
27impl InlineImage {
28    /// Create a new inline image reference
29    pub fn new(r_id: impl Into<String>, width_emu: i64, height_emu: i64) -> Self {
30        InlineImage {
31            r_id: r_id.into(),
32            width_emu,
33            height_emu,
34            description: String::new(),
35            name: String::new(),
36            raw_xml: None,
37        }
38    }
39
40    /// Create with dimensions in centimeters
41    pub fn from_cm(r_id: impl Into<String>, width_cm: f64, height_cm: f64) -> Self {
42        // 1 cm = 360000 EMU
43        Self::new(
44            r_id,
45            (width_cm * 360000.0) as i64,
46            (height_cm * 360000.0) as i64,
47        )
48    }
49
50    /// Create with dimensions in inches
51    pub fn from_inches(r_id: impl Into<String>, width_in: f64, height_in: f64) -> Self {
52        // 1 inch = 914400 EMU
53        Self::new(
54            r_id,
55            (width_in * 914400.0) as i64,
56            (height_in * 914400.0) as i64,
57        )
58    }
59
60    /// Set alt text
61    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
62        self.description = desc.into();
63        self
64    }
65
66    /// Set name
67    pub fn with_name(mut self, name: impl Into<String>) -> Self {
68        self.name = name.into();
69        self
70    }
71
72    /// Generate the DrawingML XML for this image
73    pub fn to_drawing_xml<W: std::io::Write>(&self, writer: &mut Writer<W>) -> Result<()> {
74        // If we have raw XML from parsing, use it for round-trip
75        if let Some(ref raw) = self.raw_xml {
76            raw.write_to(writer)?;
77            return Ok(());
78        }
79
80        // Generate minimal inline image XML
81        // <w:drawing>
82        writer.write_event(Event::Start(BytesStart::new("w:drawing")))?;
83
84        //   <wp:inline distT="0" distB="0" distL="0" distR="0">
85        let mut inline = BytesStart::new("wp:inline");
86        inline.push_attribute(("distT", "0"));
87        inline.push_attribute(("distB", "0"));
88        inline.push_attribute(("distL", "0"));
89        inline.push_attribute(("distR", "0"));
90        writer.write_event(Event::Start(inline))?;
91
92        //     <wp:extent cx="..." cy="..."/>
93        let mut extent = BytesStart::new("wp:extent");
94        extent.push_attribute(("cx", self.width_emu.to_string().as_str()));
95        extent.push_attribute(("cy", self.height_emu.to_string().as_str()));
96        writer.write_event(Event::Empty(extent))?;
97
98        //     <wp:docPr id="1" name="..." descr="..."/>
99        let mut doc_pr = BytesStart::new("wp:docPr");
100        doc_pr.push_attribute(("id", "1"));
101        doc_pr.push_attribute(("name", self.name.as_str()));
102        doc_pr.push_attribute(("descr", self.description.as_str()));
103        writer.write_event(Event::Empty(doc_pr))?;
104
105        //     <a:graphic>
106        let mut graphic = BytesStart::new("a:graphic");
107        graphic.push_attribute(("xmlns:a", crate::xml::A));
108        writer.write_event(Event::Start(graphic))?;
109
110        //       <a:graphicData uri="...">
111        let mut gd = BytesStart::new("a:graphicData");
112        gd.push_attribute(("uri", crate::xml::PIC));
113        writer.write_event(Event::Start(gd))?;
114
115        //         <pic:pic xmlns:pic="...">
116        let mut pic = BytesStart::new("pic:pic");
117        pic.push_attribute(("xmlns:pic", crate::xml::PIC));
118        writer.write_event(Event::Start(pic))?;
119
120        //           <pic:nvPicPr>
121        writer.write_event(Event::Start(BytesStart::new("pic:nvPicPr")))?;
122        let mut cnvpr = BytesStart::new("pic:cNvPr");
123        cnvpr.push_attribute(("id", "0"));
124        cnvpr.push_attribute(("name", self.name.as_str()));
125        writer.write_event(Event::Empty(cnvpr))?;
126        writer.write_event(Event::Empty(BytesStart::new("pic:cNvPicPr")))?;
127        writer.write_event(Event::End(BytesEnd::new("pic:nvPicPr")))?;
128
129        //           <pic:blipFill>
130        writer.write_event(Event::Start(BytesStart::new("pic:blipFill")))?;
131        let mut blip = BytesStart::new("a:blip");
132        blip.push_attribute(("r:embed", self.r_id.as_str()));
133        writer.write_event(Event::Empty(blip))?;
134        writer.write_event(Event::Start(BytesStart::new("a:stretch")))?;
135        writer.write_event(Event::Empty(BytesStart::new("a:fillRect")))?;
136        writer.write_event(Event::End(BytesEnd::new("a:stretch")))?;
137        writer.write_event(Event::End(BytesEnd::new("pic:blipFill")))?;
138
139        //           <pic:spPr>
140        writer.write_event(Event::Start(BytesStart::new("pic:spPr")))?;
141        writer.write_event(Event::Start(BytesStart::new("a:xfrm")))?;
142        let mut off = BytesStart::new("a:off");
143        off.push_attribute(("x", "0"));
144        off.push_attribute(("y", "0"));
145        writer.write_event(Event::Empty(off))?;
146        let mut ext = BytesStart::new("a:ext");
147        ext.push_attribute(("cx", self.width_emu.to_string().as_str()));
148        ext.push_attribute(("cy", self.height_emu.to_string().as_str()));
149        writer.write_event(Event::Empty(ext))?;
150        writer.write_event(Event::End(BytesEnd::new("a:xfrm")))?;
151        let mut prst = BytesStart::new("a:prstGeom");
152        prst.push_attribute(("prst", "rect"));
153        writer.write_event(Event::Start(prst))?;
154        writer.write_event(Event::Empty(BytesStart::new("a:avLst")))?;
155        writer.write_event(Event::End(BytesEnd::new("a:prstGeom")))?;
156        writer.write_event(Event::End(BytesEnd::new("pic:spPr")))?;
157
158        //         </pic:pic>
159        writer.write_event(Event::End(BytesEnd::new("pic:pic")))?;
160        //       </a:graphicData>
161        writer.write_event(Event::End(BytesEnd::new("a:graphicData")))?;
162        //     </a:graphic>
163        writer.write_event(Event::End(BytesEnd::new("a:graphic")))?;
164        //   </wp:inline>
165        writer.write_event(Event::End(BytesEnd::new("wp:inline")))?;
166        // </w:drawing>
167        writer.write_event(Event::End(BytesEnd::new("w:drawing")))?;
168
169        Ok(())
170    }
171}
172
173/// Image data to be embedded in the document
174pub struct ImageData {
175    /// Raw image bytes
176    pub data: Vec<u8>,
177    /// Content type (e.g., "image/png", "image/jpeg")
178    pub content_type: String,
179    /// File extension
180    pub extension: String,
181}
182
183impl ImageData {
184    /// Create from PNG bytes
185    pub fn png(data: Vec<u8>) -> Self {
186        ImageData {
187            data,
188            content_type: "image/png".into(),
189            extension: "png".into(),
190        }
191    }
192
193    /// Create from JPEG bytes
194    pub fn jpeg(data: Vec<u8>) -> Self {
195        ImageData {
196            data,
197            content_type: "image/jpeg".into(),
198            extension: "jpeg".into(),
199        }
200    }
201
202    /// Create from file path (auto-detects type)
203    pub fn from_file(path: &std::path::Path) -> std::io::Result<Self> {
204        let data = std::fs::read(path)?;
205        let ext = path
206            .extension()
207            .and_then(|e| e.to_str())
208            .unwrap_or("png")
209            .to_lowercase();
210        let content_type = match ext.as_str() {
211            "png" => "image/png",
212            "jpg" | "jpeg" => "image/jpeg",
213            "gif" => "image/gif",
214            "bmp" => "image/bmp",
215            "tiff" | "tif" => "image/tiff",
216            _ => "image/png",
217        };
218        Ok(ImageData {
219            data,
220            content_type: content_type.into(),
221            extension: ext,
222        })
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_inline_image_new() {
232        let img = InlineImage::new("rId5", 914400, 914400);
233        assert_eq!(img.r_id, "rId5");
234        assert_eq!(img.width_emu, 914400);
235        assert_eq!(img.height_emu, 914400);
236    }
237
238    #[test]
239    fn test_inline_image_from_cm() {
240        let img = InlineImage::from_cm("rId1", 10.0, 5.0);
241        assert_eq!(img.width_emu, 3600000);
242        assert_eq!(img.height_emu, 1800000);
243    }
244
245    #[test]
246    fn test_inline_image_from_inches() {
247        let img = InlineImage::from_inches("rId1", 1.0, 1.0);
248        assert_eq!(img.width_emu, 914400);
249        assert_eq!(img.height_emu, 914400);
250    }
251
252    #[test]
253    fn test_image_data_png() {
254        let data = ImageData::png(vec![0x89, 0x50, 0x4E, 0x47]);
255        assert_eq!(data.content_type, "image/png");
256        assert_eq!(data.extension, "png");
257    }
258
259    #[test]
260    fn test_generate_drawing_xml() {
261        let img = InlineImage::new("rId1", 914400, 914400)
262            .with_name("test.png")
263            .with_description("Test image");
264
265        let mut buf = Vec::new();
266        let mut writer = Writer::new(&mut buf);
267        img.to_drawing_xml(&mut writer).unwrap();
268
269        let xml = String::from_utf8(buf).unwrap();
270        assert!(xml.contains("w:drawing"));
271        assert!(xml.contains("wp:inline"));
272        assert!(xml.contains("r:embed=\"rId1\""));
273        assert!(xml.contains("cx=\"914400\""));
274    }
275}