Skip to main content

oxml_opc/
package.rs

1//! OPC Package reader and writer for ZIP-based OOXML packages.
2
3use std::collections::HashMap;
4use std::io::{Read, Seek, Write};
5use std::path::Path;
6
7use zip::ZipWriter;
8use zip::read::ZipArchive;
9use zip::write::SimpleFileOptions;
10
11use crate::content_types::ContentTypes;
12use crate::error::{OpcError, Result};
13use crate::relationship::{Relationships, rel_types};
14
15/// A single part within the OPC package.
16#[derive(Debug, Clone)]
17pub struct PackagePart {
18    /// Part name (URI), e.g. "/word/document.xml"
19    pub name: String,
20    /// Raw bytes of the part content
21    pub data: Vec<u8>,
22}
23
24/// An in-memory representation of an OPC package (ZIP archive).
25#[derive(Debug, Clone)]
26pub struct OpcPackage {
27    /// Content types from `[Content_Types].xml`
28    pub content_types: ContentTypes,
29    /// Package-level relationships from `_rels/.rels`
30    pub package_rels: Relationships,
31    /// Part-level relationships keyed by the part they belong to.
32    /// Key is the part name (e.g. "/word/document.xml"),
33    /// value is the parsed relationships.
34    pub part_rels: HashMap<String, Relationships>,
35    /// All parts keyed by their URI (e.g. "/word/document.xml").
36    pub parts: HashMap<String, Vec<u8>>,
37}
38
39impl OpcPackage {
40    /// Open an OPC package from a file path.
41    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
42        let file = std::fs::File::open(path)?;
43        Self::from_reader(file)
44    }
45
46    /// Open an OPC package from any reader that implements Read + Seek.
47    pub fn from_reader<R: Read + Seek>(reader: R) -> Result<Self> {
48        let mut archive = ZipArchive::new(reader)?;
49        let mut raw_parts: HashMap<String, Vec<u8>> = HashMap::new();
50
51        // Read all entries from the ZIP
52        for i in 0..archive.len() {
53            let mut entry = archive.by_index(i)?;
54            if entry.is_dir() {
55                continue;
56            }
57            let name = normalize_part_name(entry.name());
58            let name = name.strip_prefix('/').unwrap_or(&name).to_string();
59            let mut data = Vec::new();
60            entry.read_to_end(&mut data)?;
61            raw_parts.insert(name, data);
62        }
63
64        // Parse [Content_Types].xml
65        let ct_xml = raw_parts
66            .get("[Content_Types].xml")
67            .ok_or_else(|| OpcError::PartNotFound("[Content_Types].xml".into()))?;
68        let content_types = ContentTypes::from_xml(ct_xml)?;
69
70        // Parse package-level relationships: _rels/.rels
71        let package_rels = if let Some(rels_xml) = raw_parts.get("_rels/.rels") {
72            Relationships::from_xml(rels_xml)?
73        } else {
74            Relationships::new()
75        };
76
77        // Parse all part-level .rels files
78        let mut part_rels = HashMap::new();
79        let rels_entries: Vec<String> = raw_parts
80            .keys()
81            .filter(|k| k.ends_with(".rels") && *k != "_rels/.rels")
82            .cloned()
83            .collect();
84
85        for rels_path in rels_entries {
86            if let Some(xml_data) = raw_parts.get(&rels_path) {
87                let rels = Relationships::from_xml(xml_data)?;
88                // Convert rels path to the part name it belongs to.
89                // e.g. "word/_rels/document.xml.rels" → "/word/document.xml"
90                let part_name = rels_path_to_part_name(&rels_path);
91                part_rels.insert(part_name, rels);
92            }
93        }
94
95        // Build parts map with leading "/" normalized
96        let mut parts = HashMap::new();
97        for (name, data) in &raw_parts {
98            if name == "[Content_Types].xml" || name == "_rels/.rels" || name.ends_with(".rels") {
99                continue;
100            }
101            let normalized = if name.starts_with('/') {
102                name.clone()
103            } else {
104                format!("/{name}")
105            };
106            parts.insert(normalized, data.clone());
107        }
108
109        Ok(OpcPackage {
110            content_types,
111            package_rels,
112            part_rels,
113            parts,
114        })
115    }
116
117    /// Save the OPC package to a file path.
118    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
119        let file = std::fs::File::create(path)?;
120        self.write_to(file)
121    }
122
123    /// Write the OPC package to any writer.
124    pub fn write_to<W: Write + Seek>(&self, writer: W) -> Result<()> {
125        let mut zip = ZipWriter::new(writer);
126        let options =
127            SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
128
129        // Write [Content_Types].xml
130        let ct_xml = self.content_types.to_xml()?;
131        zip.start_file("[Content_Types].xml", options)?;
132        zip.write_all(&ct_xml)?;
133
134        // Write _rels/.rels
135        let pkg_rels_xml = self.package_rels.to_xml()?;
136        zip.start_file("_rels/.rels", options)?;
137        zip.write_all(&pkg_rels_xml)?;
138
139        // Write part-level .rels files. Both loops iterate in sorted order so
140        // that saving the same package twice produces byte-identical output;
141        // a HashMap's order would vary between runs.
142        let mut rels_names: Vec<&String> = self.part_rels.keys().collect();
143        rels_names.sort_unstable();
144        for part_name in rels_names {
145            let rels = &self.part_rels[part_name];
146            let rels_path = part_name_to_rels_path(part_name);
147            let rels_xml = rels.to_xml()?;
148            zip.start_file(&rels_path, options)?;
149            zip.write_all(&rels_xml)?;
150        }
151
152        // Write all parts
153        let mut part_names: Vec<&String> = self.parts.keys().collect();
154        part_names.sort_unstable();
155        for name in part_names {
156            // Strip leading "/" for ZIP entry name
157            let zip_name = name.strip_prefix('/').unwrap_or(name);
158            zip.start_file(zip_name, options)?;
159            zip.write_all(&self.parts[name])?;
160        }
161
162        zip.finish()?;
163        Ok(())
164    }
165
166    /// Get raw bytes of a part by its URI.
167    pub fn get_part(&self, part_name: &str) -> Option<&[u8]> {
168        self.parts.get(part_name).map(|v| v.as_slice())
169    }
170
171    /// Set (or replace) a part's raw bytes.
172    pub fn set_part(&mut self, part_name: &str, data: Vec<u8>) {
173        self.parts.insert(part_name.to_string(), data);
174    }
175
176    /// Get the relationships for a specific part.
177    pub fn get_part_rels(&self, part_name: &str) -> Option<&Relationships> {
178        self.part_rels.get(part_name)
179    }
180
181    /// Get or create the relationships for a specific part.
182    pub fn get_or_create_part_rels(&mut self, part_name: &str) -> &mut Relationships {
183        self.part_rels.entry(part_name.to_string()).or_default()
184    }
185
186    /// Resolve the target URI of a relationship relative to its source part.
187    ///
188    /// `.` and `..` segments are collapsed, so a target such as
189    /// `../media/image1.png` on `/word/charts/chart1.xml` resolves to
190    /// `/media/image1.png` and matches the key it is stored under. Without
191    /// this, parts referenced through a parent directory could never be found.
192    pub fn resolve_rel_target(source_part: &str, rel_target: &str) -> String {
193        let joined = if rel_target.starts_with('/') {
194            rel_target.to_string()
195        } else {
196            // Directory of the source part, including the trailing slash.
197            let dir = match source_part.rfind('/') {
198                Some(pos) => &source_part[..=pos],
199                None => "/",
200            };
201            format!("{dir}{rel_target}")
202        };
203        normalize_part_name(&joined)
204    }
205
206    /// Find the main document part URI by looking at package relationships.
207    pub fn main_document_part(&self) -> Option<String> {
208        self.package_rels
209            .get_by_type(rel_types::DOCUMENT)
210            .map(|rel| {
211                if rel.target.starts_with('/') {
212                    rel.target.clone()
213                } else {
214                    format!("/{}", rel.target)
215                }
216            })
217    }
218
219    /// Create an empty package with the universal OPC content types.
220    pub fn new() -> Self {
221        OpcPackage {
222            content_types: ContentTypes::minimal(),
223            package_rels: Relationships::new(),
224            part_rels: HashMap::new(),
225            parts: HashMap::new(),
226        }
227    }
228
229    /// Create a package whose office document relationship targets a main part.
230    pub fn with_main_part(part_name: &str, content_type: &str) -> Self {
231        let mut package = Self::new();
232        let part_name = part_name.strip_prefix('/').unwrap_or(part_name);
233        package.package_rels.add(rel_types::DOCUMENT, part_name);
234        package
235            .content_types
236            .add_override(&format!("/{part_name}"), content_type);
237        package
238    }
239}
240
241impl Default for OpcPackage {
242    fn default() -> Self {
243        Self::new()
244    }
245}
246
247#[cfg(test)]
248fn docx_package() -> OpcPackage {
249    let mut package = OpcPackage::with_main_part(
250        "word/document.xml",
251        "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
252    );
253    package.content_types.add_override(
254        "/word/styles.xml",
255        "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml",
256    );
257    package
258}
259
260/// Collapse `.` and `..` segments in an absolute part name.
261///
262/// A `..` that would escape the package root is dropped, matching how OPC
263/// consumers treat over-long parent traversals.
264fn normalize_part_name(path: &str) -> String {
265    if !path.contains("./") && !path.ends_with("/.") && !path.ends_with("/..") {
266        return path.to_string();
267    }
268
269    let mut segments: Vec<&str> = Vec::new();
270    for segment in path.split('/') {
271        match segment {
272            "" | "." => {}
273            ".." => {
274                segments.pop();
275            }
276            other => segments.push(other),
277        }
278    }
279
280    let mut out = String::with_capacity(path.len());
281    for segment in segments {
282        out.push('/');
283        out.push_str(segment);
284    }
285    if out.is_empty() { "/".to_string() } else { out }
286}
287
288/// Convert a .rels file path to the part name it belongs to.
289/// e.g. "word/_rels/document.xml.rels" → "/word/document.xml"
290fn rels_path_to_part_name(rels_path: &str) -> String {
291    // Strip the ".rels" suffix once (`trim_end_matches` would strip repeats,
292    // turning "a.rels.rels" into "a") and drop only the "_rels" path segment
293    // that directly precedes the file name.
294    let without_suffix = rels_path.strip_suffix(".rels").unwrap_or(rels_path);
295    let path = match without_suffix.rfind("_rels/") {
296        Some(pos) => format!(
297            "{}{}",
298            &without_suffix[..pos],
299            &without_suffix[pos + "_rels/".len()..]
300        ),
301        None => without_suffix.to_string(),
302    };
303    if path.starts_with('/') {
304        path
305    } else {
306        format!("/{path}")
307    }
308}
309
310/// Convert a part name to its .rels file path.
311/// e.g. "/word/document.xml" → "word/_rels/document.xml.rels"
312fn part_name_to_rels_path(part_name: &str) -> String {
313    let name = part_name.strip_prefix('/').unwrap_or(part_name);
314    if let Some(pos) = name.rfind('/') {
315        let dir = &name[..pos];
316        let file = &name[pos + 1..];
317        format!("{dir}/_rels/{file}.rels")
318    } else {
319        format!("_rels/{name}.rels")
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    fn package_zip(entries: &[(&str, &[u8])]) -> std::io::Cursor<Vec<u8>> {
328        let mut buffer = std::io::Cursor::new(Vec::new());
329        {
330            let mut zip = ZipWriter::new(&mut buffer);
331            let options = SimpleFileOptions::default();
332            for (name, data) in entries {
333                zip.start_file(name, options).unwrap();
334                zip.write_all(data).unwrap();
335            }
336            zip.finish().unwrap();
337        }
338        buffer.set_position(0);
339        buffer
340    }
341
342    const MINIMAL_CONTENT_TYPES: &[u8] =
343        br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
344<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
345  <Default Extension="xml" ContentType="application/xml"/>
346</Types>"#;
347
348    fn independently_built_pptx() -> std::io::Cursor<Vec<u8>> {
349        const CONTENT_TYPES: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
350<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
351  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
352  <Default Extension="xml" ContentType="application/xml"/>
353  <Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
354  <Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
355  <Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
356  <Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>
357  <Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>
358</Types>"#;
359        const PACKAGE_RELS: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
360<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
361  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
362</Relationships>"#;
363        const PRESENTATION_RELS: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
364<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
365  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>
366  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>
367</Relationships>"#;
368        const SLIDE_RELS: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
369<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
370  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
371</Relationships>"#;
372        const SLIDE_LAYOUT_RELS: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
373<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
374  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
375</Relationships>"#;
376        const SLIDE_MASTER_RELS: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
377<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
378  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
379  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>
380</Relationships>"#;
381        const PRESENTATION: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
382<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
383  <p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
384  <p:sldIdLst><p:sldId id="256" r:id="rId2"/></p:sldIdLst>
385  <p:sldSz cx="12192000" cy="6858000"/>
386  <p:notesSz cx="6858000" cy="9144000"/>
387</p:presentation>"#;
388        const SLIDE: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
389<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
390  <p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld>
391  <p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
392</p:sld>"#;
393        const SLIDE_LAYOUT: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
394<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
395  <p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld>
396  <p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
397</p:sldLayout>"#;
398        const SLIDE_MASTER: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
399<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
400  <p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld>
401  <p:clrMap accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" bg1="lt1" bg2="lt2" folHlink="folHlink" hlink="hlink" tx1="dk1" tx2="dk2"/>
402  <p:sldLayoutIdLst><p:sldLayoutId id="2147483648" r:id="rId1"/></p:sldLayoutIdLst>
403  <p:txStyles><p:titleStyle/><p:bodyStyle/><p:otherStyle/></p:txStyles>
404</p:sldMaster>"#;
405        const THEME: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
406<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Minimal">
407  <a:themeElements>
408    <a:clrScheme name="Minimal">
409      <a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>
410      <a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>
411      <a:dk2><a:srgbClr val="1F497D"/></a:dk2>
412      <a:lt2><a:srgbClr val="EEECE1"/></a:lt2>
413      <a:accent1><a:srgbClr val="4F81BD"/></a:accent1>
414      <a:accent2><a:srgbClr val="C0504D"/></a:accent2>
415      <a:accent3><a:srgbClr val="9BBB59"/></a:accent3>
416      <a:accent4><a:srgbClr val="8064A2"/></a:accent4>
417      <a:accent5><a:srgbClr val="4BACC6"/></a:accent5>
418      <a:accent6><a:srgbClr val="F79646"/></a:accent6>
419      <a:hlink><a:srgbClr val="0000FF"/></a:hlink>
420      <a:folHlink><a:srgbClr val="800080"/></a:folHlink>
421    </a:clrScheme>
422    <a:fontScheme name="Minimal">
423      <a:majorFont><a:latin typeface="Arial"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>
424      <a:minorFont><a:latin typeface="Arial"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>
425    </a:fontScheme>
426    <a:fmtScheme name="Minimal">
427      <a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst>
428      <a:lnStyleLst><a:ln w="6350"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln><a:ln w="12700"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln><a:ln w="19050"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln></a:lnStyleLst>
429      <a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst>
430      <a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst>
431    </a:fmtScheme>
432  </a:themeElements>
433</a:theme>"#;
434
435        package_zip(&[
436            ("[Content_Types].xml", CONTENT_TYPES),
437            ("_rels/.rels", PACKAGE_RELS),
438            ("ppt/presentation.xml", PRESENTATION),
439            ("ppt/_rels/presentation.xml.rels", PRESENTATION_RELS),
440            ("ppt/slides/slide1.xml", SLIDE),
441            ("ppt/slides/_rels/slide1.xml.rels", SLIDE_RELS),
442            ("ppt/slideLayouts/slideLayout1.xml", SLIDE_LAYOUT),
443            (
444                "ppt/slideLayouts/_rels/slideLayout1.xml.rels",
445                SLIDE_LAYOUT_RELS,
446            ),
447            ("ppt/slideMasters/slideMaster1.xml", SLIDE_MASTER),
448            (
449                "ppt/slideMasters/_rels/slideMaster1.xml.rels",
450                SLIDE_MASTER_RELS,
451            ),
452            ("ppt/theme/theme1.xml", THEME),
453        ])
454    }
455
456    fn pptx_package() -> OpcPackage {
457        let mut package =
458            OpcPackage::with_main_part("ppt/presentation.xml", crate::content_types::PRESENTATION);
459        package
460            .content_types
461            .add_override("/ppt/slides/slide1.xml", crate::content_types::SLIDE);
462        package.content_types.add_override(
463            "/ppt/slideLayouts/slideLayout1.xml",
464            crate::content_types::SLIDE_LAYOUT,
465        );
466        package.set_part("/ppt/presentation.xml", b"<p:presentation/>".to_vec());
467        package.set_part("/ppt/slides/slide1.xml", b"<p:sld/>".to_vec());
468        package.set_part(
469            "/ppt/slideLayouts/slideLayout1.xml",
470            b"<p:sldLayout/>".to_vec(),
471        );
472        package
473            .get_or_create_part_rels("/ppt/presentation.xml")
474            .add(rel_types::SLIDE, "slides/slide1.xml");
475        package
476            .get_or_create_part_rels("/ppt/slides/slide1.xml")
477            .add(rel_types::SLIDE_LAYOUT, "../slideLayouts/slideLayout1.xml");
478        package
479    }
480
481    #[test]
482    fn with_main_part_resolves_and_round_trips() {
483        let mut package = OpcPackage::with_main_part(
484            "ppt/presentation.xml",
485            "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml",
486        );
487        package.set_part("/ppt/presentation.xml", b"<p:presentation/>".to_vec());
488
489        assert_eq!(
490            package.main_document_part().as_deref(),
491            Some("/ppt/presentation.xml")
492        );
493        assert_eq!(
494            package
495                .content_types
496                .content_type_for("/ppt/presentation.xml"),
497            Some(
498                "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"
499            )
500        );
501
502        let mut buffer = std::io::Cursor::new(Vec::new());
503        package.write_to(&mut buffer).unwrap();
504        buffer.set_position(0);
505        let round_tripped = OpcPackage::from_reader(buffer).unwrap();
506
507        assert_eq!(
508            round_tripped.main_document_part().as_deref(),
509            Some("/ppt/presentation.xml")
510        );
511        assert_eq!(
512            round_tripped.get_part("/ppt/presentation.xml"),
513            Some(b"<p:presentation/>".as_slice())
514        );
515    }
516
517    #[test]
518    fn pptx_package_resolves_main_slide_and_layout_parts() {
519        let package = pptx_package();
520        let mut buffer = std::io::Cursor::new(Vec::new());
521        package.write_to(&mut buffer).unwrap();
522        buffer.set_position(0);
523        let reopened = OpcPackage::from_reader(buffer).unwrap();
524
525        let presentation_part = reopened.main_document_part().unwrap();
526        assert_eq!(presentation_part, "/ppt/presentation.xml");
527
528        let slide_target = &reopened
529            .get_part_rels(&presentation_part)
530            .unwrap()
531            .get_by_type(rel_types::SLIDE)
532            .unwrap()
533            .target;
534        let slide_part = OpcPackage::resolve_rel_target(&presentation_part, slide_target);
535        assert_eq!(slide_part, "/ppt/slides/slide1.xml");
536
537        let layout_target = &reopened
538            .get_part_rels(&slide_part)
539            .unwrap()
540            .get_by_type(rel_types::SLIDE_LAYOUT)
541            .unwrap()
542            .target;
543        let layout_part = OpcPackage::resolve_rel_target(&slide_part, layout_target);
544        assert_eq!(layout_part, "/ppt/slideLayouts/slideLayout1.xml");
545
546        assert!(reopened.parts.contains_key(&presentation_part));
547        assert!(reopened.parts.contains_key(&slide_part));
548        assert!(reopened.parts.contains_key(&layout_part));
549    }
550
551    #[test]
552    fn independently_built_pptx_opens_and_resolves_relationships() {
553        let package = OpcPackage::from_reader(independently_built_pptx()).unwrap();
554
555        let presentation_part = package.main_document_part().unwrap();
556        assert_eq!(presentation_part, "/ppt/presentation.xml");
557
558        let presentation_master_target = &package
559            .get_part_rels(&presentation_part)
560            .unwrap()
561            .get_by_type(rel_types::SLIDE_MASTER)
562            .unwrap()
563            .target;
564        let presentation_master_part =
565            OpcPackage::resolve_rel_target(&presentation_part, presentation_master_target);
566        assert_eq!(
567            presentation_master_part,
568            "/ppt/slideMasters/slideMaster1.xml"
569        );
570
571        let slide_target = &package
572            .get_part_rels(&presentation_part)
573            .unwrap()
574            .get_by_type(rel_types::SLIDE)
575            .unwrap()
576            .target;
577        let slide_part = OpcPackage::resolve_rel_target(&presentation_part, slide_target);
578        assert_eq!(slide_part, "/ppt/slides/slide1.xml");
579
580        let layout_target = &package
581            .get_part_rels(&slide_part)
582            .unwrap()
583            .get_by_type(rel_types::SLIDE_LAYOUT)
584            .unwrap()
585            .target;
586        let layout_part = OpcPackage::resolve_rel_target(&slide_part, layout_target);
587        assert_eq!(layout_part, "/ppt/slideLayouts/slideLayout1.xml");
588
589        let master_target = &package
590            .get_part_rels(&layout_part)
591            .unwrap()
592            .get_by_type(rel_types::SLIDE_MASTER)
593            .unwrap()
594            .target;
595        let master_part = OpcPackage::resolve_rel_target(&layout_part, master_target);
596        assert_eq!(master_part, "/ppt/slideMasters/slideMaster1.xml");
597        assert_eq!(master_part, presentation_master_part);
598
599        let master_layout_target = &package
600            .get_part_rels(&master_part)
601            .unwrap()
602            .get_by_type(rel_types::SLIDE_LAYOUT)
603            .unwrap()
604            .target;
605        let master_layout_part = OpcPackage::resolve_rel_target(&master_part, master_layout_target);
606        assert_eq!(master_layout_part, layout_part);
607
608        let theme_target = &package
609            .get_part_rels(&master_part)
610            .unwrap()
611            .get_by_type(rel_types::THEME)
612            .unwrap()
613            .target;
614        let theme_part = OpcPackage::resolve_rel_target(&master_part, theme_target);
615        assert_eq!(theme_part, "/ppt/theme/theme1.xml");
616
617        let master_xml = std::str::from_utf8(package.parts.get(&master_part).unwrap()).unwrap();
618        let layout_id = master_xml
619            .split_once("<p:sldLayoutId id=\"")
620            .unwrap()
621            .1
622            .split_once('"')
623            .unwrap()
624            .0
625            .parse::<u64>()
626            .unwrap();
627        assert!(layout_id >= 2_147_483_648);
628
629        assert!(package.parts.contains_key(&presentation_part));
630        assert!(package.parts.contains_key(&slide_part));
631        assert!(package.parts.contains_key(&layout_part));
632        assert!(package.parts.contains_key(&master_part));
633        assert!(package.parts.contains_key(&theme_part));
634    }
635
636    #[test]
637    fn presentation_layout_target_resolves_one_directory_up() {
638        assert_eq!(
639            OpcPackage::resolve_rel_target(
640                "/ppt/slides/slide1.xml",
641                "../slideLayouts/slideLayout1.xml"
642            ),
643            "/ppt/slideLayouts/slideLayout1.xml"
644        );
645    }
646
647    #[test]
648    fn rels_path_conversion() {
649        assert_eq!(
650            rels_path_to_part_name("word/_rels/document.xml.rels"),
651            "/word/document.xml"
652        );
653        assert_eq!(
654            part_name_to_rels_path("/word/document.xml"),
655            "word/_rels/document.xml.rels"
656        );
657    }
658
659    #[test]
660    fn resolve_relative_target() {
661        assert_eq!(
662            OpcPackage::resolve_rel_target("/word/document.xml", "styles.xml"),
663            "/word/styles.xml"
664        );
665        assert_eq!(
666            OpcPackage::resolve_rel_target("/word/document.xml", "/word/styles.xml"),
667            "/word/styles.xml"
668        );
669    }
670
671    #[test]
672    fn resolve_target_collapses_parent_segments() {
673        // Charts and headers routinely reference media through a parent dir.
674        assert_eq!(
675            OpcPackage::resolve_rel_target("/word/charts/chart1.xml", "../media/image1.png"),
676            "/word/media/image1.png"
677        );
678        assert_eq!(
679            OpcPackage::resolve_rel_target("/word/document.xml", "./styles.xml"),
680            "/word/styles.xml"
681        );
682        assert_eq!(
683            OpcPackage::resolve_rel_target("/word/document.xml", "../../../etc/passwd"),
684            "/etc/passwd"
685        );
686    }
687
688    #[test]
689    fn zip_entry_that_escapes_root_is_clamped_to_root() {
690        let archive = package_zip(&[
691            ("[Content_Types].xml", MINIMAL_CONTENT_TYPES),
692            ("../../etc/passwd", b"not a real password file"),
693        ]);
694
695        let package = OpcPackage::from_reader(archive).unwrap();
696
697        assert_eq!(
698            package.get_part("/etc/passwd"),
699            Some(b"not a real password file".as_slice())
700        );
701        assert!(package.parts.keys().all(|name| !name.contains("..")));
702    }
703
704    #[test]
705    fn absolute_zip_entry_is_normalized_to_package_root() {
706        let archive = package_zip(&[
707            ("/[Content_Types].xml", MINIMAL_CONTENT_TYPES),
708            ("/absolute/path.xml", b"<absolute/>"),
709        ]);
710
711        let package = OpcPackage::from_reader(archive).unwrap();
712
713        assert_eq!(
714            package.get_part("/absolute/path.xml"),
715            Some(b"<absolute/>".as_slice())
716        );
717        assert_eq!(
718            package
719                .parts
720                .keys()
721                .filter(|name| name.as_str() == "/absolute/path.xml")
722                .count(),
723            1
724        );
725    }
726
727    #[test]
728    fn rels_path_suffix_is_stripped_once() {
729        assert_eq!(
730            rels_path_to_part_name("word/_rels/document.xml.rels"),
731            "/word/document.xml"
732        );
733        // A part whose own name ends in ".rels" must keep that segment.
734        assert_eq!(
735            rels_path_to_part_name("word/_rels/odd.rels.rels"),
736            "/word/odd.rels"
737        );
738    }
739
740    #[test]
741    fn saved_packages_are_byte_identical() {
742        let mut pkg = docx_package();
743        for i in 0..40 {
744            pkg.set_part(&format!("/word/media/image{i}.png"), vec![i as u8]);
745        }
746        pkg.get_or_create_part_rels("/word/document.xml")
747            .add(rel_types::STYLES, "styles.xml");
748
749        let write = || {
750            let mut buf = std::io::Cursor::new(Vec::new());
751            pkg.write_to(&mut buf).unwrap();
752            buf.into_inner()
753        };
754        assert_eq!(write(), write());
755    }
756
757    #[test]
758    fn new_docx_package() {
759        let pkg = docx_package();
760        assert!(pkg.main_document_part().is_some());
761        assert_eq!(pkg.main_document_part().unwrap(), "/word/document.xml");
762    }
763
764    #[test]
765    fn round_trip_package() {
766        let mut pkg = docx_package();
767        pkg.set_part("/word/document.xml", b"<document/>".to_vec());
768
769        // Write to memory
770        let mut buf = std::io::Cursor::new(Vec::new());
771        pkg.write_to(&mut buf).unwrap();
772
773        // Read back
774        buf.set_position(0);
775        let pkg2 = OpcPackage::from_reader(buf).unwrap();
776        assert_eq!(
777            pkg2.get_part("/word/document.xml"),
778            Some(b"<document/>".as_slice())
779        );
780        assert!(pkg2.main_document_part().is_some());
781    }
782}