use std::collections::HashMap;
use std::io::{Read, Seek, Write};
use std::path::Path;
use zip::ZipWriter;
use zip::read::ZipArchive;
use zip::write::SimpleFileOptions;
use crate::content_types::ContentTypes;
use crate::error::{OpcError, Result};
use crate::relationship::{Relationships, rel_types};
#[derive(Debug, Clone)]
pub struct PackagePart {
pub name: String,
pub data: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct OpcPackage {
pub content_types: ContentTypes,
pub package_rels: Relationships,
pub part_rels: HashMap<String, Relationships>,
pub parts: HashMap<String, Vec<u8>>,
}
impl OpcPackage {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let file = std::fs::File::open(path)?;
Self::from_reader(file)
}
pub fn from_reader<R: Read + Seek>(reader: R) -> Result<Self> {
let mut archive = ZipArchive::new(reader)?;
let mut raw_parts: HashMap<String, Vec<u8>> = HashMap::new();
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
if entry.is_dir() {
continue;
}
let name = entry.name().to_string();
let mut data = Vec::new();
entry.read_to_end(&mut data)?;
raw_parts.insert(name, data);
}
let ct_xml = raw_parts
.get("[Content_Types].xml")
.ok_or_else(|| OpcError::PartNotFound("[Content_Types].xml".into()))?;
let content_types = ContentTypes::from_xml(ct_xml)?;
let package_rels = if let Some(rels_xml) = raw_parts.get("_rels/.rels") {
Relationships::from_xml(rels_xml)?
} else {
Relationships::new()
};
let mut part_rels = HashMap::new();
let rels_entries: Vec<String> = raw_parts
.keys()
.filter(|k| k.ends_with(".rels") && *k != "_rels/.rels")
.cloned()
.collect();
for rels_path in rels_entries {
if let Some(xml_data) = raw_parts.get(&rels_path) {
let rels = Relationships::from_xml(xml_data)?;
let part_name = rels_path_to_part_name(&rels_path);
part_rels.insert(part_name, rels);
}
}
let mut parts = HashMap::new();
for (name, data) in &raw_parts {
if name == "[Content_Types].xml" || name == "_rels/.rels" || name.ends_with(".rels") {
continue;
}
let normalized = if name.starts_with('/') {
name.clone()
} else {
format!("/{name}")
};
parts.insert(normalized, data.clone());
}
Ok(OpcPackage {
content_types,
package_rels,
part_rels,
parts,
})
}
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let file = std::fs::File::create(path)?;
self.write_to(file)
}
pub fn write_to<W: Write + Seek>(&self, writer: W) -> Result<()> {
let mut zip = ZipWriter::new(writer);
let options =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let ct_xml = self.content_types.to_xml()?;
zip.start_file("[Content_Types].xml", options)?;
zip.write_all(&ct_xml)?;
let pkg_rels_xml = self.package_rels.to_xml()?;
zip.start_file("_rels/.rels", options)?;
zip.write_all(&pkg_rels_xml)?;
let mut rels_names: Vec<&String> = self.part_rels.keys().collect();
rels_names.sort_unstable();
for part_name in rels_names {
let rels = &self.part_rels[part_name];
let rels_path = part_name_to_rels_path(part_name);
let rels_xml = rels.to_xml()?;
zip.start_file(&rels_path, options)?;
zip.write_all(&rels_xml)?;
}
let mut part_names: Vec<&String> = self.parts.keys().collect();
part_names.sort_unstable();
for name in part_names {
let zip_name = name.strip_prefix('/').unwrap_or(name);
zip.start_file(zip_name, options)?;
zip.write_all(&self.parts[name])?;
}
zip.finish()?;
Ok(())
}
pub fn get_part(&self, part_name: &str) -> Option<&[u8]> {
self.parts.get(part_name).map(|v| v.as_slice())
}
pub fn set_part(&mut self, part_name: &str, data: Vec<u8>) {
self.parts.insert(part_name.to_string(), data);
}
pub fn get_part_rels(&self, part_name: &str) -> Option<&Relationships> {
self.part_rels.get(part_name)
}
pub fn get_or_create_part_rels(&mut self, part_name: &str) -> &mut Relationships {
self.part_rels.entry(part_name.to_string()).or_default()
}
pub fn resolve_rel_target(source_part: &str, rel_target: &str) -> String {
let joined = if rel_target.starts_with('/') {
rel_target.to_string()
} else {
let dir = match source_part.rfind('/') {
Some(pos) => &source_part[..=pos],
None => "/",
};
format!("{dir}{rel_target}")
};
normalize_part_name(&joined)
}
pub fn main_document_part(&self) -> Option<String> {
self.package_rels
.get_by_type(rel_types::DOCUMENT)
.map(|rel| {
if rel.target.starts_with('/') {
rel.target.clone()
} else {
format!("/{}", rel.target)
}
})
}
pub fn new_docx() -> Self {
let content_types = ContentTypes::new_docx();
let mut package_rels = Relationships::new();
package_rels.add(rel_types::DOCUMENT, "word/document.xml");
OpcPackage {
content_types,
package_rels,
part_rels: HashMap::new(),
parts: HashMap::new(),
}
}
}
fn normalize_part_name(path: &str) -> String {
if !path.contains("./") && !path.ends_with("/.") && !path.ends_with("/..") {
return path.to_string();
}
let mut segments: Vec<&str> = Vec::new();
for segment in path.split('/') {
match segment {
"" | "." => {}
".." => {
segments.pop();
}
other => segments.push(other),
}
}
let mut out = String::with_capacity(path.len());
for segment in segments {
out.push('/');
out.push_str(segment);
}
if out.is_empty() { "/".to_string() } else { out }
}
fn rels_path_to_part_name(rels_path: &str) -> String {
let without_suffix = rels_path.strip_suffix(".rels").unwrap_or(rels_path);
let path = match without_suffix.rfind("_rels/") {
Some(pos) => format!(
"{}{}",
&without_suffix[..pos],
&without_suffix[pos + "_rels/".len()..]
),
None => without_suffix.to_string(),
};
if path.starts_with('/') {
path
} else {
format!("/{path}")
}
}
fn part_name_to_rels_path(part_name: &str) -> String {
let name = part_name.strip_prefix('/').unwrap_or(part_name);
if let Some(pos) = name.rfind('/') {
let dir = &name[..pos];
let file = &name[pos + 1..];
format!("{dir}/_rels/{file}.rels")
} else {
format!("_rels/{name}.rels")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rels_path_conversion() {
assert_eq!(
rels_path_to_part_name("word/_rels/document.xml.rels"),
"/word/document.xml"
);
assert_eq!(
part_name_to_rels_path("/word/document.xml"),
"word/_rels/document.xml.rels"
);
}
#[test]
fn resolve_relative_target() {
assert_eq!(
OpcPackage::resolve_rel_target("/word/document.xml", "styles.xml"),
"/word/styles.xml"
);
assert_eq!(
OpcPackage::resolve_rel_target("/word/document.xml", "/word/styles.xml"),
"/word/styles.xml"
);
}
#[test]
fn resolve_target_collapses_parent_segments() {
assert_eq!(
OpcPackage::resolve_rel_target("/word/charts/chart1.xml", "../media/image1.png"),
"/word/media/image1.png"
);
assert_eq!(
OpcPackage::resolve_rel_target("/word/document.xml", "./styles.xml"),
"/word/styles.xml"
);
assert_eq!(
OpcPackage::resolve_rel_target("/word/document.xml", "../../../etc/passwd"),
"/etc/passwd"
);
}
#[test]
fn rels_path_suffix_is_stripped_once() {
assert_eq!(
rels_path_to_part_name("word/_rels/document.xml.rels"),
"/word/document.xml"
);
assert_eq!(
rels_path_to_part_name("word/_rels/odd.rels.rels"),
"/word/odd.rels"
);
}
#[test]
fn saved_packages_are_byte_identical() {
let mut pkg = OpcPackage::new_docx();
for i in 0..40 {
pkg.set_part(&format!("/word/media/image{i}.png"), vec![i as u8]);
}
pkg.get_or_create_part_rels("/word/document.xml")
.add(rel_types::STYLES, "styles.xml");
let write = || {
let mut buf = std::io::Cursor::new(Vec::new());
pkg.write_to(&mut buf).unwrap();
buf.into_inner()
};
assert_eq!(write(), write());
}
#[test]
fn new_docx_package() {
let pkg = OpcPackage::new_docx();
assert!(pkg.main_document_part().is_some());
assert_eq!(pkg.main_document_part().unwrap(), "/word/document.xml");
}
#[test]
fn round_trip_package() {
let mut pkg = OpcPackage::new_docx();
pkg.set_part("/word/document.xml", b"<document/>".to_vec());
let mut buf = std::io::Cursor::new(Vec::new());
pkg.write_to(&mut buf).unwrap();
buf.set_position(0);
let pkg2 = OpcPackage::from_reader(buf).unwrap();
assert_eq!(
pkg2.get_part("/word/document.xml"),
Some(b"<document/>".as_slice())
);
assert!(pkg2.main_document_part().is_some());
}
}