use std::{
collections::BTreeSet,
fmt::{self, Debug},
io::{self, Write},
iter,
};
use bytes::Bytes;
use thiserror::Error;
use crate::{
image::Image,
opc,
package::{DECOMPRESSION_BUDGET, InvalidZip, MAX_ENTRY_NAME_LEN, Package},
replace,
table::Table,
xml::{Element, InvalidXml, NODE_BUDGET, Node, Ns, W_URI, XmlDocument},
};
const OFFICE_DOCUMENT: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument";
const HEADER: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
const FOOTER: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
const FOOTNOTES: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes";
const ENDNOTES: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes";
const STORY_TYPES: [&str; 4] = [HEADER, FOOTER, FOOTNOTES, ENDNOTES];
const COMMENTS: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
const GLOSSARY_DOCUMENT: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/glossaryDocument";
const DRAWING_ID_TYPES: [&str; 2] = [COMMENTS, GLOSSARY_DOCUMENT];
const SETTINGS: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings";
const SETTINGS_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml";
const AFTER_UPDATE_FIELDS: [&str; 19] = [
"hdrShapeDefaults",
"footnotePr",
"endnotePr",
"compat",
"docVars",
"rsids",
"attachedSchema",
"themeFontLang",
"clrSchemeMapping",
"doNotIncludeSubdocsInStats",
"doNotAutoCompressPictures",
"forceUpgrade",
"captions",
"readModeInkLockDown",
"smartTagType",
"shapeDefaults",
"doNotEmbedSmartTags",
"decimalSymbol",
"listSeparator",
];
const IMAGE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum InvalidDocx {
#[error("unreadable ZIP archive")]
Zip(#[source] InvalidZip),
#[error("unreadable XML part `{}`", .part.escape_debug())]
Xml {
part: String,
#[source]
source: InvalidXml,
},
#[error("missing part `{}`", .0.escape_debug())]
MissingPart(String),
#[error("not a WordprocessingML document")]
NotWordprocessingMl,
}
#[derive(Debug, Error)]
#[error("table placeholder `{0}` must be the only content of its paragraph")]
#[non_exhaustive]
pub struct InvalidPlaceholder(String);
impl InvalidPlaceholder {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_inner(self) -> String {
self.0
}
}
#[derive(Clone)]
pub struct Docx {
package: Package,
content_types: Part,
stories: Vec<Story>,
media: Vec<(String, Bytes)>,
media_names: BTreeSet<String>,
drawing_ids: DrawingIds,
settings: Settings,
}
#[derive(Clone)]
struct Part {
name: String,
xml: XmlDocument,
dirty: bool,
}
#[derive(Clone)]
enum Settings {
Part(Part),
Absent,
Missing(String),
Unusable,
}
#[derive(Clone)]
struct Story {
part: Part,
rels: Option<Part>,
}
impl Story {
fn targets(&self, types: &[&str]) -> impl Iterator<Item = String> {
self.rels
.iter()
.flat_map(|rels| opc::relationships(&rels.xml.root))
.filter(move |rel| !rel.is_external() && types.contains(&&*rel.rel_type))
.map(|rel| opc::resolve_target(&self.part.name, &rel.target))
}
fn can_add_relationship(&self) -> bool {
self.rels.is_some() || opc::rels_part_name(&self.part.name).len() <= MAX_ENTRY_NAME_LEN
}
fn fresh_relationship_id(&self) -> String {
opc::fresh_relationship_id(self.rels.as_ref().map(|rels| &rels.xml.root))
}
fn add_relationship(&mut self, id: &str, rel_type: &str, part: &str) -> bool {
let created = self.rels.is_none();
let rels = self.rels.get_or_insert_with(|| Part {
name: opc::rels_part_name(&self.part.name),
xml: opc::new_relationships(),
dirty: true,
});
let target = opc::relative_target(&self.part.name, part);
opc::add_relationship(&mut rels.xml.root, id, rel_type, &target);
rels.dirty = true;
created
}
}
impl Docx {
pub fn from_bytes(bytes: impl Into<Bytes>) -> Result<Self, InvalidDocx> {
let budget = Budget {
bytes: DECOMPRESSION_BUDGET,
nodes: NODE_BUDGET,
};
Self::open(bytes.into(), budget)
}
fn open(bytes: Bytes, mut budget: Budget) -> Result<Self, InvalidDocx> {
let package = Package::parse(bytes).map_err(InvalidDocx::Zip)?;
let content_types = read_part(&package, opc::CONTENT_TYPES, &mut budget)?;
let package_rels = read_part(&package, opc::PACKAGE_RELS, &mut budget)?;
let main_name = opc::relationships(&package_rels.xml.root)
.find(|rel| rel.rel_type == OFFICE_DOCUMENT && !rel.is_external())
.map(|rel| opc::resolve_target("", &rel.target))
.filter(|name| !is_reserved(name))
.ok_or(InvalidDocx::NotWordprocessingMl)?;
let main = read_part(&package, &main_name, &mut budget)?;
if !main.xml.root.is(Ns::W, "document") {
return Err(InvalidDocx::NotWordprocessingMl);
}
let main = Story {
rels: read_rels(&package, &main_name, &mut budget)?,
part: main,
};
let targets = |types: &[&str]| -> Vec<String> {
main.targets(types)
.filter(|name| !is_reserved(name))
.collect()
};
let story_names = targets(&STORY_TYPES);
let drawing_id_names = targets(&DRAWING_ID_TYPES);
let settings_name = main.targets(&[SETTINGS]).next();
let mut seen = BTreeSet::from([main_name.to_ascii_lowercase()]);
let mut stories = vec![main];
for name in story_names {
if seen.insert(name.to_ascii_lowercase()) {
let part = read_part(&package, &name, &mut budget)?;
let rels = read_rels(&package, &name, &mut budget)?;
stories.push(Story { part, rels });
}
}
let settings = read_settings(&package, settings_name, &mut seen, &mut budget)?;
let mut drawing_ids = DrawingIds::default();
for story in &stories {
drawing_ids.collect(&story.part.xml.root);
}
for name in drawing_id_names {
if seen.insert(name.to_ascii_lowercase())
&& let Some(part) = read_optional_part(&package, &name, &mut budget)?
{
drawing_ids.collect(&part.xml.root);
}
}
Ok(Self {
package,
content_types,
stories,
media: Vec::new(),
media_names: BTreeSet::new(),
drawing_ids,
settings,
})
}
pub fn replace_text<I, K, V>(&mut self, replacements: I) -> usize
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
let replacements: Vec<(K, V)> = replacements.into_iter().collect();
let pairs: Vec<(&str, &str)> = replacements
.iter()
.map(|(key, value)| (key.as_ref(), value.as_ref()))
.collect();
let mut total = 0_usize;
for story in &mut self.stories {
let count = replace::replace_text(&mut story.part.xml.root, &pairs);
if count > 0 {
story.part.dirty = true;
total = total.saturating_add(count);
}
}
total
}
pub fn replace_with_image(&mut self, placeholder: &str, image: &Image) -> usize {
if placeholder.is_empty() {
return 0;
}
let Some(media_name) = self.new_media_name(image.extension()) else {
return 0;
};
let mut total = 0_usize;
let mut created_rels = false;
for story in &mut self.stories {
if !story.can_add_relationship() {
continue;
}
let rel_id = story.fresh_relationship_id();
let drawing_ids = &mut self.drawing_ids;
let count =
replace::replace_with_element(&mut story.part.xml.root, placeholder, || {
parse_snippet(&image.drawing_xml(&rel_id, drawing_ids.allocate()))
});
if count == 0 {
continue;
}
story.part.dirty = true;
total = total.saturating_add(count);
created_rels |= story.add_relationship(&rel_id, IMAGE, &media_name);
}
if total == 0 {
return 0;
}
let types = &mut self.content_types;
types.dirty |=
opc::ensure_default(&mut types.xml.root, image.extension(), image.content_type());
types.dirty |= created_rels
&& opc::ensure_default(&mut types.xml.root, "rels", opc::RELS_CONTENT_TYPE);
self.media_names.insert(media_name.to_ascii_lowercase());
self.media.push((media_name, image.data().clone()));
total
}
pub fn replace_with_table(
&mut self,
placeholder: &str,
table: &Table,
) -> Result<usize, InvalidPlaceholder> {
if placeholder.is_empty() {
return Ok(0);
}
let alone = self
.stories
.iter()
.all(|story| replace::table_placeholders_are_alone(&story.part.xml.root, placeholder));
if !alone {
return Err(InvalidPlaceholder(placeholder.to_owned()));
}
let table = parse_snippet(&table.to_xml());
let mut total = 0_usize;
for story in &mut self.stories {
let count = replace::replace_paragraphs_with_table(
&mut story.part.xml.root,
placeholder,
&table,
);
if count > 0 {
story.part.dirty = true;
total = total.saturating_add(count);
}
}
Ok(total)
}
pub fn update_fields_on_open(&mut self) {
let target = match &mut self.settings {
Settings::Part(part) => {
if switch_on_update_fields(&mut part.xml.root) {
part.dirty = true;
}
return;
}
Settings::Unusable => return,
Settings::Absent => None,
Settings::Missing(name) => Some(std::mem::take(name)),
};
self.settings = self.create_settings(target);
}
pub fn write_to<W: Write + ?Sized>(&self, writer: &mut W) -> io::Result<()> {
self.package.write(writer, &self.changed_parts()).map(drop)
}
#[must_use]
#[expect(
clippy::missing_panics_doc,
reason = "writing to a Vec only fails on invalid part names, which are never generated"
)]
pub fn to_bytes(&self) -> Bytes {
let archive = self
.package
.write(Vec::new(), &self.changed_parts())
.expect("parts to write have distinct, canonical names that fit in a ZIP archive");
Bytes::from(archive)
}
fn new_media_name(&self, extension: &str) -> Option<String> {
let main = self.stories.first()?;
let prefix = opc::join(opc::directory(&main.part.name), "media/image");
let candidates = (1..=u64::MAX).map(|n| format!("{prefix}{n}.{extension}"));
free_name(candidates, |name| {
self.package.contains(name) || self.media_names.contains(&name.to_ascii_lowercase())
})
}
fn create_settings(&mut self, target: Option<String>) -> Settings {
let Some(main) = self.stories.first_mut() else {
return Settings::Unusable;
};
let types = &mut self.content_types;
let name = if let Some(name) = target {
name
} else {
if !main.can_add_relationship() {
return Settings::Unusable;
}
let Some(name) = free_settings_name(&self.package, opc::directory(&main.part.name))
else {
return Settings::Unusable;
};
let id = main.fresh_relationship_id();
let created_rels = main.add_relationship(&id, SETTINGS, &name);
types.dirty |= created_rels
&& opc::ensure_default(&mut types.xml.root, "rels", opc::RELS_CONTENT_TYPE);
name
};
types.dirty |= opc::ensure_override(&mut types.xml.root, &name, SETTINGS_CONTENT_TYPE);
let mut root = Element::new("w:settings", Ns::W);
root.set_attr("xmlns:w", W_URI);
switch_on_update_fields(&mut root);
Settings::Part(Part {
name,
xml: XmlDocument::new(root),
dirty: true,
})
}
fn parts(&self) -> impl Iterator<Item = &Part> {
let settings = match &self.settings {
Settings::Part(part) => Some(part),
Settings::Absent | Settings::Missing(_) | Settings::Unusable => None,
};
iter::once(&self.content_types)
.chain(
self.stories
.iter()
.flat_map(|story| iter::once(&story.part).chain(&story.rels)),
)
.chain(settings)
}
fn changed_parts(&self) -> Vec<(String, Bytes)> {
self.parts()
.filter(|part| part.dirty)
.map(|part| (part.name.clone(), Bytes::from(part.xml.to_bytes())))
.chain(self.media.iter().cloned())
.collect()
}
}
impl Debug for Docx {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let stories: Vec<&str> = self.stories.iter().map(|story| &*story.part.name).collect();
let changed: Vec<&str> = self
.parts()
.filter(|part| part.dirty)
.map(|part| &*part.name)
.chain(self.media.iter().map(|(name, _)| &**name))
.collect();
f.debug_struct("Docx")
.field("parts", &self.package.names().collect::<Vec<_>>())
.field("stories", &stories)
.field("changed", &changed)
.finish_non_exhaustive()
}
}
struct Budget {
bytes: usize,
nodes: usize,
}
fn read_part(package: &Package, name: &str, budget: &mut Budget) -> Result<Part, InvalidDocx> {
read_optional_part(package, name, budget)?
.ok_or_else(|| InvalidDocx::MissingPart(name.to_owned()))
}
fn read_optional_part(
package: &Package,
name: &str,
budget: &mut Budget,
) -> Result<Option<Part>, InvalidDocx> {
let Some(content) = package
.read(name, &mut budget.bytes)
.map_err(InvalidDocx::Zip)?
else {
return Ok(None);
};
let xml = XmlDocument::parse_with_budget(&content, &mut budget.nodes).map_err(|source| {
InvalidDocx::Xml {
part: name.to_owned(),
source,
}
})?;
Ok(Some(Part {
name: name.to_owned(),
xml,
dirty: false,
}))
}
fn read_rels(
package: &Package,
part: &str,
budget: &mut Budget,
) -> Result<Option<Part>, InvalidDocx> {
read_optional_part(package, &opc::rels_part_name(part), budget)
}
fn read_settings(
package: &Package,
name: Option<String>,
seen: &mut BTreeSet<String>,
budget: &mut Budget,
) -> Result<Settings, InvalidDocx> {
let Some(name) = name else {
return Ok(Settings::Absent);
};
if is_reserved(&name) {
return Ok(Settings::Unusable);
}
if !seen.insert(name.to_ascii_lowercase()) {
return Ok(Settings::Unusable);
}
let settings = match read_optional_part(package, &name, budget)? {
Some(part) if part.xml.root.is(Ns::W, "settings") => Settings::Part(part),
None if is_plain_xml_name(&name) => Settings::Missing(name),
_ => Settings::Unusable,
};
Ok(settings)
}
fn is_reserved(name: &str) -> bool {
name.is_empty() || name.eq_ignore_ascii_case(opc::CONTENT_TYPES) || opc::is_rels_part(name)
}
fn is_plain_xml_name(name: &str) -> bool {
let plain_segment = |segment: &str| {
!matches!(segment, "" | "." | "..")
&& segment
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
};
name.len() <= MAX_ENTRY_NAME_LEN
&& name.split('/').all(plain_segment)
&& name
.rsplit_once('.')
.is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("xml"))
}
fn free_settings_name(package: &Package, directory: &str) -> Option<String> {
let prefix = opc::join(directory, "settings");
let candidates = iter::once(format!("{prefix}.xml"))
.chain((1..=u64::MAX).map(|n| format!("{prefix}{n}.xml")));
free_name(candidates, |name| package.contains(name))
}
fn free_name(
mut candidates: impl Iterator<Item = String>,
taken: impl Fn(&str) -> bool,
) -> Option<String> {
candidates
.find(|name| !taken(name))
.filter(|name| name.len() <= MAX_ENTRY_NAME_LEN)
}
fn switch_on_update_fields(settings: &mut Element) -> bool {
let prefix = settings.prefix();
let element = new_update_fields(prefix);
if only_update_fields(settings)
.is_some_and(|only| *only == element || is_plainly_on(only, prefix))
{
return false;
}
let is_update_fields = |node: &Node| {
node.as_element()
.is_some_and(|child| child.is(Ns::W, "updateFields"))
};
let position = settings
.children
.iter()
.position(is_update_fields)
.unwrap_or_else(|| {
settings
.children
.iter()
.position(|node| {
node.as_element().is_some_and(|child| {
child.ns() != Ns::W || AFTER_UPDATE_FIELDS.contains(&child.local_name())
})
})
.unwrap_or(settings.children.len())
});
settings.children.retain(|node| !is_update_fields(node));
settings.children.insert(position, Node::Element(element));
true
}
fn only_update_fields(settings: &Element) -> Option<&Element> {
let mut existing = settings
.child_elements()
.filter(|child| child.is(Ns::W, "updateFields"));
match (existing.next(), existing.next()) {
(Some(only), None) => Some(only),
_ => None,
}
}
fn is_plainly_on(element: &Element, root_prefix: Option<&str>) -> bool {
if element.prefix() != root_prefix {
return false;
}
let mut vals = element
.attr_names()
.filter(|name| name.rsplit_once(':').map_or(*name, |(_, local)| local) == "val");
match (vals.next(), vals.next()) {
(None, _) => true,
(Some(name), None) => {
root_prefix.is_some_and(|prefix| name.strip_prefix(prefix) == Some(":val"))
&& element
.attr(name)
.is_some_and(|value| matches!(&*value, "true" | "1" | "on"))
}
(Some(_), Some(_)) => false,
}
}
fn new_update_fields(root_prefix: Option<&str>) -> Element {
let prefix = root_prefix.unwrap_or("w");
let mut element = Element::new(format!("{prefix}:updateFields"), Ns::W);
if root_prefix.is_none() {
element.set_attr("xmlns:w", W_URI);
}
element.set_attr(&format!("{prefix}:val"), "true");
element
}
fn parse_snippet(xml: &str) -> Element {
Element::parse_fragment(xml).expect("generated snippets are well-formed elements")
}
#[derive(Clone, Debug, Default)]
struct DrawingIds {
used: BTreeSet<u32>,
next: u32,
}
impl DrawingIds {
fn collect(&mut self, root: &Element) {
self.used.extend(
root.descendants()
.filter(|element| element.is(Ns::Wp, "docPr"))
.filter_map(|element| element.attr("id")?.trim().parse::<u32>().ok()),
);
self.next = self
.used
.last()
.map_or(1, |largest| largest.saturating_add(1));
}
fn allocate(&mut self) -> u32 {
loop {
let id = self.next;
self.next = self.next.checked_add(1).unwrap_or(1);
if id != 0 && self.used.insert(id) {
return id;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn element(xml: &str) -> Element {
Element::parse_fragment(xml).unwrap()
}
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const WP: &str = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
const RELS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
#[test]
fn drawing_ids_continue_after_the_largest() {
let body = element(&format!(
"<body xmlns:wp=\"{WP}\"><wp:docPr id=\"4\"/><x><wp:docPr id=\" 9 \"/></x>\
<wp:docPr id=\"not a number\"/><wp:docPr/><docPr id=\"77\"/></body>"
));
let mut ids = DrawingIds::default();
ids.collect(&body);
assert_eq!(ids.allocate(), 10);
assert_eq!(ids.allocate(), 11);
}
#[test]
fn drawing_ids_are_matched_by_namespace_not_prefix() {
let body = element(&format!(
"<body xmlns:wp=\"{WP}\"><wp:docPr id=\"4\"/></body>"
));
let header = element(&format!(
"<hdr xmlns:w=\"{WP}\" xmlns:wp=\"urn:other\">\
<w:docPr id=\"12\"/><wp:docPr id=\"20\"/></hdr>"
));
let mut ids = DrawingIds::default();
ids.collect(&body);
ids.collect(&header);
assert_eq!(ids.allocate(), 13);
assert_eq!(ids.allocate(), 14);
}
#[test]
fn drawing_ids_start_at_one() {
let mut ids = DrawingIds::default();
ids.collect(&element("<body/>"));
assert_eq!(ids.allocate(), 1);
assert_eq!(ids.allocate(), 2);
}
#[test]
fn drawing_ids_wrap_around_to_free_ids() {
let body = element(&format!(
"<body xmlns:wp=\"{WP}\"><wp:docPr id=\"4294967295\"/><wp:docPr id=\"1\"/>\
<wp:docPr id=\"3\"/></body>"
));
let mut ids = DrawingIds::default();
ids.collect(&body);
assert_eq!(ids.allocate(), 2);
assert_eq!(ids.allocate(), 4);
assert_eq!(ids.allocate(), 5);
}
fn zip(parts: &[(&str, String)]) -> Bytes {
let mut writer = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
for (name, content) in parts {
let options = zip::write::SimpleFileOptions::default();
writer.start_file(*name, options).unwrap();
writer.write_all(content.as_bytes()).unwrap();
}
writer.finish().unwrap().into_inner().into()
}
fn parts(relationships: &str) -> Vec<(&'static str, String)> {
vec![
(
"[Content_Types].xml",
"<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\
<Default Extension=\"xml\" ContentType=\"application/xml\"/></Types>"
.to_owned(),
),
(
"_rels/.rels",
format!(
"<Relationships xmlns=\"{RELS}\"><Relationship Id=\"rId1\" \
Type=\"{OFFICE_DOCUMENT}\" Target=\"word/document.xml\"/></Relationships>"
),
),
(
"word/document.xml",
format!("<w:document xmlns:w=\"{W}\"><w:body><w:p/></w:body></w:document>"),
),
(
"word/_rels/document.xml.rels",
format!("<Relationships xmlns=\"{RELS}\">{relationships}</Relationships>"),
),
(
"word/header1.xml",
format!("<w:hdr xmlns:w=\"{W}\"><w:p>\n</w:p></w:hdr>"),
),
]
}
#[test]
fn parts_read_share_one_budget() {
let parts = parts(&format!(
"<Relationship Id=\"rId1\" Type=\"{HEADER}\" Target=\"header1.xml\"/>"
));
let zip = zip(&parts);
let bytes: usize = parts.iter().map(|(_, xml)| xml.len()).sum();
let nodes: usize = parts
.iter()
.map(|(_, xml)| {
let mut left = usize::MAX;
XmlDocument::parse_with_budget(xml.as_bytes(), &mut left).unwrap();
usize::MAX - left
})
.sum();
assert!(nodes > 10);
Docx::open(zip.clone(), Budget { bytes, nodes }).unwrap();
let err = Docx::open(
zip.clone(),
Budget {
bytes,
nodes: nodes - 1,
},
)
.unwrap_err();
assert!(
matches!(&err, InvalidDocx::Xml { part, .. } if part == "word/header1.xml"),
"{err:?}"
);
let err = Docx::open(
zip,
Budget {
bytes: bytes - 1,
nodes,
},
)
.unwrap_err();
assert!(matches!(err, InvalidDocx::Zip(_)), "{err:?}");
}
#[test]
fn reserved_names_are_not_stories() {
assert!(is_reserved(""));
assert!(is_reserved("[Content_Types].xml"));
assert!(is_reserved("[content_types].XML"));
assert!(is_reserved("_rels/.rels"));
assert!(is_reserved("word/_rels/document.xml.rels"));
assert!(!is_reserved("word/header1.xml"));
assert!(!is_reserved("word/rels.xml"));
}
#[test]
fn relationships_to_package_parts_are_not_stories() {
let parts = parts(&format!(
"<Relationship Id=\"rId1\" Type=\"{HEADER}\" Target=\"/[Content_Types].xml\"/>\
<Relationship Id=\"rId2\" Type=\"{FOOTER}\" Target=\"_rels/document.xml.rels\"/>\
<Relationship Id=\"rId3\" Type=\"{FOOTER}\" Target=\"/\"/>\
<Relationship Id=\"rId4\" Type=\"{HEADER}\" Target=\"header1.xml\"/>"
));
let docx = Docx::from_bytes(zip(&parts)).unwrap();
let stories: Vec<&str> = docx.stories.iter().map(|story| &*story.part.name).collect();
assert_eq!(stories, ["word/document.xml", "word/header1.xml"]);
}
#[test]
fn invalid_placeholder_gives_back_the_placeholder() {
let err = InvalidPlaceholder("{{items}}".to_owned());
assert_eq!(
err.to_string(),
"table placeholder `{{items}}` must be the only content of its paragraph"
);
assert_eq!(err.as_str(), "{{items}}");
assert_eq!(err.into_inner(), "{{items}}");
}
#[test]
fn error_messages_escape_part_names() {
assert_eq!(
InvalidDocx::MissingPart("word/header1.xml".to_owned()).to_string(),
"missing part `word/header1.xml`"
);
assert_eq!(
InvalidDocx::MissingPart("a\nb\u{202e}".to_owned()).to_string(),
"missing part `a\\nb\\u{202e}`"
);
}
}