use std::io::{Seek, Write};
use opc::{Package, Part, Relationship, Relationships, TargetMode};
use xml_core::{BytesDecl, BytesEnd, BytesStart, BytesText, Event, Writer};
use crate::error::Result;
use crate::model::{
Alignment, Block, Bookmark, BreakKind, ColorScheme, Comment, CustomPropertyValue, Document,
DocumentProperties, EmbeddedChart, ExtendedProperties, Field, FontScheme, HeaderFooter,
Highlight, Hyperlink, Image, Note, NoteKind, NoteReference, NumberingDefinition, Orientation,
PageSetup, Paragraph, ProtectionKind, Run, RunContent, StructuredDocumentTag, Style, StyleKind,
TabStop, Table, TableCell, TableRow, Theme, UnderlineStyle, VerticalAlign,
};
const WORDPROCESSINGML_NAMESPACE: &str =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const RELATIONSHIPS_NAMESPACE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const WORDPROCESSING_DRAWING_NAMESPACE: &str =
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
const DRAWINGML_NAMESPACE: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
const DRAWINGML_PICTURE_NAMESPACE: &str =
"http://schemas.openxmlformats.org/drawingml/2006/picture";
const IMAGE_RELATIONSHIP_TYPE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
const DRAWINGML_CHART_NAMESPACE: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart";
const CHART_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.drawingml.chart+xml";
const CHART_RELATIONSHIP_TYPE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart";
const MAIN_DOCUMENT_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml";
const MAIN_DOCUMENT_RELATIONSHIP_TYPE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument";
const MAIN_DOCUMENT_PART_NAME: &str = "/word/document.xml";
const MAIN_DOCUMENT_ENTRY_NAME: &str = "word/document.xml";
const CORE_PROPERTIES_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-package.core-properties+xml";
const CORE_PROPERTIES_RELATIONSHIP_TYPE: &str =
"http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties";
const CORE_PROPERTIES_PART_NAME: &str = "/docProps/core.xml";
const CORE_PROPERTIES_ENTRY_NAME: &str = "docProps/core.xml";
const CORE_PROPERTIES_NAMESPACE: &str =
"http://schemas.openxmlformats.org/package/2006/metadata/core-properties";
const DC_NAMESPACE: &str = "http://purl.org/dc/elements/1.1/";
const DCTERMS_NAMESPACE: &str = "http://purl.org/dc/terms/";
const XSI_NAMESPACE: &str = "http://www.w3.org/2001/XMLSchema-instance";
const EXTENDED_PROPERTIES_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.extended-properties+xml";
const EXTENDED_PROPERTIES_RELATIONSHIP_TYPE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties";
const EXTENDED_PROPERTIES_PART_NAME: &str = "/docProps/app.xml";
const EXTENDED_PROPERTIES_ENTRY_NAME: &str = "docProps/app.xml";
const EXTENDED_PROPERTIES_NAMESPACE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/extended-properties";
const CUSTOM_PROPERTIES_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.custom-properties+xml";
const CUSTOM_PROPERTIES_RELATIONSHIP_TYPE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties";
const CUSTOM_PROPERTIES_PART_NAME: &str = "/docProps/custom.xml";
const CUSTOM_PROPERTIES_ENTRY_NAME: &str = "docProps/custom.xml";
const CUSTOM_PROPERTIES_NAMESPACE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/custom-properties";
const CUSTOM_PROPERTIES_VT_NAMESPACE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes";
const CUSTOM_PROPERTY_FMTID: &str = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}";
const FIRST_CUSTOM_PROPERTY_PID: i32 = 2;
const SETTINGS_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml";
const SETTINGS_RELATIONSHIP_TYPE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings";
const SETTINGS_PART_NAME: &str = "/word/settings.xml";
const SETTINGS_ENTRY_NAME: &str = "settings.xml";
const HEADER_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml";
const HEADER_RELATIONSHIP_TYPE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
const HEADER_PART_NAME: &str = "/word/header1.xml";
const HEADER_ENTRY_NAME: &str = "header1.xml";
const HEADER_FIRST_PART_NAME: &str = "/word/header2.xml";
const HEADER_FIRST_ENTRY_NAME: &str = "header2.xml";
const HEADER_EVEN_PART_NAME: &str = "/word/header3.xml";
const HEADER_EVEN_ENTRY_NAME: &str = "header3.xml";
const FOOTER_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml";
const FOOTER_RELATIONSHIP_TYPE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
const FOOTER_PART_NAME: &str = "/word/footer1.xml";
const FOOTER_ENTRY_NAME: &str = "footer1.xml";
const FOOTER_FIRST_PART_NAME: &str = "/word/footer2.xml";
const FOOTER_FIRST_ENTRY_NAME: &str = "footer2.xml";
const FOOTER_EVEN_PART_NAME: &str = "/word/footer3.xml";
const FOOTER_EVEN_ENTRY_NAME: &str = "footer3.xml";
const STYLES_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml";
const STYLES_RELATIONSHIP_TYPE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles";
const STYLES_PART_NAME: &str = "/word/styles.xml";
const STYLES_ENTRY_NAME: &str = "styles.xml";
const HYPERLINK_RELATIONSHIP_TYPE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
const NUMBERING_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml";
const NUMBERING_RELATIONSHIP_TYPE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering";
const NUMBERING_PART_NAME: &str = "/word/numbering.xml";
const NUMBERING_ENTRY_NAME: &str = "numbering.xml";
const FOOTNOTES_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml";
const FOOTNOTES_RELATIONSHIP_TYPE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes";
const FOOTNOTES_PART_NAME: &str = "/word/footnotes.xml";
const FOOTNOTES_ENTRY_NAME: &str = "footnotes.xml";
const ENDNOTES_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml";
const ENDNOTES_RELATIONSHIP_TYPE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes";
const ENDNOTES_PART_NAME: &str = "/word/endnotes.xml";
const ENDNOTES_ENTRY_NAME: &str = "endnotes.xml";
const COMMENTS_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml";
const COMMENTS_RELATIONSHIP_TYPE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
const COMMENTS_PART_NAME: &str = "/word/comments.xml";
const COMMENTS_ENTRY_NAME: &str = "comments.xml";
const COMMENT_REFERENCE_STYLE_ID: &str = "CommentReference";
const THEME_CONTENT_TYPE: &str = "application/vnd.openxmlformats-officedocument.theme+xml";
const THEME_RELATIONSHIP_TYPE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme";
const THEME_PART_NAME: &str = "/word/theme/theme1.xml";
const THEME_ENTRY_NAME: &str = "theme/theme1.xml";
impl Document {
pub fn write_to<W: Write + Seek>(&self, writer: W) -> Result<W> {
let mut package = Package::new();
package.add_relationship(Relationship {
id: "rId1".to_string(),
rel_type: MAIN_DOCUMENT_RELATIONSHIP_TYPE.to_string(),
target: MAIN_DOCUMENT_ENTRY_NAME.to_string(),
target_mode: TargetMode::Internal,
});
package.add_relationship(Relationship {
id: "rId2".to_string(),
rel_type: CORE_PROPERTIES_RELATIONSHIP_TYPE.to_string(),
target: CORE_PROPERTIES_ENTRY_NAME.to_string(),
target_mode: TargetMode::Internal,
});
package.add_relationship(Relationship {
id: "rId3".to_string(),
rel_type: EXTENDED_PROPERTIES_RELATIONSHIP_TYPE.to_string(),
target: EXTENDED_PROPERTIES_ENTRY_NAME.to_string(),
target_mode: TargetMode::Internal,
});
if !self.custom_properties.is_empty() {
package.add_relationship(Relationship {
id: "rId4".to_string(),
rel_type: CUSTOM_PROPERTIES_RELATIONSHIP_TYPE.to_string(),
target: CUSTOM_PROPERTIES_ENTRY_NAME.to_string(),
target_mode: TargetMode::Internal,
});
}
let mut state = PackageState::new();
let mut document_relationships = PartRelationships::new();
document_relationships.add(SETTINGS_RELATIONSHIP_TYPE, SETTINGS_ENTRY_NAME);
if !self.styles.is_empty() {
document_relationships.add(STYLES_RELATIONSHIP_TYPE, STYLES_ENTRY_NAME);
package.add_part(Part::new(
STYLES_PART_NAME,
STYLES_CONTENT_TYPE,
to_styles_xml(&self.styles)?,
));
}
if !self.numbering_definitions.is_empty() {
document_relationships.add(NUMBERING_RELATIONSHIP_TYPE, NUMBERING_ENTRY_NAME);
package.add_part(Part::new(
NUMBERING_PART_NAME,
NUMBERING_CONTENT_TYPE,
to_numbering_xml(&self.numbering_definitions)?,
));
}
if !self.footnotes.is_empty() {
document_relationships.add(FOOTNOTES_RELATIONSHIP_TYPE, FOOTNOTES_ENTRY_NAME);
let mut footnotes_relationships = PartRelationships::new();
let xml = to_notes_xml(
"w:footnotes",
"w:footnote",
&self.footnotes,
&mut state,
&mut footnotes_relationships,
)?;
let mut part = Part::new(FOOTNOTES_PART_NAME, FOOTNOTES_CONTENT_TYPE, xml);
part.relationships = footnotes_relationships.relationships;
package.add_part(part);
}
if !self.endnotes.is_empty() {
document_relationships.add(ENDNOTES_RELATIONSHIP_TYPE, ENDNOTES_ENTRY_NAME);
let mut endnotes_relationships = PartRelationships::new();
let xml = to_notes_xml(
"w:endnotes",
"w:endnote",
&self.endnotes,
&mut state,
&mut endnotes_relationships,
)?;
let mut part = Part::new(ENDNOTES_PART_NAME, ENDNOTES_CONTENT_TYPE, xml);
part.relationships = endnotes_relationships.relationships;
package.add_part(part);
}
if !self.comments.is_empty() {
document_relationships.add(COMMENTS_RELATIONSHIP_TYPE, COMMENTS_ENTRY_NAME);
let mut comments_relationships = PartRelationships::new();
let xml = to_comments_xml(&self.comments, &mut state, &mut comments_relationships)?;
let mut part = Part::new(COMMENTS_PART_NAME, COMMENTS_CONTENT_TYPE, xml);
part.relationships = comments_relationships.relationships;
package.add_part(part);
}
if let Some(theme) = &self.theme {
document_relationships.add(THEME_RELATIONSHIP_TYPE, THEME_ENTRY_NAME);
package.add_part(Part::new(
THEME_PART_NAME,
THEME_CONTENT_TYPE,
to_theme_xml(theme)?,
));
}
let header_relationship_id = match &self.header {
Some(header) => Some(write_header_footer_part(
&mut package,
&mut state,
&mut document_relationships,
header,
"w:hdr",
HEADER_PART_NAME,
HEADER_CONTENT_TYPE,
HEADER_RELATIONSHIP_TYPE,
HEADER_ENTRY_NAME,
)?),
None => None,
};
let footer_relationship_id = match &self.footer {
Some(footer) => Some(write_header_footer_part(
&mut package,
&mut state,
&mut document_relationships,
footer,
"w:ftr",
FOOTER_PART_NAME,
FOOTER_CONTENT_TYPE,
FOOTER_RELATIONSHIP_TYPE,
FOOTER_ENTRY_NAME,
)?),
None => None,
};
let header_first_relationship_id = match &self.header_first {
Some(header) => Some(write_header_footer_part(
&mut package,
&mut state,
&mut document_relationships,
header,
"w:hdr",
HEADER_FIRST_PART_NAME,
HEADER_CONTENT_TYPE,
HEADER_RELATIONSHIP_TYPE,
HEADER_FIRST_ENTRY_NAME,
)?),
None => None,
};
let footer_first_relationship_id = match &self.footer_first {
Some(footer) => Some(write_header_footer_part(
&mut package,
&mut state,
&mut document_relationships,
footer,
"w:ftr",
FOOTER_FIRST_PART_NAME,
FOOTER_CONTENT_TYPE,
FOOTER_RELATIONSHIP_TYPE,
FOOTER_FIRST_ENTRY_NAME,
)?),
None => None,
};
let header_even_relationship_id = match &self.header_even {
Some(header) => Some(write_header_footer_part(
&mut package,
&mut state,
&mut document_relationships,
header,
"w:hdr",
HEADER_EVEN_PART_NAME,
HEADER_CONTENT_TYPE,
HEADER_RELATIONSHIP_TYPE,
HEADER_EVEN_ENTRY_NAME,
)?),
None => None,
};
let footer_even_relationship_id = match &self.footer_even {
Some(footer) => Some(write_header_footer_part(
&mut package,
&mut state,
&mut document_relationships,
footer,
"w:ftr",
FOOTER_EVEN_PART_NAME,
FOOTER_CONTENT_TYPE,
FOOTER_RELATIONSHIP_TYPE,
FOOTER_EVEN_ENTRY_NAME,
)?),
None => None,
};
let title_pg = self.header_first.is_some() || self.footer_first.is_some();
let even_and_odd_headers = self.header_even.is_some() || self.footer_even.is_some();
let document_xml = self.to_document_xml(
&mut state,
&mut document_relationships,
SectionReferenceIds {
header: header_relationship_id.as_deref(),
footer: footer_relationship_id.as_deref(),
header_first: header_first_relationship_id.as_deref(),
footer_first: footer_first_relationship_id.as_deref(),
header_even: header_even_relationship_id.as_deref(),
footer_even: footer_even_relationship_id.as_deref(),
title_pg,
},
)?;
let mut document_part = Part::new(
MAIN_DOCUMENT_PART_NAME,
MAIN_DOCUMENT_CONTENT_TYPE,
document_xml,
);
document_part.relationships = document_relationships.relationships;
package.add_part(document_part);
for media_part in state.images.media_parts {
package.add_part(Part::new(
format!("/word/{}", media_part.entry_name),
media_part.content_type,
media_part.data,
));
}
for chart_part in state.charts.chart_parts {
package.add_part(Part::new(
format!("/word/{}", chart_part.entry_name),
CHART_CONTENT_TYPE,
chart_part.data,
));
}
package.add_part(Part::new(
SETTINGS_PART_NAME,
SETTINGS_CONTENT_TYPE,
to_settings_xml(self.track_changes, self.protection, even_and_odd_headers)?,
));
package.add_part(Part::new(
CORE_PROPERTIES_PART_NAME,
CORE_PROPERTIES_CONTENT_TYPE,
to_core_properties_xml(&self.properties)?,
));
package.add_part(Part::new(
EXTENDED_PROPERTIES_PART_NAME,
EXTENDED_PROPERTIES_CONTENT_TYPE,
to_extended_properties_xml(&self.extended_properties)?,
));
if !self.custom_properties.is_empty() {
package.add_part(Part::new(
CUSTOM_PROPERTIES_PART_NAME,
CUSTOM_PROPERTIES_CONTENT_TYPE,
to_custom_properties_xml(&self.custom_properties)?,
));
}
Ok(package.write_to(writer)?)
}
fn to_document_xml(
&self,
state: &mut PackageState,
relationships: &mut PartRelationships,
section_references: SectionReferenceIds<'_>,
) -> Result<Vec<u8>> {
let mut writer = Writer::new(Vec::new());
writer.write_event(Event::Decl(BytesDecl::new(
"1.0",
Some("UTF-8"),
Some("yes"),
)))?;
let mut root = BytesStart::new("w:document");
root.push_attribute(("xmlns:w", WORDPROCESSINGML_NAMESPACE));
root.push_attribute(("xmlns:r", RELATIONSHIPS_NAMESPACE));
root.push_attribute(("xmlns:wp", WORDPROCESSING_DRAWING_NAMESPACE));
root.push_attribute(("xmlns:a", DRAWINGML_NAMESPACE));
root.push_attribute(("xmlns:pic", DRAWINGML_PICTURE_NAMESPACE));
writer.write_event(Event::Start(root))?;
writer.write_event(Event::Start(BytesStart::new("w:body")))?;
for block in &self.blocks {
write_block(&mut writer, block, state, relationships)?;
}
write_section_properties(&mut writer, section_references, &self.page_setup)?;
writer.write_event(Event::End(BytesEnd::new("w:body")))?;
writer.write_event(Event::End(BytesEnd::new("w:document")))?;
Ok(writer.into_inner())
}
}
#[derive(Default)]
struct SectionReferenceIds<'a> {
header: Option<&'a str>,
footer: Option<&'a str>,
header_first: Option<&'a str>,
footer_first: Option<&'a str>,
header_even: Option<&'a str>,
footer_even: Option<&'a str>,
title_pg: bool,
}
#[allow(clippy::too_many_arguments)]
fn write_header_footer_part(
package: &mut Package,
state: &mut PackageState,
document_relationships: &mut PartRelationships,
header_footer: &HeaderFooter,
root_name: &str,
part_name: &str,
content_type: &str,
relationship_type: &str,
entry_name: &str,
) -> Result<String> {
let mut part_relationships = PartRelationships::new();
let xml = to_header_footer_xml(
root_name,
&header_footer.blocks,
state,
&mut part_relationships,
)?;
let mut part = Part::new(part_name, content_type, xml);
part.relationships = part_relationships.relationships;
package.add_part(part);
Ok(document_relationships.add(relationship_type, entry_name))
}
fn to_header_footer_xml(
root_name: &str,
blocks: &[Block],
state: &mut PackageState,
relationships: &mut PartRelationships,
) -> Result<Vec<u8>> {
let mut writer = Writer::new(Vec::new());
writer.write_event(Event::Decl(BytesDecl::new(
"1.0",
Some("UTF-8"),
Some("yes"),
)))?;
let mut root = BytesStart::new(root_name);
root.push_attribute(("xmlns:w", WORDPROCESSINGML_NAMESPACE));
root.push_attribute(("xmlns:r", RELATIONSHIPS_NAMESPACE));
root.push_attribute(("xmlns:wp", WORDPROCESSING_DRAWING_NAMESPACE));
root.push_attribute(("xmlns:a", DRAWINGML_NAMESPACE));
root.push_attribute(("xmlns:pic", DRAWINGML_PICTURE_NAMESPACE));
writer.write_event(Event::Start(root))?;
for block in blocks {
write_block(&mut writer, block, state, relationships)?;
}
writer.write_event(Event::End(BytesEnd::new(root_name)))?;
Ok(writer.into_inner())
}
fn write_block<W: Write>(
writer: &mut Writer<W>,
block: &Block,
state: &mut PackageState,
relationships: &mut PartRelationships,
) -> Result<()> {
match block {
Block::Paragraph(paragraph) => write_paragraph(writer, paragraph, state, relationships),
Block::Table(table) => write_table(writer, table, state, relationships),
Block::StructuredDocumentTag(sdt) => {
write_structured_document_tag(writer, sdt, state, relationships)
}
}
}
fn write_paragraph<W: Write>(
writer: &mut Writer<W>,
paragraph: &Paragraph,
state: &mut PackageState,
relationships: &mut PartRelationships,
) -> Result<()> {
if paragraph.runs.is_empty()
&& paragraph.alignment.is_none()
&& paragraph.style_id.is_none()
&& paragraph.numbering_id.is_none()
&& paragraph.line_spacing.is_none()
&& paragraph.space_before.is_none()
&& paragraph.space_after.is_none()
&& paragraph.shading_color.is_none()
&& !paragraph.border
&& !paragraph.keep_with_next
&& !paragraph.keep_lines_together
&& !paragraph.page_break_before
&& paragraph.tabs.is_empty()
&& !paragraph.contextual_spacing
{
writer.write_event(Event::Empty(BytesStart::new("w:p")))?;
return Ok(());
}
writer.write_event(Event::Start(BytesStart::new("w:p")))?;
if paragraph.style_id.is_some()
|| paragraph.numbering_id.is_some()
|| paragraph.shading_color.is_some()
|| paragraph.border
|| paragraph.line_spacing.is_some()
|| paragraph.space_before.is_some()
|| paragraph.space_after.is_some()
|| paragraph.alignment.is_some()
|| paragraph.keep_with_next
|| paragraph.keep_lines_together
|| paragraph.page_break_before
|| !paragraph.tabs.is_empty()
|| paragraph.contextual_spacing
{
writer.write_event(Event::Start(BytesStart::new("w:pPr")))?;
if let Some(style_id) = ¶graph.style_id {
let mut p_style = BytesStart::new("w:pStyle");
p_style.push_attribute(("w:val", style_id.as_str()));
writer.write_event(Event::Empty(p_style))?;
}
if paragraph.keep_with_next {
writer.write_event(Event::Empty(BytesStart::new("w:keepNext")))?;
}
if paragraph.keep_lines_together {
writer.write_event(Event::Empty(BytesStart::new("w:keepLines")))?;
}
if paragraph.page_break_before {
writer.write_event(Event::Empty(BytesStart::new("w:pageBreakBefore")))?;
}
if let Some(numbering_id) = paragraph.numbering_id {
writer.write_event(Event::Start(BytesStart::new("w:numPr")))?;
let mut ilvl = BytesStart::new("w:ilvl");
let level = paragraph.numbering_level.to_string();
ilvl.push_attribute(("w:val", level.as_str()));
writer.write_event(Event::Empty(ilvl))?;
let mut num_id = BytesStart::new("w:numId");
let id = numbering_id.to_string();
num_id.push_attribute(("w:val", id.as_str()));
writer.write_event(Event::Empty(num_id))?;
writer.write_event(Event::End(BytesEnd::new("w:numPr")))?;
}
if paragraph.border {
write_paragraph_border(writer)?;
}
if let Some(shading_color) = ¶graph.shading_color {
write_shading(writer, "w:shd", shading_color)?;
}
if !paragraph.tabs.is_empty() {
write_tabs(writer, ¶graph.tabs)?;
}
if paragraph.line_spacing.is_some()
|| paragraph.space_before.is_some()
|| paragraph.space_after.is_some()
{
write_paragraph_spacing(
writer,
paragraph.line_spacing,
paragraph.space_before,
paragraph.space_after,
)?;
}
if paragraph.contextual_spacing {
writer.write_event(Event::Empty(BytesStart::new("w:contextualSpacing")))?;
}
if let Some(alignment) = paragraph.alignment {
let mut jc = BytesStart::new("w:jc");
jc.push_attribute(("w:val", alignment_value(alignment)));
writer.write_event(Event::Empty(jc))?;
}
writer.write_event(Event::End(BytesEnd::new("w:pPr")))?;
}
let mut active_comment_ids: Vec<u32> = Vec::new();
let mut active_bookmarks: Vec<Bookmark> = Vec::new();
for run in ¶graph.runs {
let to_close: Vec<u32> = active_comment_ids
.iter()
.copied()
.filter(|id| !run.comment_ids.contains(id))
.collect();
for id in &to_close {
write_comment_range_end_and_reference(writer, *id)?;
}
active_comment_ids.retain(|id| !to_close.contains(id));
let to_open: Vec<u32> = run
.comment_ids
.iter()
.copied()
.filter(|id| !active_comment_ids.contains(id))
.collect();
for id in &to_open {
let id_string = id.to_string();
let mut start = BytesStart::new("w:commentRangeStart");
start.push_attribute(("w:id", id_string.as_str()));
writer.write_event(Event::Empty(start))?;
}
active_comment_ids.extend(to_open);
let bookmarks_to_close: Vec<u32> = active_bookmarks
.iter()
.map(|bookmark| bookmark.id)
.filter(|id| !run.bookmarks.iter().any(|bookmark| bookmark.id == *id))
.collect();
for id in &bookmarks_to_close {
write_bookmark_end(writer, *id)?;
}
active_bookmarks.retain(|bookmark| !bookmarks_to_close.contains(&bookmark.id));
let bookmarks_to_open: Vec<Bookmark> = run
.bookmarks
.iter()
.filter(|bookmark| {
!active_bookmarks
.iter()
.any(|active| active.id == bookmark.id)
})
.cloned()
.collect();
for bookmark in &bookmarks_to_open {
write_bookmark_start(writer, bookmark)?;
}
active_bookmarks.extend(bookmarks_to_open);
match &run.hyperlink {
Some(Hyperlink::External(url)) => {
let relationship_id =
relationships.add_external(HYPERLINK_RELATIONSHIP_TYPE, url.as_str());
let mut hyperlink = BytesStart::new("w:hyperlink");
hyperlink.push_attribute(("r:id", relationship_id.as_str()));
writer.write_event(Event::Start(hyperlink))?;
write_run(writer, run, state, relationships)?;
writer.write_event(Event::End(BytesEnd::new("w:hyperlink")))?;
}
Some(Hyperlink::Internal(anchor)) => {
let mut hyperlink = BytesStart::new("w:hyperlink");
hyperlink.push_attribute(("w:anchor", anchor.as_str()));
writer.write_event(Event::Start(hyperlink))?;
write_run(writer, run, state, relationships)?;
writer.write_event(Event::End(BytesEnd::new("w:hyperlink")))?;
}
None => write_run(writer, run, state, relationships)?,
}
}
for id in active_comment_ids {
write_comment_range_end_and_reference(writer, id)?;
}
for bookmark in active_bookmarks {
write_bookmark_end(writer, bookmark.id)?;
}
writer.write_event(Event::End(BytesEnd::new("w:p")))?;
Ok(())
}
fn write_bookmark_start<W: Write>(writer: &mut Writer<W>, bookmark: &Bookmark) -> Result<()> {
let id_string = bookmark.id.to_string();
let mut start = BytesStart::new("w:bookmarkStart");
start.push_attribute(("w:id", id_string.as_str()));
start.push_attribute(("w:name", bookmark.name.as_str()));
writer.write_event(Event::Empty(start))?;
Ok(())
}
fn write_bookmark_end<W: Write>(writer: &mut Writer<W>, id: u32) -> Result<()> {
let id_string = id.to_string();
let mut end = BytesStart::new("w:bookmarkEnd");
end.push_attribute(("w:id", id_string.as_str()));
writer.write_event(Event::Empty(end))?;
Ok(())
}
fn write_comment_range_end_and_reference<W: Write>(writer: &mut Writer<W>, id: u32) -> Result<()> {
let id_string = id.to_string();
let mut end = BytesStart::new("w:commentRangeEnd");
end.push_attribute(("w:id", id_string.as_str()));
writer.write_event(Event::Empty(end))?;
writer.write_event(Event::Start(BytesStart::new("w:r")))?;
writer.write_event(Event::Start(BytesStart::new("w:rPr")))?;
let mut r_style = BytesStart::new("w:rStyle");
r_style.push_attribute(("w:val", COMMENT_REFERENCE_STYLE_ID));
writer.write_event(Event::Empty(r_style))?;
writer.write_event(Event::End(BytesEnd::new("w:rPr")))?;
let mut reference = BytesStart::new("w:commentReference");
reference.push_attribute(("w:id", id_string.as_str()));
writer.write_event(Event::Empty(reference))?;
writer.write_event(Event::End(BytesEnd::new("w:r")))?;
Ok(())
}
fn write_run<W: Write>(
writer: &mut Writer<W>,
run: &Run,
state: &mut PackageState,
relationships: &mut PartRelationships,
) -> Result<()> {
match &run.content {
RunContent::Field(field) => write_field(writer, run, *field)?,
RunContent::Image(image) => {
writer.write_event(Event::Start(BytesStart::new("w:r")))?;
write_drawing(writer, image, state, relationships)?;
writer.write_event(Event::End(BytesEnd::new("w:r")))?;
}
RunContent::Chart(chart) => {
writer.write_event(Event::Start(BytesStart::new("w:r")))?;
write_embedded_chart(writer, chart, state, relationships)?;
writer.write_event(Event::End(BytesEnd::new("w:r")))?;
}
RunContent::Text(text) => {
writer.write_event(Event::Start(BytesStart::new("w:r")))?;
write_run_properties(writer, run)?;
let mut text_element = BytesStart::new("w:t");
if starts_or_ends_with_whitespace(text) {
text_element.push_attribute(("xml:space", "preserve"));
}
writer.write_event(Event::Start(text_element))?;
writer.write_event(Event::Text(BytesText::new(text)))?;
writer.write_event(Event::End(BytesEnd::new("w:t")))?;
writer.write_event(Event::End(BytesEnd::new("w:r")))?;
}
RunContent::NoteReference(reference) => {
writer.write_event(Event::Start(BytesStart::new("w:r")))?;
write_run_properties(writer, run)?;
let (element_name, id) = match reference {
NoteReference::Footnote(id) => ("w:footnoteReference", *id),
NoteReference::Endnote(id) => ("w:endnoteReference", *id),
};
let id = id.to_string();
let mut element = BytesStart::new(element_name);
element.push_attribute(("w:id", id.as_str()));
writer.write_event(Event::Empty(element))?;
writer.write_event(Event::End(BytesEnd::new("w:r")))?;
}
RunContent::NoteMarker(kind) => {
writer.write_event(Event::Start(BytesStart::new("w:r")))?;
write_run_properties(writer, run)?;
let element_name = match kind {
NoteKind::Footnote => "w:footnoteRef",
NoteKind::Endnote => "w:endnoteRef",
};
writer.write_event(Event::Empty(BytesStart::new(element_name)))?;
writer.write_event(Event::End(BytesEnd::new("w:r")))?;
}
RunContent::Break(kind) => {
writer.write_event(Event::Start(BytesStart::new("w:r")))?;
write_run_properties(writer, run)?;
let mut element = BytesStart::new("w:br");
if *kind != BreakKind::TextWrapping {
element.push_attribute(("w:type", kind.attribute_value()));
}
writer.write_event(Event::Empty(element))?;
writer.write_event(Event::End(BytesEnd::new("w:r")))?;
}
RunContent::CarriageReturn => {
writer.write_event(Event::Start(BytesStart::new("w:r")))?;
write_run_properties(writer, run)?;
writer.write_event(Event::Empty(BytesStart::new("w:cr")))?;
writer.write_event(Event::End(BytesEnd::new("w:r")))?;
}
}
Ok(())
}
fn write_run_properties<W: Write>(writer: &mut Writer<W>, run: &Run) -> Result<()> {
if run.style_id.is_none()
&& run.font_family.is_none()
&& run.color.is_none()
&& run.font_size.is_none()
&& run.highlight.is_none()
&& run.vertical_align.is_none()
&& run.underline.is_none()
&& run.shading_color.is_none()
&& run.character_spacing.is_none()
&& run.vertical_position.is_none()
&& !(run.bold
|| run.italic
|| run.strike
|| run.small_caps
|| run.all_caps
|| run.text_border
|| run.hidden)
{
return Ok(());
}
writer.write_event(Event::Start(BytesStart::new("w:rPr")))?;
if let Some(style_id) = &run.style_id {
let mut r_style = BytesStart::new("w:rStyle");
r_style.push_attribute(("w:val", style_id.as_str()));
writer.write_event(Event::Empty(r_style))?;
}
write_character_formatting(
writer,
run.font_family.as_deref(),
run.color.as_deref(),
run.font_size,
run.highlight,
run.bold,
run.italic,
run.underline,
run.underline_color.as_deref(),
run.strike,
run.vertical_align,
run.small_caps,
run.all_caps,
run.shading_color.as_deref(),
run.text_border,
run.character_spacing,
run.vertical_position,
run.hidden,
)?;
writer.write_event(Event::End(BytesEnd::new("w:rPr")))?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn write_character_formatting<W: Write>(
writer: &mut Writer<W>,
font_family: Option<&str>,
color: Option<&str>,
size_points: Option<u16>,
highlight: Option<Highlight>,
bold: bool,
italic: bool,
underline: Option<UnderlineStyle>,
underline_color: Option<&str>,
strike: bool,
vertical_align: Option<VerticalAlign>,
small_caps: bool,
all_caps: bool,
shading_color: Option<&str>,
text_border: bool,
character_spacing: Option<i16>,
vertical_position: Option<i16>,
hidden: bool,
) -> Result<()> {
if let Some(font_family) = font_family {
let mut r_fonts = BytesStart::new("w:rFonts");
r_fonts.push_attribute(("w:ascii", font_family));
r_fonts.push_attribute(("w:hAnsi", font_family));
writer.write_event(Event::Empty(r_fonts))?;
}
if let Some(color) = color {
let mut color_element = BytesStart::new("w:color");
color_element.push_attribute(("w:val", color));
writer.write_event(Event::Empty(color_element))?;
}
if let Some(size_points) = size_points {
let half_points = (u32::from(size_points) * 2).to_string();
let mut sz = BytesStart::new("w:sz");
sz.push_attribute(("w:val", half_points.as_str()));
writer.write_event(Event::Empty(sz))?;
let mut sz_cs = BytesStart::new("w:szCs");
sz_cs.push_attribute(("w:val", half_points.as_str()));
writer.write_event(Event::Empty(sz_cs))?;
}
if let Some(highlight) = highlight {
let mut highlight_element = BytesStart::new("w:highlight");
highlight_element.push_attribute(("w:val", highlight.attribute_value()));
writer.write_event(Event::Empty(highlight_element))?;
}
if bold {
writer.write_event(Event::Empty(BytesStart::new("w:b")))?;
}
if italic {
writer.write_event(Event::Empty(BytesStart::new("w:i")))?;
}
if let Some(underline) = underline {
let mut underline_element = BytesStart::new("w:u");
underline_element.push_attribute(("w:val", underline.attribute_value()));
if let Some(underline_color) = underline_color {
underline_element.push_attribute(("w:color", underline_color));
}
writer.write_event(Event::Empty(underline_element))?;
}
if strike {
writer.write_event(Event::Empty(BytesStart::new("w:strike")))?;
}
if let Some(vertical_align) = vertical_align {
let mut vert_align_element = BytesStart::new("w:vertAlign");
vert_align_element.push_attribute(("w:val", vertical_align.attribute_value()));
writer.write_event(Event::Empty(vert_align_element))?;
}
if small_caps {
writer.write_event(Event::Empty(BytesStart::new("w:smallCaps")))?;
}
if all_caps {
writer.write_event(Event::Empty(BytesStart::new("w:caps")))?;
}
if let Some(shading_color) = shading_color {
write_shading(writer, "w:shd", shading_color)?;
}
if text_border {
let mut bdr = BytesStart::new("w:bdr");
bdr.push_attribute(("w:val", "single"));
bdr.push_attribute(("w:sz", "4"));
bdr.push_attribute(("w:space", "0"));
bdr.push_attribute(("w:color", "auto"));
writer.write_event(Event::Empty(bdr))?;
}
if let Some(character_spacing) = character_spacing {
let mut spacing = BytesStart::new("w:spacing");
spacing.push_attribute(("w:val", character_spacing.to_string().as_str()));
writer.write_event(Event::Empty(spacing))?;
}
if let Some(vertical_position) = vertical_position {
let mut position = BytesStart::new("w:position");
position.push_attribute(("w:val", vertical_position.to_string().as_str()));
writer.write_event(Event::Empty(position))?;
}
if hidden {
writer.write_event(Event::Empty(BytesStart::new("w:vanish")))?;
}
Ok(())
}
fn write_shading<W: Write>(writer: &mut Writer<W>, tag_name: &str, fill: &str) -> Result<()> {
let mut shd = BytesStart::new(tag_name);
shd.push_attribute(("w:val", "clear"));
shd.push_attribute(("w:color", "auto"));
shd.push_attribute(("w:fill", fill));
writer.write_event(Event::Empty(shd))?;
Ok(())
}
fn write_border_side<W: Write>(writer: &mut Writer<W>, tag_name: &str) -> Result<()> {
let mut side = BytesStart::new(tag_name);
side.push_attribute(("w:val", "single"));
side.push_attribute(("w:sz", "4"));
side.push_attribute(("w:space", "0"));
side.push_attribute(("w:color", "auto"));
writer.write_event(Event::Empty(side))?;
Ok(())
}
fn write_paragraph_border<W: Write>(writer: &mut Writer<W>) -> Result<()> {
writer.write_event(Event::Start(BytesStart::new("w:pBdr")))?;
write_border_side(writer, "w:top")?;
write_border_side(writer, "w:left")?;
write_border_side(writer, "w:bottom")?;
write_border_side(writer, "w:right")?;
writer.write_event(Event::End(BytesEnd::new("w:pBdr")))?;
Ok(())
}
fn write_tabs<W: Write>(writer: &mut Writer<W>, tabs: &[TabStop]) -> Result<()> {
writer.write_event(Event::Start(BytesStart::new("w:tabs")))?;
for tab in tabs {
write_tab_stop(writer, tab)?;
}
writer.write_event(Event::End(BytesEnd::new("w:tabs")))?;
Ok(())
}
fn write_tab_stop<W: Write>(writer: &mut Writer<W>, tab: &TabStop) -> Result<()> {
let mut element = BytesStart::new("w:tab");
let position = tab.position_twips.to_string();
element.push_attribute(("w:val", tab.alignment.attribute_value()));
if let Some(leader) = tab.leader {
element.push_attribute(("w:leader", leader.attribute_value()));
}
element.push_attribute(("w:pos", position.as_str()));
writer.write_event(Event::Empty(element))?;
Ok(())
}
fn write_paragraph_spacing<W: Write>(
writer: &mut Writer<W>,
line: Option<u32>,
before: Option<u32>,
after: Option<u32>,
) -> Result<()> {
let mut spacing = BytesStart::new("w:spacing");
let before_string = before.map(|value| value.to_string());
let after_string = after.map(|value| value.to_string());
let line_string = line.map(|value| value.to_string());
if let Some(before_string) = &before_string {
spacing.push_attribute(("w:before", before_string.as_str()));
}
if let Some(after_string) = &after_string {
spacing.push_attribute(("w:after", after_string.as_str()));
}
if let Some(line_string) = &line_string {
spacing.push_attribute(("w:line", line_string.as_str()));
spacing.push_attribute(("w:lineRule", "auto"));
}
writer.write_event(Event::Empty(spacing))?;
Ok(())
}
fn write_field<W: Write>(writer: &mut Writer<W>, run: &Run, field: Field) -> Result<()> {
let mut fld_simple = BytesStart::new("w:fldSimple");
fld_simple.push_attribute(("w:instr", field.instruction()));
writer.write_event(Event::Start(fld_simple))?;
writer.write_event(Event::Start(BytesStart::new("w:r")))?;
write_run_properties(writer, run)?;
writer.write_event(Event::Start(BytesStart::new("w:t")))?;
writer.write_event(Event::Text(BytesText::new("1")))?;
writer.write_event(Event::End(BytesEnd::new("w:t")))?;
writer.write_event(Event::End(BytesEnd::new("w:r")))?;
writer.write_event(Event::End(BytesEnd::new("w:fldSimple")))?;
Ok(())
}
struct ImageRegistry {
next_media_index: usize,
media_parts: Vec<MediaPart>,
}
struct MediaPart {
entry_name: String,
content_type: &'static str,
data: Vec<u8>,
}
impl ImageRegistry {
fn new() -> Self {
Self {
next_media_index: 1,
media_parts: Vec::new(),
}
}
fn register(&mut self, image: &Image) -> String {
let index = self.next_media_index;
self.next_media_index += 1;
let entry_name = format!("media/image{index}.{}", image.format.extension());
self.media_parts.push(MediaPart {
entry_name: entry_name.clone(),
content_type: image.format.content_type(),
data: image.data.clone(),
});
entry_name
}
}
struct ChartRegistry {
next_chart_index: usize,
chart_parts: Vec<ChartPart>,
}
struct ChartPart {
entry_name: String,
data: Vec<u8>,
}
impl ChartRegistry {
fn new() -> Self {
Self {
next_chart_index: 1,
chart_parts: Vec::new(),
}
}
fn register(&mut self, chart_space: &chart::ChartSpace) -> Result<String> {
let index = self.next_chart_index;
self.next_chart_index += 1;
let entry_name = format!("charts/chart{index}.xml");
let mut chart_writer = Writer::new(Vec::new());
chart::write_chart_space(&mut chart_writer, chart_space)?;
self.chart_parts.push(ChartPart {
entry_name: entry_name.clone(),
data: chart_writer.into_inner(),
});
Ok(entry_name)
}
}
struct PackageState {
images: ImageRegistry,
charts: ChartRegistry,
next_drawing_id: usize,
}
impl PackageState {
fn new() -> Self {
Self {
images: ImageRegistry::new(),
charts: ChartRegistry::new(),
next_drawing_id: 1,
}
}
fn next_drawing_id(&mut self) -> usize {
let id = self.next_drawing_id;
self.next_drawing_id += 1;
id
}
}
struct PartRelationships {
next_id: usize,
relationships: Relationships,
}
impl PartRelationships {
fn new() -> Self {
Self {
next_id: 1,
relationships: Relationships::new(),
}
}
fn add(&mut self, rel_type: &str, target: impl Into<String>) -> String {
self.add_with_mode(rel_type, target, TargetMode::Internal)
}
fn add_external(&mut self, rel_type: &str, target: impl Into<String>) -> String {
self.add_with_mode(rel_type, target, TargetMode::External)
}
fn add_with_mode(
&mut self,
rel_type: &str,
target: impl Into<String>,
target_mode: TargetMode,
) -> String {
let id = format!("rId{}", self.next_id);
self.next_id += 1;
self.relationships.add(Relationship {
id: id.clone(),
rel_type: rel_type.to_string(),
target: target.into(),
target_mode,
});
id
}
}
fn write_drawing<W: Write>(
writer: &mut Writer<W>,
image: &Image,
state: &mut PackageState,
relationships: &mut PartRelationships,
) -> Result<()> {
let entry_name = state.images.register(image);
let relationship_id = relationships.add(IMAGE_RELATIONSHIP_TYPE, entry_name);
let drawing_id = state.next_drawing_id().to_string();
let width = image.width_emu.to_string();
let height = image.height_emu.to_string();
writer.write_event(Event::Start(BytesStart::new("w:drawing")))?;
let mut inline = BytesStart::new("wp:inline");
inline.push_attribute(("distT", "0"));
inline.push_attribute(("distB", "0"));
inline.push_attribute(("distL", "0"));
inline.push_attribute(("distR", "0"));
writer.write_event(Event::Start(inline))?;
let mut extent = BytesStart::new("wp:extent");
extent.push_attribute(("cx", width.as_str()));
extent.push_attribute(("cy", height.as_str()));
writer.write_event(Event::Empty(extent))?;
let mut doc_pr = BytesStart::new("wp:docPr");
doc_pr.push_attribute(("id", drawing_id.as_str()));
doc_pr.push_attribute(("name", relationship_id.as_str()));
doc_pr.push_attribute(("descr", image.description.as_str()));
writer.write_event(Event::Empty(doc_pr))?;
writer.write_event(Event::Start(BytesStart::new("a:graphic")))?;
let mut graphic_data = BytesStart::new("a:graphicData");
graphic_data.push_attribute(("uri", DRAWINGML_PICTURE_NAMESPACE));
writer.write_event(Event::Start(graphic_data))?;
writer.write_event(Event::Start(BytesStart::new("pic:pic")))?;
writer.write_event(Event::Start(BytesStart::new("pic:nvPicPr")))?;
let mut c_nv_pr = BytesStart::new("pic:cNvPr");
c_nv_pr.push_attribute(("id", "0"));
c_nv_pr.push_attribute(("name", relationship_id.as_str()));
c_nv_pr.push_attribute(("descr", image.description.as_str()));
writer.write_event(Event::Empty(c_nv_pr))?;
writer.write_event(Event::Start(BytesStart::new("pic:cNvPicPr")))?;
let mut pic_locks = BytesStart::new("a:picLocks");
pic_locks.push_attribute(("noChangeAspect", "1"));
writer.write_event(Event::Empty(pic_locks))?;
writer.write_event(Event::End(BytesEnd::new("pic:cNvPicPr")))?;
writer.write_event(Event::End(BytesEnd::new("pic:nvPicPr")))?;
writer.write_event(Event::Start(BytesStart::new("pic:blipFill")))?;
let mut blip = BytesStart::new("a:blip");
blip.push_attribute(("r:embed", relationship_id.as_str()));
writer.write_event(Event::Empty(blip))?;
writer.write_event(Event::Start(BytesStart::new("a:stretch")))?;
writer.write_event(Event::Empty(BytesStart::new("a:fillRect")))?;
writer.write_event(Event::End(BytesEnd::new("a:stretch")))?;
writer.write_event(Event::End(BytesEnd::new("pic:blipFill")))?;
writer.write_event(Event::Start(BytesStart::new("pic:spPr")))?;
writer.write_event(Event::Start(BytesStart::new("a:xfrm")))?;
let mut off = BytesStart::new("a:off");
off.push_attribute(("x", "0"));
off.push_attribute(("y", "0"));
writer.write_event(Event::Empty(off))?;
let mut ext = BytesStart::new("a:ext");
ext.push_attribute(("cx", width.as_str()));
ext.push_attribute(("cy", height.as_str()));
writer.write_event(Event::Empty(ext))?;
writer.write_event(Event::End(BytesEnd::new("a:xfrm")))?;
let empty_properties = drawing::ShapeProperties::default();
let shape_properties = image.shape_properties.as_ref().unwrap_or(&empty_properties);
drawing::write_geometry_fill_line_effects(writer, shape_properties, "rect")?;
writer.write_event(Event::End(BytesEnd::new("pic:spPr")))?;
writer.write_event(Event::End(BytesEnd::new("pic:pic")))?;
writer.write_event(Event::End(BytesEnd::new("a:graphicData")))?;
writer.write_event(Event::End(BytesEnd::new("a:graphic")))?;
writer.write_event(Event::End(BytesEnd::new("wp:inline")))?;
writer.write_event(Event::End(BytesEnd::new("w:drawing")))?;
Ok(())
}
fn write_embedded_chart<W: Write>(
writer: &mut Writer<W>,
chart: &EmbeddedChart,
state: &mut PackageState,
relationships: &mut PartRelationships,
) -> Result<()> {
let entry_name = state.charts.register(&chart.chart_space)?;
let relationship_id = relationships.add(CHART_RELATIONSHIP_TYPE, entry_name);
let drawing_id = state.next_drawing_id().to_string();
let width = chart.width_emu.to_string();
let height = chart.height_emu.to_string();
writer.write_event(Event::Start(BytesStart::new("w:drawing")))?;
let mut inline = BytesStart::new("wp:inline");
inline.push_attribute(("distT", "0"));
inline.push_attribute(("distB", "0"));
inline.push_attribute(("distL", "0"));
inline.push_attribute(("distR", "0"));
writer.write_event(Event::Start(inline))?;
let mut extent = BytesStart::new("wp:extent");
extent.push_attribute(("cx", width.as_str()));
extent.push_attribute(("cy", height.as_str()));
writer.write_event(Event::Empty(extent))?;
let mut doc_pr = BytesStart::new("wp:docPr");
doc_pr.push_attribute(("id", drawing_id.as_str()));
doc_pr.push_attribute(("name", chart.name.as_str()));
doc_pr.push_attribute(("descr", chart.name.as_str()));
writer.write_event(Event::Empty(doc_pr))?;
writer.write_event(Event::Start(BytesStart::new("a:graphic")))?;
let mut graphic_data = BytesStart::new("a:graphicData");
graphic_data.push_attribute(("uri", DRAWINGML_CHART_NAMESPACE));
writer.write_event(Event::Start(graphic_data))?;
let mut c_chart = BytesStart::new("c:chart");
c_chart.push_attribute(("xmlns:c", DRAWINGML_CHART_NAMESPACE));
c_chart.push_attribute(("r:id", relationship_id.as_str()));
writer.write_event(Event::Empty(c_chart))?;
writer.write_event(Event::End(BytesEnd::new("a:graphicData")))?;
writer.write_event(Event::End(BytesEnd::new("a:graphic")))?;
writer.write_event(Event::End(BytesEnd::new("wp:inline")))?;
writer.write_event(Event::End(BytesEnd::new("w:drawing")))?;
Ok(())
}
const DEFAULT_COLUMN_WIDTH_TWIPS: &str = "2000";
fn write_table<W: Write>(
writer: &mut Writer<W>,
table: &Table,
state: &mut PackageState,
relationships: &mut PartRelationships,
) -> Result<()> {
writer.write_event(Event::Start(BytesStart::new("w:tbl")))?;
let column_count = table
.rows
.iter()
.map(|row| {
row.cells
.iter()
.map(|cell| cell.horizontal_span.unwrap_or(1).max(1) as usize)
.sum::<usize>()
})
.max()
.unwrap_or(0);
let has_column_widths = !table.column_widths.is_empty();
writer.write_event(Event::Start(BytesStart::new("w:tblPr")))?;
if let Some(style_id) = &table.style_id {
let mut tbl_style = BytesStart::new("w:tblStyle");
tbl_style.push_attribute(("w:val", style_id.as_str()));
writer.write_event(Event::Empty(tbl_style))?;
}
let mut table_width = BytesStart::new("w:tblW");
if has_column_widths {
let total_width: u32 = table.column_widths.iter().sum();
table_width.push_attribute(("w:w", total_width.to_string().as_str()));
table_width.push_attribute(("w:type", "dxa"));
} else {
table_width.push_attribute(("w:w", "0"));
table_width.push_attribute(("w:type", "auto"));
}
writer.write_event(Event::Empty(table_width))?;
if let Some(alignment) = table.alignment {
let mut jc = BytesStart::new("w:jc");
jc.push_attribute(("w:val", alignment_value(alignment)));
writer.write_event(Event::Empty(jc))?;
}
if let Some(indent_twips) = table.indent_twips {
let mut tbl_ind = BytesStart::new("w:tblInd");
tbl_ind.push_attribute(("w:w", indent_twips.to_string().as_str()));
tbl_ind.push_attribute(("w:type", "dxa"));
writer.write_event(Event::Empty(tbl_ind))?;
}
let border_color = table.border_color.as_deref().unwrap_or("auto");
writer.write_event(Event::Start(BytesStart::new("w:tblBorders")))?;
for side_element_name in [
"w:top",
"w:left",
"w:bottom",
"w:right",
"w:insideH",
"w:insideV",
] {
let mut border = BytesStart::new(side_element_name);
border.push_attribute(("w:val", "single"));
border.push_attribute(("w:sz", "4"));
border.push_attribute(("w:space", "0"));
border.push_attribute(("w:color", border_color));
writer.write_event(Event::Empty(border))?;
}
writer.write_event(Event::End(BytesEnd::new("w:tblBorders")))?;
if has_column_widths {
let mut tbl_layout = BytesStart::new("w:tblLayout");
tbl_layout.push_attribute(("w:type", "fixed"));
writer.write_event(Event::Empty(tbl_layout))?;
}
writer.write_event(Event::End(BytesEnd::new("w:tblPr")))?;
writer.write_event(Event::Start(BytesStart::new("w:tblGrid")))?;
for column_index in 0..column_count {
let width = table
.column_widths
.get(column_index)
.map(u32::to_string)
.unwrap_or_else(|| DEFAULT_COLUMN_WIDTH_TWIPS.to_string());
let mut grid_col = BytesStart::new("w:gridCol");
grid_col.push_attribute(("w:w", width.as_str()));
writer.write_event(Event::Empty(grid_col))?;
}
writer.write_event(Event::End(BytesEnd::new("w:tblGrid")))?;
for row in &table.rows {
writer.write_event(Event::Start(BytesStart::new("w:tr")))?;
write_table_row_properties(writer, row)?;
for cell in &row.cells {
writer.write_event(Event::Start(BytesStart::new("w:tc")))?;
write_table_cell_properties(writer, cell)?;
for block in &cell.blocks {
write_block(writer, block, state, relationships)?;
}
let needs_trailing_paragraph = !matches!(cell.blocks.last(), Some(Block::Paragraph(_)));
if needs_trailing_paragraph {
writer.write_event(Event::Empty(BytesStart::new("w:p")))?;
}
writer.write_event(Event::End(BytesEnd::new("w:tc")))?;
}
writer.write_event(Event::End(BytesEnd::new("w:tr")))?;
}
writer.write_event(Event::End(BytesEnd::new("w:tbl")))?;
Ok(())
}
fn write_table_row_properties<W: Write>(writer: &mut Writer<W>, row: &TableRow) -> Result<()> {
if !row.cant_split && row.height_twips.is_none() && !row.repeat_as_header_row {
return Ok(());
}
writer.write_event(Event::Start(BytesStart::new("w:trPr")))?;
if row.cant_split {
writer.write_event(Event::Empty(BytesStart::new("w:cantSplit")))?;
}
if let Some(height_twips) = row.height_twips {
let mut tr_height = BytesStart::new("w:trHeight");
tr_height.push_attribute(("w:val", height_twips.to_string().as_str()));
tr_height.push_attribute(("w:hRule", "atLeast"));
writer.write_event(Event::Empty(tr_height))?;
}
if row.repeat_as_header_row {
writer.write_event(Event::Empty(BytesStart::new("w:tblHeader")))?;
}
writer.write_event(Event::End(BytesEnd::new("w:trPr")))?;
Ok(())
}
fn write_table_cell_properties<W: Write>(writer: &mut Writer<W>, cell: &TableCell) -> Result<()> {
if cell.horizontal_span.is_none()
&& cell.vertical_merge.is_none()
&& !cell.border
&& cell.shading_color.is_none()
&& cell.margin_top_twips.is_none()
&& cell.margin_left_twips.is_none()
&& cell.margin_bottom_twips.is_none()
&& cell.margin_right_twips.is_none()
&& cell.text_direction.is_none()
&& cell.vertical_alignment.is_none()
{
return Ok(());
}
writer.write_event(Event::Start(BytesStart::new("w:tcPr")))?;
if let Some(horizontal_span) = cell.horizontal_span {
let mut grid_span = BytesStart::new("w:gridSpan");
grid_span.push_attribute(("w:val", horizontal_span.to_string().as_str()));
writer.write_event(Event::Empty(grid_span))?;
}
if let Some(vertical_merge) = cell.vertical_merge {
let mut v_merge = BytesStart::new("w:vMerge");
v_merge.push_attribute(("w:val", vertical_merge.attribute_value()));
writer.write_event(Event::Empty(v_merge))?;
}
if cell.border {
writer.write_event(Event::Start(BytesStart::new("w:tcBorders")))?;
write_border_side(writer, "w:top")?;
write_border_side(writer, "w:left")?;
write_border_side(writer, "w:bottom")?;
write_border_side(writer, "w:right")?;
writer.write_event(Event::End(BytesEnd::new("w:tcBorders")))?;
}
if let Some(shading_color) = &cell.shading_color {
write_shading(writer, "w:shd", shading_color)?;
}
if cell.margin_top_twips.is_some()
|| cell.margin_left_twips.is_some()
|| cell.margin_bottom_twips.is_some()
|| cell.margin_right_twips.is_some()
{
writer.write_event(Event::Start(BytesStart::new("w:tcMar")))?;
write_cell_margin_side(writer, "w:top", cell.margin_top_twips)?;
write_cell_margin_side(writer, "w:left", cell.margin_left_twips)?;
write_cell_margin_side(writer, "w:bottom", cell.margin_bottom_twips)?;
write_cell_margin_side(writer, "w:right", cell.margin_right_twips)?;
writer.write_event(Event::End(BytesEnd::new("w:tcMar")))?;
}
if let Some(text_direction) = cell.text_direction {
let mut element = BytesStart::new("w:textDirection");
element.push_attribute(("w:val", text_direction.attribute_value()));
writer.write_event(Event::Empty(element))?;
}
if let Some(vertical_alignment) = cell.vertical_alignment {
let mut v_align = BytesStart::new("w:vAlign");
v_align.push_attribute(("w:val", vertical_alignment.attribute_value()));
writer.write_event(Event::Empty(v_align))?;
}
writer.write_event(Event::End(BytesEnd::new("w:tcPr")))?;
Ok(())
}
fn write_cell_margin_side<W: Write>(
writer: &mut Writer<W>,
tag_name: &str,
value: Option<u32>,
) -> Result<()> {
if let Some(value) = value {
let mut element = BytesStart::new(tag_name);
element.push_attribute(("w:w", value.to_string().as_str()));
element.push_attribute(("w:type", "dxa"));
writer.write_event(Event::Empty(element))?;
}
Ok(())
}
fn write_structured_document_tag<W: Write>(
writer: &mut Writer<W>,
sdt: &StructuredDocumentTag,
state: &mut PackageState,
relationships: &mut PartRelationships,
) -> Result<()> {
writer.write_event(Event::Start(BytesStart::new("w:sdt")))?;
if sdt.alias.is_some() || sdt.tag.is_some() || sdt.id.is_some() {
writer.write_event(Event::Start(BytesStart::new("w:sdtPr")))?;
if let Some(alias) = &sdt.alias {
let mut element = BytesStart::new("w:alias");
element.push_attribute(("w:val", alias.as_str()));
writer.write_event(Event::Empty(element))?;
}
if let Some(tag) = &sdt.tag {
let mut element = BytesStart::new("w:tag");
element.push_attribute(("w:val", tag.as_str()));
writer.write_event(Event::Empty(element))?;
}
if let Some(id) = sdt.id {
let id_string = id.to_string();
let mut element = BytesStart::new("w:id");
element.push_attribute(("w:val", id_string.as_str()));
writer.write_event(Event::Empty(element))?;
}
writer.write_event(Event::End(BytesEnd::new("w:sdtPr")))?;
}
writer.write_event(Event::Start(BytesStart::new("w:sdtContent")))?;
for block in &sdt.blocks {
write_block(writer, block, state, relationships)?;
}
writer.write_event(Event::End(BytesEnd::new("w:sdtContent")))?;
writer.write_event(Event::End(BytesEnd::new("w:sdt")))?;
Ok(())
}
const DEFAULT_COLUMN_SPACING_TWIPS: u32 = 720;
fn write_section_properties<W: Write>(
writer: &mut Writer<W>,
section_references: SectionReferenceIds<'_>,
page_setup: &PageSetup,
) -> Result<()> {
writer.write_event(Event::Start(BytesStart::new("w:sectPr")))?;
write_header_footer_reference(
writer,
"w:headerReference",
"default",
section_references.header,
)?;
write_header_footer_reference(
writer,
"w:headerReference",
"first",
section_references.header_first,
)?;
write_header_footer_reference(
writer,
"w:headerReference",
"even",
section_references.header_even,
)?;
write_header_footer_reference(
writer,
"w:footerReference",
"default",
section_references.footer,
)?;
write_header_footer_reference(
writer,
"w:footerReference",
"first",
section_references.footer_first,
)?;
write_header_footer_reference(
writer,
"w:footerReference",
"even",
section_references.footer_even,
)?;
let mut page_size = BytesStart::new("w:pgSz");
let width = page_setup.width_twips.to_string();
let height = page_setup.height_twips.to_string();
page_size.push_attribute(("w:w", width.as_str()));
page_size.push_attribute(("w:h", height.as_str()));
if page_setup.orientation == Orientation::Landscape {
page_size.push_attribute(("w:orient", page_setup.orientation.attribute_value()));
}
writer.write_event(Event::Empty(page_size))?;
let mut page_margin = BytesStart::new("w:pgMar");
let top = page_setup.margin_top_twips.to_string();
let right = page_setup.margin_right_twips.to_string();
let bottom = page_setup.margin_bottom_twips.to_string();
let left = page_setup.margin_left_twips.to_string();
let header = page_setup.margin_header_twips.to_string();
let footer = page_setup.margin_footer_twips.to_string();
let gutter = page_setup.margin_gutter_twips.to_string();
page_margin.push_attribute(("w:top", top.as_str()));
page_margin.push_attribute(("w:right", right.as_str()));
page_margin.push_attribute(("w:bottom", bottom.as_str()));
page_margin.push_attribute(("w:left", left.as_str()));
page_margin.push_attribute(("w:header", header.as_str()));
page_margin.push_attribute(("w:footer", footer.as_str()));
page_margin.push_attribute(("w:gutter", gutter.as_str()));
writer.write_event(Event::Empty(page_margin))?;
if let Some(columns) = page_setup.columns {
let mut cols = BytesStart::new("w:cols");
let num = columns.to_string();
let space = DEFAULT_COLUMN_SPACING_TWIPS.to_string();
cols.push_attribute(("w:num", num.as_str()));
cols.push_attribute(("w:space", space.as_str()));
cols.push_attribute(("w:equalWidth", "true"));
writer.write_event(Event::Empty(cols))?;
}
if section_references.title_pg {
writer.write_event(Event::Empty(BytesStart::new("w:titlePg")))?;
}
writer.write_event(Event::End(BytesEnd::new("w:sectPr")))?;
Ok(())
}
fn write_header_footer_reference<W: Write>(
writer: &mut Writer<W>,
element_name: &str,
type_value: &str,
relationship_id: Option<&str>,
) -> Result<()> {
if let Some(id) = relationship_id {
let mut reference = BytesStart::new(element_name);
reference.push_attribute(("w:type", type_value));
reference.push_attribute(("r:id", id));
writer.write_event(Event::Empty(reference))?;
}
Ok(())
}
fn starts_or_ends_with_whitespace(text: &str) -> bool {
text.starts_with(char::is_whitespace) || text.ends_with(char::is_whitespace)
}
fn alignment_value(alignment: Alignment) -> &'static str {
match alignment {
Alignment::Left => "left",
Alignment::Center => "center",
Alignment::Right => "right",
Alignment::Justify => "both",
}
}
fn to_styles_xml(styles: &[Style]) -> Result<Vec<u8>> {
let mut writer = Writer::new(Vec::new());
writer.write_event(Event::Decl(BytesDecl::new(
"1.0",
Some("UTF-8"),
Some("yes"),
)))?;
let mut root = BytesStart::new("w:styles");
root.push_attribute(("xmlns:w", WORDPROCESSINGML_NAMESPACE));
writer.write_event(Event::Start(root))?;
for style in styles {
let mut style_element = BytesStart::new("w:style");
style_element.push_attribute(("w:type", style.kind.attribute_value()));
style_element.push_attribute(("w:styleId", style.id.as_str()));
writer.write_event(Event::Start(style_element))?;
let mut name = BytesStart::new("w:name");
name.push_attribute(("w:val", style.name.as_str()));
writer.write_event(Event::Empty(name))?;
if let Some(based_on) = &style.based_on {
let mut based_on_element = BytesStart::new("w:basedOn");
based_on_element.push_attribute(("w:val", based_on.as_str()));
writer.write_event(Event::Empty(based_on_element))?;
}
if style.kind == StyleKind::Paragraph
&& (style.alignment.is_some()
|| style.paragraph_shading_color.is_some()
|| style.paragraph_border
|| style.line_spacing.is_some()
|| style.space_before.is_some()
|| style.space_after.is_some()
|| style.keep_with_next
|| style.keep_lines_together
|| style.page_break_before
|| !style.tabs.is_empty()
|| style.contextual_spacing)
{
writer.write_event(Event::Start(BytesStart::new("w:pPr")))?;
if style.keep_with_next {
writer.write_event(Event::Empty(BytesStart::new("w:keepNext")))?;
}
if style.keep_lines_together {
writer.write_event(Event::Empty(BytesStart::new("w:keepLines")))?;
}
if style.page_break_before {
writer.write_event(Event::Empty(BytesStart::new("w:pageBreakBefore")))?;
}
if style.paragraph_border {
write_paragraph_border(&mut writer)?;
}
if let Some(paragraph_shading_color) = &style.paragraph_shading_color {
write_shading(&mut writer, "w:shd", paragraph_shading_color)?;
}
if !style.tabs.is_empty() {
write_tabs(&mut writer, &style.tabs)?;
}
if style.line_spacing.is_some()
|| style.space_before.is_some()
|| style.space_after.is_some()
{
write_paragraph_spacing(
&mut writer,
style.line_spacing,
style.space_before,
style.space_after,
)?;
}
if style.contextual_spacing {
writer.write_event(Event::Empty(BytesStart::new("w:contextualSpacing")))?;
}
if let Some(alignment) = style.alignment {
let mut jc = BytesStart::new("w:jc");
jc.push_attribute(("w:val", alignment_value(alignment)));
writer.write_event(Event::Empty(jc))?;
}
writer.write_event(Event::End(BytesEnd::new("w:pPr")))?;
}
if style.font_family.is_some()
|| style.color.is_some()
|| style.font_size.is_some()
|| style.highlight.is_some()
|| style.vertical_align.is_some()
|| style.underline.is_some()
|| style.shading_color.is_some()
|| style.character_spacing.is_some()
|| style.vertical_position.is_some()
|| style.bold
|| style.italic
|| style.strike
|| style.small_caps
|| style.all_caps
|| style.text_border
|| style.hidden
{
writer.write_event(Event::Start(BytesStart::new("w:rPr")))?;
write_character_formatting(
&mut writer,
style.font_family.as_deref(),
style.color.as_deref(),
style.font_size,
style.highlight,
style.bold,
style.italic,
style.underline,
style.underline_color.as_deref(),
style.strike,
style.vertical_align,
style.small_caps,
style.all_caps,
style.shading_color.as_deref(),
style.text_border,
style.character_spacing,
style.vertical_position,
style.hidden,
)?;
writer.write_event(Event::End(BytesEnd::new("w:rPr")))?;
}
writer.write_event(Event::End(BytesEnd::new("w:style")))?;
}
writer.write_event(Event::End(BytesEnd::new("w:styles")))?;
Ok(writer.into_inner())
}
fn to_numbering_xml(definitions: &[NumberingDefinition]) -> Result<Vec<u8>> {
let mut writer = Writer::new(Vec::new());
writer.write_event(Event::Decl(BytesDecl::new(
"1.0",
Some("UTF-8"),
Some("yes"),
)))?;
let mut root = BytesStart::new("w:numbering");
root.push_attribute(("xmlns:w", WORDPROCESSINGML_NAMESPACE));
writer.write_event(Event::Start(root))?;
for definition in definitions {
let abstract_num_id = definition.id.to_string();
let mut abstract_num = BytesStart::new("w:abstractNum");
abstract_num.push_attribute(("w:abstractNumId", abstract_num_id.as_str()));
writer.write_event(Event::Start(abstract_num))?;
for (level_index, level) in definition.levels.iter().enumerate() {
let ilvl = level_index.to_string();
let mut lvl = BytesStart::new("w:lvl");
lvl.push_attribute(("w:ilvl", ilvl.as_str()));
writer.write_event(Event::Start(lvl))?;
let mut start = BytesStart::new("w:start");
start.push_attribute(("w:val", "1"));
writer.write_event(Event::Empty(start))?;
let mut num_fmt = BytesStart::new("w:numFmt");
num_fmt.push_attribute(("w:val", level.format.attribute_value()));
writer.write_event(Event::Empty(num_fmt))?;
let mut lvl_text = BytesStart::new("w:lvlText");
lvl_text.push_attribute(("w:val", level.text.as_str()));
writer.write_event(Event::Empty(lvl_text))?;
let mut lvl_jc = BytesStart::new("w:lvlJc");
lvl_jc.push_attribute(("w:val", "left"));
writer.write_event(Event::Empty(lvl_jc))?;
writer.write_event(Event::Start(BytesStart::new("w:pPr")))?;
let mut ind = BytesStart::new("w:ind");
ind.push_attribute(("w:left", level.indent_twips.to_string().as_str()));
ind.push_attribute(("w:hanging", level.hanging_twips.to_string().as_str()));
writer.write_event(Event::Empty(ind))?;
writer.write_event(Event::End(BytesEnd::new("w:pPr")))?;
writer.write_event(Event::End(BytesEnd::new("w:lvl")))?;
}
writer.write_event(Event::End(BytesEnd::new("w:abstractNum")))?;
}
for definition in definitions {
let num_id = definition.id.to_string();
let mut num = BytesStart::new("w:num");
num.push_attribute(("w:numId", num_id.as_str()));
writer.write_event(Event::Start(num))?;
let mut abstract_num_id_ref = BytesStart::new("w:abstractNumId");
abstract_num_id_ref.push_attribute(("w:val", num_id.as_str()));
writer.write_event(Event::Empty(abstract_num_id_ref))?;
writer.write_event(Event::End(BytesEnd::new("w:num")))?;
}
writer.write_event(Event::End(BytesEnd::new("w:numbering")))?;
Ok(writer.into_inner())
}
fn to_notes_xml(
root_name: &str,
note_element_name: &str,
notes: &[Note],
state: &mut PackageState,
relationships: &mut PartRelationships,
) -> Result<Vec<u8>> {
let mut writer = Writer::new(Vec::new());
writer.write_event(Event::Decl(BytesDecl::new(
"1.0",
Some("UTF-8"),
Some("yes"),
)))?;
let mut root = BytesStart::new(root_name);
root.push_attribute(("xmlns:w", WORDPROCESSINGML_NAMESPACE));
root.push_attribute(("xmlns:r", RELATIONSHIPS_NAMESPACE));
root.push_attribute(("xmlns:wp", WORDPROCESSING_DRAWING_NAMESPACE));
root.push_attribute(("xmlns:a", DRAWINGML_NAMESPACE));
root.push_attribute(("xmlns:pic", DRAWINGML_PICTURE_NAMESPACE));
writer.write_event(Event::Start(root))?;
for note in notes {
let id = note.id.to_string();
let mut note_element = BytesStart::new(note_element_name);
note_element.push_attribute(("w:id", id.as_str()));
writer.write_event(Event::Start(note_element))?;
for block in ¬e.blocks {
write_block(&mut writer, block, state, relationships)?;
}
writer.write_event(Event::End(BytesEnd::new(note_element_name)))?;
}
writer.write_event(Event::End(BytesEnd::new(root_name)))?;
Ok(writer.into_inner())
}
fn to_comments_xml(
comments: &[Comment],
state: &mut PackageState,
relationships: &mut PartRelationships,
) -> Result<Vec<u8>> {
let mut writer = Writer::new(Vec::new());
writer.write_event(Event::Decl(BytesDecl::new(
"1.0",
Some("UTF-8"),
Some("yes"),
)))?;
let mut root = BytesStart::new("w:comments");
root.push_attribute(("xmlns:w", WORDPROCESSINGML_NAMESPACE));
root.push_attribute(("xmlns:r", RELATIONSHIPS_NAMESPACE));
root.push_attribute(("xmlns:wp", WORDPROCESSING_DRAWING_NAMESPACE));
root.push_attribute(("xmlns:a", DRAWINGML_NAMESPACE));
root.push_attribute(("xmlns:pic", DRAWINGML_PICTURE_NAMESPACE));
writer.write_event(Event::Start(root))?;
for comment in comments {
let id = comment.id.to_string();
let mut comment_element = BytesStart::new("w:comment");
comment_element.push_attribute(("w:id", id.as_str()));
if let Some(author) = &comment.author {
comment_element.push_attribute(("w:author", author.as_str()));
}
if let Some(date) = &comment.date {
comment_element.push_attribute(("w:date", date.as_str()));
}
if let Some(initials) = &comment.initials {
comment_element.push_attribute(("w:initials", initials.as_str()));
}
writer.write_event(Event::Start(comment_element))?;
for block in &comment.blocks {
write_block(&mut writer, block, state, relationships)?;
}
writer.write_event(Event::End(BytesEnd::new("w:comment")))?;
}
writer.write_event(Event::End(BytesEnd::new("w:comments")))?;
Ok(writer.into_inner())
}
fn to_theme_xml(theme: &Theme) -> Result<Vec<u8>> {
let mut writer = Writer::new(Vec::new());
writer.write_event(Event::Decl(BytesDecl::new(
"1.0",
Some("UTF-8"),
Some("yes"),
)))?;
let mut root = BytesStart::new("a:theme");
root.push_attribute(("xmlns:a", DRAWINGML_NAMESPACE));
root.push_attribute(("name", theme.name.as_str()));
writer.write_event(Event::Start(root))?;
writer.write_event(Event::Start(BytesStart::new("a:themeElements")))?;
write_color_scheme(&mut writer, &theme.name, &theme.colors)?;
write_font_scheme(&mut writer, &theme.name, &theme.fonts)?;
writer.write_raw(FIXED_FORMAT_SCHEME_XML)?;
writer.write_event(Event::End(BytesEnd::new("a:themeElements")))?;
writer.write_event(Event::End(BytesEnd::new("a:theme")))?;
Ok(writer.into_inner())
}
fn write_color_scheme<W: Write>(
writer: &mut Writer<W>,
name: &str,
colors: &ColorScheme,
) -> Result<()> {
let mut clr_scheme = BytesStart::new("a:clrScheme");
clr_scheme.push_attribute(("name", name));
writer.write_event(Event::Start(clr_scheme))?;
for (slot, value) in [
("dk1", &colors.dark1),
("lt1", &colors.light1),
("dk2", &colors.dark2),
("lt2", &colors.light2),
("accent1", &colors.accent1),
("accent2", &colors.accent2),
("accent3", &colors.accent3),
("accent4", &colors.accent4),
("accent5", &colors.accent5),
("accent6", &colors.accent6),
("hlink", &colors.hyperlink),
("folHlink", &colors.followed_hyperlink),
] {
writer.write_event(Event::Start(BytesStart::new(format!("a:{slot}"))))?;
let mut srgb_clr = BytesStart::new("a:srgbClr");
srgb_clr.push_attribute(("val", value.as_str()));
writer.write_event(Event::Empty(srgb_clr))?;
writer.write_event(Event::End(BytesEnd::new(format!("a:{slot}"))))?;
}
writer.write_event(Event::End(BytesEnd::new("a:clrScheme")))?;
Ok(())
}
fn write_font_scheme<W: Write>(
writer: &mut Writer<W>,
name: &str,
fonts: &FontScheme,
) -> Result<()> {
let mut font_scheme = BytesStart::new("a:fontScheme");
font_scheme.push_attribute(("name", name));
writer.write_event(Event::Start(font_scheme))?;
for (wrapper, typeface) in [
("a:majorFont", &fonts.major_latin),
("a:minorFont", &fonts.minor_latin),
] {
writer.write_event(Event::Start(BytesStart::new(wrapper)))?;
let mut latin = BytesStart::new("a:latin");
latin.push_attribute(("typeface", typeface.as_str()));
writer.write_event(Event::Empty(latin))?;
let mut ea = BytesStart::new("a:ea");
ea.push_attribute(("typeface", ""));
writer.write_event(Event::Empty(ea))?;
let mut cs = BytesStart::new("a:cs");
cs.push_attribute(("typeface", ""));
writer.write_event(Event::Empty(cs))?;
writer.write_event(Event::End(BytesEnd::new(wrapper)))?;
}
writer.write_event(Event::End(BytesEnd::new("a:fontScheme")))?;
Ok(())
}
const FIXED_FORMAT_SCHEME_XML: &str = concat!(
"<a:fmtScheme name=\"Office\">",
"<a:fillStyleLst>",
"<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill>",
"<a:gradFill rotWithShape=\"1\"><a:gsLst>",
"<a:gs pos=\"0\"><a:schemeClr val=\"phClr\"><a:tint val=\"50000\"/><a:satMod val=\"300000\"/></a:schemeClr></a:gs>",
"<a:gs pos=\"35000\"><a:schemeClr val=\"phClr\"><a:tint val=\"37000\"/><a:satMod val=\"300000\"/></a:schemeClr></a:gs>",
"<a:gs pos=\"100000\"><a:schemeClr val=\"phClr\"><a:tint val=\"15000\"/><a:satMod val=\"350000\"/></a:schemeClr></a:gs>",
"</a:gsLst><a:lin ang=\"16200000\" scaled=\"1\"/></a:gradFill>",
"<a:gradFill rotWithShape=\"1\"><a:gsLst>",
"<a:gs pos=\"0\"><a:schemeClr val=\"phClr\"><a:tint val=\"100000\"/><a:shade val=\"100000\"/><a:satMod val=\"130000\"/></a:schemeClr></a:gs>",
"<a:gs pos=\"100000\"><a:schemeClr val=\"phClr\"><a:tint val=\"50000\"/><a:shade val=\"100000\"/><a:satMod val=\"350000\"/></a:schemeClr></a:gs>",
"</a:gsLst><a:lin ang=\"16200000\" scaled=\"0\"/></a:gradFill>",
"</a:fillStyleLst>",
"<a:lnStyleLst>",
"<a:ln w=\"9525\" cap=\"flat\" cmpd=\"sng\" algn=\"ctr\"><a:solidFill><a:schemeClr val=\"phClr\"><a:shade val=\"95000\"/><a:satMod val=\"105000\"/></a:schemeClr></a:solidFill><a:prstDash val=\"solid\"/></a:ln>",
"<a:ln w=\"25400\" cap=\"flat\" cmpd=\"sng\" algn=\"ctr\"><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill><a:prstDash val=\"solid\"/></a:ln>",
"<a:ln w=\"38100\" cap=\"flat\" cmpd=\"sng\" algn=\"ctr\"><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill><a:prstDash val=\"solid\"/></a:ln>",
"</a:lnStyleLst>",
"<a:effectStyleLst>",
"<a:effectStyle><a:effectLst><a:outerShdw blurRad=\"40000\" dist=\"20000\" dir=\"5400000\" rotWithShape=\"0\"><a:srgbClr val=\"000000\"><a:alpha val=\"38000\"/></a:srgbClr></a:outerShdw></a:effectLst></a:effectStyle>",
"<a:effectStyle><a:effectLst><a:outerShdw blurRad=\"40000\" dist=\"23000\" dir=\"5400000\" rotWithShape=\"0\"><a:srgbClr val=\"000000\"><a:alpha val=\"35000\"/></a:srgbClr></a:outerShdw></a:effectLst></a:effectStyle>",
"<a:effectStyle>",
"<a:effectLst><a:outerShdw blurRad=\"40000\" dist=\"23000\" dir=\"5400000\" rotWithShape=\"0\"><a:srgbClr val=\"000000\"><a:alpha val=\"35000\"/></a:srgbClr></a:outerShdw></a:effectLst>",
"<a:scene3d><a:camera prst=\"orthographicFront\"><a:rot lat=\"0\" lon=\"0\" rev=\"0\"/></a:camera><a:lightRig rig=\"threePt\" dir=\"t\"><a:rot lat=\"0\" lon=\"0\" rev=\"1200000\"/></a:lightRig></a:scene3d>",
"<a:sp3d><a:bevelT w=\"63500\" h=\"25400\"/></a:sp3d>",
"</a:effectStyle>",
"</a:effectStyleLst>",
"<a:bgFillStyleLst>",
"<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill>",
"<a:gradFill rotWithShape=\"1\"><a:gsLst>",
"<a:gs pos=\"0\"><a:schemeClr val=\"phClr\"><a:tint val=\"40000\"/><a:satMod val=\"350000\"/></a:schemeClr></a:gs>",
"<a:gs pos=\"40000\"><a:schemeClr val=\"phClr\"><a:tint val=\"45000\"/><a:shade val=\"99000\"/><a:satMod val=\"350000\"/></a:schemeClr></a:gs>",
"<a:gs pos=\"100000\"><a:schemeClr val=\"phClr\"><a:shade val=\"20000\"/><a:satMod val=\"255000\"/></a:schemeClr></a:gs>",
"</a:gsLst><a:path path=\"circle\"><a:fillToRect l=\"50000\" t=\"-80000\" r=\"50000\" b=\"180000\"/></a:path></a:gradFill>",
"<a:gradFill rotWithShape=\"1\"><a:gsLst>",
"<a:gs pos=\"0\"><a:schemeClr val=\"phClr\"><a:tint val=\"80000\"/><a:satMod val=\"300000\"/></a:schemeClr></a:gs>",
"<a:gs pos=\"100000\"><a:schemeClr val=\"phClr\"><a:shade val=\"30000\"/><a:satMod val=\"200000\"/></a:schemeClr></a:gs>",
"</a:gsLst><a:path path=\"circle\"><a:fillToRect l=\"50000\" t=\"50000\" r=\"50000\" b=\"50000\"/></a:path></a:gradFill>",
"</a:bgFillStyleLst>",
"</a:fmtScheme>",
);
fn to_settings_xml(
track_changes: bool,
protection: Option<ProtectionKind>,
even_and_odd_headers: bool,
) -> Result<Vec<u8>> {
let mut writer = Writer::new(Vec::new());
writer.write_event(Event::Decl(BytesDecl::new(
"1.0",
Some("UTF-8"),
Some("yes"),
)))?;
let mut root = BytesStart::new("w:settings");
root.push_attribute(("xmlns:w", WORDPROCESSINGML_NAMESPACE));
if track_changes || protection.is_some() || even_and_odd_headers {
writer.write_event(Event::Start(root))?;
if track_changes {
writer.write_event(Event::Empty(BytesStart::new("w:trackRevisions")))?;
}
if let Some(protection) = protection {
let mut document_protection = BytesStart::new("w:documentProtection");
document_protection.push_attribute(("w:edit", protection.attribute_value()));
document_protection.push_attribute(("w:enforcement", "1"));
writer.write_event(Event::Empty(document_protection))?;
}
if even_and_odd_headers {
writer.write_event(Event::Empty(BytesStart::new("w:evenAndOddHeaders")))?;
}
writer.write_event(Event::End(BytesEnd::new("w:settings")))?;
} else {
writer.write_event(Event::Empty(root))?;
}
Ok(writer.into_inner())
}
fn to_core_properties_xml(properties: &DocumentProperties) -> Result<Vec<u8>> {
let mut writer = Writer::new(Vec::new());
writer.write_event(Event::Decl(BytesDecl::new(
"1.0",
Some("UTF-8"),
Some("yes"),
)))?;
let mut root = BytesStart::new("cp:coreProperties");
root.push_attribute(("xmlns:cp", CORE_PROPERTIES_NAMESPACE));
root.push_attribute(("xmlns:dc", DC_NAMESPACE));
root.push_attribute(("xmlns:dcterms", DCTERMS_NAMESPACE));
root.push_attribute(("xmlns:xsi", XSI_NAMESPACE));
let has_any_property = properties.title.is_some()
|| properties.subject.is_some()
|| properties.creator.is_some()
|| properties.keywords.is_some()
|| properties.description.is_some()
|| properties.last_modified_by.is_some()
|| properties.revision.is_some()
|| properties.created.is_some()
|| properties.modified.is_some()
|| properties.category.is_some()
|| properties.content_status.is_some();
if !has_any_property {
writer.write_event(Event::Empty(root))?;
return Ok(writer.into_inner());
}
writer.write_event(Event::Start(root))?;
write_core_property_text(&mut writer, "dc:title", properties.title.as_deref())?;
write_core_property_text(&mut writer, "dc:subject", properties.subject.as_deref())?;
write_core_property_text(&mut writer, "dc:creator", properties.creator.as_deref())?;
write_core_property_text(&mut writer, "cp:keywords", properties.keywords.as_deref())?;
write_core_property_text(
&mut writer,
"dc:description",
properties.description.as_deref(),
)?;
write_core_property_text(
&mut writer,
"cp:lastModifiedBy",
properties.last_modified_by.as_deref(),
)?;
write_core_property_text(&mut writer, "cp:revision", properties.revision.as_deref())?;
write_core_property_datetime(
&mut writer,
"dcterms:created",
properties.created.as_deref(),
)?;
write_core_property_datetime(
&mut writer,
"dcterms:modified",
properties.modified.as_deref(),
)?;
write_core_property_text(&mut writer, "cp:category", properties.category.as_deref())?;
write_core_property_text(
&mut writer,
"cp:contentStatus",
properties.content_status.as_deref(),
)?;
writer.write_event(Event::End(BytesEnd::new("cp:coreProperties")))?;
Ok(writer.into_inner())
}
fn write_core_property_text<W: Write>(
writer: &mut Writer<W>,
element_name: &str,
value: Option<&str>,
) -> Result<()> {
if let Some(value) = value {
writer.write_event(Event::Start(BytesStart::new(element_name)))?;
writer.write_event(Event::Text(BytesText::new(value)))?;
writer.write_event(Event::End(BytesEnd::new(element_name)))?;
}
Ok(())
}
fn write_core_property_datetime<W: Write>(
writer: &mut Writer<W>,
element_name: &str,
value: Option<&str>,
) -> Result<()> {
if let Some(value) = value {
let mut start = BytesStart::new(element_name);
start.push_attribute(("xsi:type", "dcterms:W3CDTF"));
writer.write_event(Event::Start(start))?;
writer.write_event(Event::Text(BytesText::new(value)))?;
writer.write_event(Event::End(BytesEnd::new(element_name)))?;
}
Ok(())
}
fn to_extended_properties_xml(extended_properties: &ExtendedProperties) -> Result<Vec<u8>> {
let mut writer = Writer::new(Vec::new());
writer.write_event(Event::Decl(BytesDecl::new(
"1.0",
Some("UTF-8"),
Some("yes"),
)))?;
let mut root = BytesStart::new("Properties");
root.push_attribute(("xmlns", EXTENDED_PROPERTIES_NAMESPACE));
writer.write_event(Event::Start(root))?;
writer.write_event(Event::Start(BytesStart::new("Application")))?;
writer.write_event(Event::Text(BytesText::new("office-toolkit")))?;
writer.write_event(Event::End(BytesEnd::new("Application")))?;
write_core_property_text(
&mut writer,
"Manager",
extended_properties.manager.as_deref(),
)?;
write_core_property_text(
&mut writer,
"Company",
extended_properties.company.as_deref(),
)?;
writer.write_event(Event::End(BytesEnd::new("Properties")))?;
Ok(writer.into_inner())
}
fn to_custom_properties_xml(
custom_properties: &[(String, CustomPropertyValue)],
) -> Result<Vec<u8>> {
let mut writer = Writer::new(Vec::new());
writer.write_event(Event::Decl(BytesDecl::new(
"1.0",
Some("UTF-8"),
Some("yes"),
)))?;
let mut root = BytesStart::new("Properties");
root.push_attribute(("xmlns", CUSTOM_PROPERTIES_NAMESPACE));
root.push_attribute(("xmlns:vt", CUSTOM_PROPERTIES_VT_NAMESPACE));
writer.write_event(Event::Start(root))?;
for (index, (name, value)) in custom_properties.iter().enumerate() {
let pid = FIRST_CUSTOM_PROPERTY_PID + index as i32;
let mut property = BytesStart::new("property");
property.push_attribute(("fmtid", CUSTOM_PROPERTY_FMTID));
property.push_attribute(("pid", pid.to_string().as_str()));
property.push_attribute(("name", name.as_str()));
writer.write_event(Event::Start(property))?;
let (variant_element, text) = match value {
CustomPropertyValue::Text(text) => ("vt:lpwstr", text.clone()),
CustomPropertyValue::Bool(value) => (
"vt:bool",
if *value {
"true".to_string()
} else {
"false".to_string()
},
),
CustomPropertyValue::Int(value) => ("vt:i4", value.to_string()),
CustomPropertyValue::Number(value) => ("vt:r8", value.to_string()),
};
writer.write_event(Event::Start(BytesStart::new(variant_element)))?;
writer.write_event(Event::Text(BytesText::new(&text)))?;
writer.write_event(Event::End(BytesEnd::new(variant_element)))?;
writer.write_event(Event::End(BytesEnd::new("property")))?;
}
writer.write_event(Event::End(BytesEnd::new("Properties")))?;
Ok(writer.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Paragraph;
fn document_xml(document: &Document) -> String {
let xml = document
.to_document_xml(
&mut PackageState::new(),
&mut PartRelationships::new(),
SectionReferenceIds::default(),
)
.unwrap();
String::from_utf8(xml).unwrap()
}
#[test]
fn writes_paragraphs_and_runs() {
let document = Document::new()
.with_paragraph(Paragraph::with_text("Hello"))
.with_paragraph(Paragraph::with_text("World"));
let xml = document_xml(&document);
assert!(xml.contains("<w:t>Hello</w:t>"), "{xml}");
assert!(xml.contains("<w:t>World</w:t>"), "{xml}");
assert!(xml.contains("<w:sectPr>"), "{xml}");
}
#[test]
fn marks_runs_with_significant_whitespace_as_preserved() {
let document = Document::new().with_paragraph(Paragraph::with_text(" padded "));
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:t xml:space="preserve"> padded </w:t>"#),
"{xml}"
);
}
#[test]
fn escapes_xml_special_characters_in_written_text() {
let text = r#"A & B < C > D ' E " F"#;
let document = Document::new().with_paragraph(Paragraph::with_text(text));
let xml = document_xml(&document);
assert!(
xml.contains("<w:t>A & B < C > D ' E " F</w:t>"),
"expected escaped entities in the raw XML, got: {xml}"
);
}
#[test]
fn writes_bold_italic_and_underline_run_properties() {
use crate::model::{Run, UnderlineStyle};
let document = Document::new().with_paragraph(
Paragraph::new().with_run(
Run::new("Styled")
.with_bold(true)
.with_italic(true)
.with_underline(UnderlineStyle::Single),
),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:rPr><w:b/><w:i/><w:u w:val="single"/></w:rPr><w:t>Styled</w:t>"#),
"{xml}"
);
}
#[test]
fn writes_color_size_and_font_family_run_properties() {
use crate::model::Run;
let document = Document::new().with_paragraph(
Paragraph::new().with_run(
Run::new("Colorful")
.with_color("FF0000")
.with_font_size(14)
.with_font_family("Georgia"),
),
);
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:rPr><w:rFonts w:ascii="Georgia" w:hAnsi="Georgia"/><w:color w:val="FF0000"/><w:sz w:val="28"/><w:szCs w:val="28"/></w:rPr><w:t>Colorful</w:t>"#
),
"{xml}"
);
}
#[test]
fn combines_color_size_font_and_bold_italic_underline_in_one_run() {
use crate::model::{Run, UnderlineStyle};
let document = Document::new().with_paragraph(
Paragraph::new().with_run(
Run::new("All of it")
.with_color("0000FF")
.with_font_size(10)
.with_font_family("Verdana")
.with_bold(true)
.with_italic(true)
.with_underline(UnderlineStyle::Single),
),
);
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:rPr><w:rFonts w:ascii="Verdana" w:hAnsi="Verdana"/><w:color w:val="0000FF"/><w:sz w:val="20"/><w:szCs w:val="20"/><w:b/><w:i/><w:u w:val="single"/></w:rPr>"#
),
"{xml}"
);
}
#[test]
fn writes_a_highlighted_run() {
use crate::model::{Highlight, Run};
let document = Document::new().with_paragraph(
Paragraph::new().with_run(Run::new("Highlighted").with_highlight(Highlight::Yellow)),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:rPr><w:highlight w:val="yellow"/></w:rPr><w:t>Highlighted</w:t>"#),
"{xml}"
);
}
#[test]
fn combines_highlight_with_color_size_font_and_bold_italic_underline() {
use crate::model::{Highlight, Run, UnderlineStyle};
let document = Document::new().with_paragraph(
Paragraph::new().with_run(
Run::new("All of it")
.with_color("0000FF")
.with_font_size(10)
.with_font_family("Verdana")
.with_highlight(Highlight::Cyan)
.with_bold(true)
.with_italic(true)
.with_underline(UnderlineStyle::Single),
),
);
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:rPr><w:rFonts w:ascii="Verdana" w:hAnsi="Verdana"/><w:color w:val="0000FF"/><w:sz w:val="20"/><w:szCs w:val="20"/><w:highlight w:val="cyan"/><w:b/><w:i/><w:u w:val="single"/></w:rPr>"#
),
"{xml}"
);
}
#[test]
fn writes_a_struck_through_run() {
use crate::model::Run;
let document = Document::new()
.with_paragraph(Paragraph::new().with_run(Run::new("Struck").with_strike(true)));
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:rPr><w:strike/></w:rPr><w:t>Struck</w:t>"#),
"{xml}"
);
}
#[test]
fn writes_a_superscript_run() {
use crate::model::{Run, VerticalAlign};
let document = Document::new().with_paragraph(
Paragraph::new()
.with_run(Run::new("Superscript").with_vertical_align(VerticalAlign::Superscript)),
);
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:rPr><w:vertAlign w:val="superscript"/></w:rPr><w:t>Superscript</w:t>"#
),
"{xml}"
);
}
#[test]
fn writes_a_subscript_run() {
use crate::model::{Run, VerticalAlign};
let document = Document::new().with_paragraph(
Paragraph::new()
.with_run(Run::new("Subscript").with_vertical_align(VerticalAlign::Subscript)),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:rPr><w:vertAlign w:val="subscript"/></w:rPr><w:t>Subscript</w:t>"#),
"{xml}"
);
}
#[test]
fn combines_strike_and_vertical_align_with_highlight_color_size_font_and_bold_italic_underline()
{
use crate::model::{Highlight, Run, UnderlineStyle, VerticalAlign};
let document = Document::new().with_paragraph(
Paragraph::new().with_run(
Run::new("Everything")
.with_color("0000FF")
.with_font_size(10)
.with_font_family("Verdana")
.with_highlight(Highlight::Cyan)
.with_bold(true)
.with_italic(true)
.with_underline(UnderlineStyle::Single)
.with_strike(true)
.with_vertical_align(VerticalAlign::Superscript),
),
);
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:rPr><w:rFonts w:ascii="Verdana" w:hAnsi="Verdana"/><w:color w:val="0000FF"/><w:sz w:val="20"/><w:szCs w:val="20"/><w:highlight w:val="cyan"/><w:b/><w:i/><w:u w:val="single"/><w:strike/><w:vertAlign w:val="superscript"/></w:rPr>"#
),
"{xml}"
);
}
#[test]
fn writes_a_wave_underline() {
use crate::model::{Run, UnderlineStyle};
let document = Document::new().with_paragraph(
Paragraph::new().with_run(Run::new("Wavy").with_underline(UnderlineStyle::Wave)),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:rPr><w:u w:val="wave"/></w:rPr><w:t>Wavy</w:t>"#),
"{xml}"
);
}
#[test]
fn writes_an_underline_color() {
use crate::model::{Run, UnderlineStyle};
let document = Document::new().with_paragraph(
Paragraph::new().with_run(
Run::new("Red wave")
.with_underline(UnderlineStyle::Wave)
.with_underline_color("FF0000"),
),
);
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:rPr><w:u w:val="wave" w:color="FF0000"/></w:rPr><w:t>Red wave</w:t>"#
),
"{xml}"
);
}
#[test]
fn writes_small_caps_and_all_caps() {
use crate::model::Run;
let document = Document::new()
.with_paragraph(Paragraph::new().with_run(Run::new("Small caps").with_small_caps(true)))
.with_paragraph(Paragraph::new().with_run(Run::new("All caps").with_all_caps(true)));
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:rPr><w:smallCaps/></w:rPr><w:t>Small caps</w:t>"#),
"{xml}"
);
assert!(
xml.contains(r#"<w:rPr><w:caps/></w:rPr><w:t>All caps</w:t>"#),
"{xml}"
);
}
#[test]
fn writes_a_shading_color() {
use crate::model::Run;
let document = Document::new().with_paragraph(
Paragraph::new().with_run(Run::new("Shaded").with_shading_color("FFFF00")),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:rPr><w:shd w:val="clear" w:color="auto" w:fill="FFFF00"/></w:rPr><w:t>Shaded</w:t>"#),
"{xml}"
);
}
#[test]
fn writes_a_text_border() {
use crate::model::Run;
let document = Document::new()
.with_paragraph(Paragraph::new().with_run(Run::new("Bordered").with_text_border(true)));
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:rPr><w:bdr w:val="single" w:sz="4" w:space="0" w:color="auto"/></w:rPr><w:t>Bordered</w:t>"#
),
"{xml}"
);
}
#[test]
fn writes_character_spacing_and_vertical_position() {
use crate::model::Run;
let document = Document::new()
.with_paragraph(
Paragraph::new().with_run(Run::new("Expanded").with_character_spacing(40)),
)
.with_paragraph(
Paragraph::new().with_run(Run::new("Condensed").with_character_spacing(-20)),
)
.with_paragraph(Paragraph::new().with_run(Run::new("Raised").with_vertical_position(6)))
.with_paragraph(
Paragraph::new().with_run(Run::new("Lowered").with_vertical_position(-6)),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:rPr><w:spacing w:val="40"/></w:rPr><w:t>Expanded</w:t>"#),
"{xml}"
);
assert!(
xml.contains(r#"<w:rPr><w:spacing w:val="-20"/></w:rPr><w:t>Condensed</w:t>"#),
"{xml}"
);
assert!(
xml.contains(r#"<w:rPr><w:position w:val="6"/></w:rPr><w:t>Raised</w:t>"#),
"{xml}"
);
assert!(
xml.contains(r#"<w:rPr><w:position w:val="-6"/></w:rPr><w:t>Lowered</w:t>"#),
"{xml}"
);
}
#[test]
fn writes_hidden_text() {
use crate::model::Run;
let document = Document::new()
.with_paragraph(Paragraph::new().with_run(Run::new("Hidden").with_hidden(true)));
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:rPr><w:vanish/></w:rPr><w:t>Hidden</w:t>"#),
"{xml}"
);
}
#[test]
fn plain_runs_have_no_run_properties_element() {
let document = Document::new().with_paragraph(Paragraph::with_text("Plain"));
let xml = document_xml(&document);
assert!(!xml.contains("w:rPr"), "{xml}");
}
#[test]
fn writes_paragraph_alignment_before_its_runs() {
let document = Document::new()
.with_paragraph(Paragraph::with_text("Centered").with_alignment(Alignment::Center));
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:p><w:pPr><w:jc w:val="center"/></w:pPr><w:r>"#),
"{xml}"
);
}
#[test]
fn writes_paragraph_line_spacing_and_before_after() {
let document = Document::new().with_paragraph(
Paragraph::with_text("Spaced out")
.with_line_spacing(480)
.with_space_before(240)
.with_space_after(120),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:pPr><w:spacing w:before="240" w:after="120" w:line="480" w:lineRule="auto"/></w:pPr>"#),
"{xml}"
);
}
#[test]
fn writes_a_paragraph_shading_color() {
let document = Document::new()
.with_paragraph(Paragraph::with_text("Shaded paragraph").with_shading_color("D9D9D9"));
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:pPr><w:shd w:val="clear" w:color="auto" w:fill="D9D9D9"/></w:pPr>"#),
"{xml}"
);
}
#[test]
fn writes_a_paragraph_border_on_all_four_sides() {
let document = Document::new()
.with_paragraph(Paragraph::with_text("Boxed paragraph").with_border(true));
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:pPr><w:pBdr><w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/></w:pBdr></w:pPr>"#
),
"{xml}"
);
}
#[test]
fn writes_pbdr_before_shd_before_spacing_before_jc() {
let document = Document::new().with_paragraph(
Paragraph::with_text("All paragraph properties at once")
.with_border(true)
.with_shading_color("FFFF00")
.with_line_spacing(360)
.with_alignment(Alignment::Center),
);
let xml = document_xml(&document);
let pbdr = xml.find("<w:pBdr>").expect("missing w:pBdr");
let shd = xml.find("<w:shd").expect("missing w:shd");
let spacing = xml.find("<w:spacing").expect("missing w:spacing");
let jc = xml.find("<w:jc").expect("missing w:jc");
assert!(pbdr < shd && shd < spacing && spacing < jc, "{xml}");
}
#[test]
fn writes_a_table_with_tblpr_before_tblgrid_before_rows() {
use crate::model::{Table, TableCell, TableRow};
let document = Document::new().with_table(
Table::new().with_row(
TableRow::new()
.with_cell(TableCell::with_text("A1"))
.with_cell(TableCell::with_text("B1")),
),
);
let xml = document_xml(&document);
let tbl_pr = xml.find("<w:tblPr>").expect("missing w:tblPr");
let tbl_grid = xml.find("<w:tblGrid>").expect("missing w:tblGrid");
let first_row = xml.find("<w:tr>").expect("missing w:tr");
assert!(tbl_pr < tbl_grid && tbl_grid < first_row, "{xml}");
assert!(
xml.contains("<w:gridCol w:w=\"2000\"/><w:gridCol w:w=\"2000\"/>"),
"{xml}"
);
assert!(xml.contains("<w:t>A1</w:t>"), "{xml}");
assert!(xml.contains("<w:t>B1</w:t>"), "{xml}");
}
#[test]
fn writes_explicit_column_widths_and_a_fixed_layout() {
use crate::model::{Table, TableCell, TableRow};
let document = Document::new().with_table(
Table::new().with_column_widths(vec![1000, 3000]).with_row(
TableRow::new()
.with_cell(TableCell::with_text("A1"))
.with_cell(TableCell::with_text("B1")),
),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:tblW w:w="4000" w:type="dxa"/>"#),
"{xml}"
);
assert!(xml.contains(r#"<w:tblLayout w:type="fixed"/>"#), "{xml}");
assert!(
xml.contains(r#"<w:gridCol w:w="1000"/><w:gridCol w:w="3000"/>"#),
"{xml}"
);
}
#[test]
fn a_table_with_no_column_widths_keeps_auto_layout() {
use crate::model::{Table, TableCell, TableRow};
let document = Document::new().with_table(
Table::new().with_row(TableRow::new().with_cell(TableCell::with_text("A1"))),
);
let xml = document_xml(&document);
assert!(xml.contains(r#"<w:tblW w:w="0" w:type="auto"/>"#), "{xml}");
assert!(!xml.contains("w:tblLayout"), "{xml}");
}
#[test]
fn column_widths_shorter_than_the_real_column_count_fall_back_to_the_default_width() {
use crate::model::{Table, TableCell, TableRow};
let document = Document::new().with_table(
Table::new().with_column_widths(vec![1000]).with_row(
TableRow::new()
.with_cell(TableCell::with_text("A1"))
.with_cell(TableCell::with_text("B1")),
),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:gridCol w:w="1000"/><w:gridCol w:w="2000"/>"#),
"{xml}"
);
}
#[test]
fn writes_an_empty_paragraph_for_an_empty_table_cell() {
use crate::model::{Table, TableCell, TableRow};
let document = Document::new()
.with_table(Table::new().with_row(TableRow::new().with_cell(TableCell::new())));
let xml = document_xml(&document);
assert!(xml.contains("<w:tc><w:p/></w:tc>"), "{xml}");
}
#[test]
fn writes_a_nested_table_inside_a_cell_followed_by_a_trailing_paragraph() {
use crate::model::{Table, TableCell, TableRow};
let inner_table =
Table::new().with_row(TableRow::new().with_cell(TableCell::with_text("Inner")));
let document = Document::new().with_table(
Table::new()
.with_row(TableRow::new().with_cell(TableCell::new().with_table(inner_table))),
);
let xml = document_xml(&document);
assert!(xml.contains("<w:tc><w:tbl>"), "{xml}");
assert!(xml.contains("</w:tbl><w:p/></w:tc>"), "{xml}");
assert!(xml.contains(">Inner<"), "{xml}");
}
#[test]
fn a_cell_ending_in_a_paragraph_after_a_nested_table_gets_no_extra_trailing_paragraph() {
use crate::model::{Table, TableCell, TableRow};
let inner_table =
Table::new().with_row(TableRow::new().with_cell(TableCell::with_text("Inner")));
let document = Document::new().with_table(
Table::new().with_row(
TableRow::new().with_cell(
TableCell::new()
.with_table(inner_table)
.with_paragraph(Paragraph::with_text("Caption")),
),
),
);
let xml = document_xml(&document);
assert!(xml.contains("</w:tbl><w:p>"), "{xml}");
assert!(xml.contains(">Caption<"), "{xml}");
assert!(!xml.contains("</w:tbl><w:p/><w:p>"), "{xml}");
}
#[test]
fn writes_a_horizontal_cell_merge() {
use crate::model::{Table, TableCell, TableRow};
let document = Document::new().with_table(
Table::new().with_row(
TableRow::new()
.with_cell(TableCell::with_text("Spans two columns").with_horizontal_span(2)),
),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:tc><w:tcPr><w:gridSpan w:val="2"/></w:tcPr><w:p>"#),
"{xml}"
);
}
#[test]
fn writes_a_vertical_cell_merge() {
use crate::model::{Table, TableCell, TableRow, VerticalMerge};
let document = Document::new().with_table(
Table::new()
.with_row(TableRow::new().with_cell(
TableCell::with_text("Merged").with_vertical_merge(VerticalMerge::Restart),
))
.with_row(
TableRow::new()
.with_cell(TableCell::new().with_vertical_merge(VerticalMerge::Continue)),
),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr><w:p>"#),
"{xml}"
);
assert!(
xml.contains(r#"<w:tc><w:tcPr><w:vMerge w:val="continue"/></w:tcPr><w:p/></w:tc>"#),
"{xml}"
);
}
#[test]
fn writes_gridspan_before_vmerge_in_tcpr() {
use crate::model::{Table, TableCell, TableRow, VerticalMerge};
let document = Document::new().with_table(
Table::new().with_row(
TableRow::new().with_cell(
TableCell::with_text("Both at once")
.with_horizontal_span(2)
.with_vertical_merge(VerticalMerge::Restart),
),
),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:tcPr><w:gridSpan w:val="2"/><w:vMerge w:val="restart"/></w:tcPr>"#),
"{xml}"
);
}
#[test]
fn tblgrid_column_count_accounts_for_horizontal_span() {
use crate::model::{Table, TableCell, TableRow};
let document = Document::new().with_table(
Table::new()
.with_row(
TableRow::new()
.with_cell(TableCell::with_text("A1").with_horizontal_span(2))
.with_cell(TableCell::with_text("B1").with_horizontal_span(2)),
)
.with_row(
TableRow::new()
.with_cell(TableCell::with_text("A2").with_horizontal_span(2))
.with_cell(TableCell::with_text("B2").with_horizontal_span(2)),
),
);
let xml = document_xml(&document);
let grid_col_count = xml.matches("<w:gridCol ").count();
assert_eq!(grid_col_count, 4, "{xml}");
}
#[test]
fn writes_a_custom_table_border_color() {
use crate::model::{Table, TableCell, TableRow};
let document = Document::new().with_table(
Table::new()
.with_border_color("FF0000")
.with_row(TableRow::new().with_cell(TableCell::with_text("A1"))),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:top w:val="single" w:sz="4" w:space="0" w:color="FF0000"/>"#),
"{xml}"
);
assert!(!xml.contains(r#"w:color="auto""#), "{xml}");
}
#[test]
fn a_table_with_no_border_color_keeps_the_fixed_auto_color() {
use crate::model::{Table, TableCell, TableRow};
let document = Document::new().with_table(
Table::new().with_row(TableRow::new().with_cell(TableCell::with_text("A1"))),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>"#),
"{xml}"
);
}
#[test]
fn writes_tblstyle_before_tblw_and_jc_and_tblind_after_it() {
use crate::model::{Alignment, Table, TableCell, TableRow};
let document = Document::new().with_table(
Table::new()
.with_style_id("TableGrid")
.with_alignment(Alignment::Center)
.with_indent_twips(200)
.with_row(TableRow::new().with_cell(TableCell::with_text("A1"))),
);
let xml = document_xml(&document);
let tbl_style = xml
.find(r#"<w:tblStyle w:val="TableGrid"/>"#)
.expect("missing w:tblStyle");
let tbl_w = xml.find("<w:tblW ").expect("missing w:tblW");
let jc = xml.find(r#"<w:jc w:val="center"/>"#).expect("missing w:jc");
let tbl_ind = xml
.find(r#"<w:tblInd w:w="200" w:type="dxa"/>"#)
.expect("missing w:tblInd");
let tbl_borders = xml.find("<w:tblBorders>").expect("missing w:tblBorders");
assert!(
tbl_style < tbl_w && tbl_w < jc && jc < tbl_ind && tbl_ind < tbl_borders,
"{xml}"
);
}
#[test]
fn a_table_with_no_alignment_indent_or_style_omits_them() {
use crate::model::{Table, TableCell, TableRow};
let document = Document::new().with_table(
Table::new().with_row(TableRow::new().with_cell(TableCell::with_text("A1"))),
);
let xml = document_xml(&document);
assert!(!xml.contains("w:tblStyle"), "{xml}");
assert!(!xml.contains("w:tblInd"), "{xml}");
assert!(!xml.contains("<w:jc "), "{xml}");
}
#[test]
fn writes_a_cell_border() {
use crate::model::{Table, TableCell, TableRow};
let document =
Document::new().with_table(Table::new().with_row(
TableRow::new().with_cell(TableCell::with_text("Boxed").with_border(true)),
));
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:tcPr><w:tcBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/></w:tcBorders></w:tcPr>"#
),
"{xml}"
);
}
#[test]
fn writes_a_cell_shading_color() {
use crate::model::{Table, TableCell, TableRow};
let document = Document::new().with_table(Table::new().with_row(
TableRow::new().with_cell(TableCell::with_text("Shaded").with_shading_color("FFFF00")),
));
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:tcPr><w:shd w:val="clear" w:color="auto" w:fill="FFFF00"/></w:tcPr>"#
),
"{xml}"
);
}
#[test]
fn writes_a_cell_vertical_alignment() {
use crate::model::{CellVerticalAlign, Table, TableCell, TableRow};
let document =
Document::new().with_table(Table::new().with_row(TableRow::new().with_cell(
TableCell::with_text("Centered").with_vertical_alignment(CellVerticalAlign::Center),
)));
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:tcPr><w:vAlign w:val="center"/></w:tcPr>"#),
"{xml}"
);
}
#[test]
fn writes_cell_margins_in_tcmar_order() {
use crate::model::{Table, TableCell, TableRow};
let document =
Document::new().with_table(Table::new().with_row(
TableRow::new().with_cell(
TableCell::with_text("Padded").with_margins_twips(100, 200, 300, 400),
),
));
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:tcMar><w:top w:w="100" w:type="dxa"/><w:left w:w="200" w:type="dxa"/><w:bottom w:w="300" w:type="dxa"/><w:right w:w="400" w:type="dxa"/></w:tcMar>"#
),
"{xml}"
);
}
#[test]
fn writes_a_cell_text_direction() {
use crate::model::{Table, TableCell, TableRow, TextDirection};
let document =
Document::new().with_table(Table::new().with_row(TableRow::new().with_cell(
TableCell::with_text("Rotated").with_text_direction(TextDirection::TopToBottom),
)));
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:tcPr><w:textDirection w:val="tbRl"/></w:tcPr>"#),
"{xml}"
);
}
#[test]
fn writes_tcpr_children_in_ct_tcpr_sequence_order() {
use crate::model::{
CellVerticalAlign, Table, TableCell, TableRow, TextDirection, VerticalMerge,
};
let document = Document::new().with_table(
Table::new().with_row(
TableRow::new().with_cell(
TableCell::with_text("Everything")
.with_horizontal_span(2)
.with_vertical_merge(VerticalMerge::Restart)
.with_border(true)
.with_shading_color("00FF00")
.with_margins_twips(10, 20, 30, 40)
.with_text_direction(TextDirection::BottomToTop)
.with_vertical_alignment(CellVerticalAlign::Bottom),
),
),
);
let xml = document_xml(&document);
let grid_span = xml.find("<w:gridSpan").expect("missing w:gridSpan");
let v_merge = xml.find("<w:vMerge").expect("missing w:vMerge");
let tc_borders = xml.find("<w:tcBorders>").expect("missing w:tcBorders");
let shd = xml.find("<w:shd").expect("missing w:shd");
let tc_mar = xml.find("<w:tcMar>").expect("missing w:tcMar");
let text_direction = xml
.find("<w:textDirection")
.expect("missing w:textDirection");
let v_align = xml.find("<w:vAlign").expect("missing w:vAlign");
assert!(
grid_span < v_merge
&& v_merge < tc_borders
&& tc_borders < shd
&& shd < tc_mar
&& tc_mar < text_direction
&& text_direction < v_align,
"{xml}"
);
}
#[test]
fn writes_a_repeating_header_row() {
use crate::model::{Table, TableCell, TableRow};
let document = Document::new().with_table(
Table::new().with_row(
TableRow::new()
.with_cell(TableCell::with_text("Header"))
.with_repeat_as_header_row(true),
),
);
let xml = document_xml(&document);
assert!(
xml.contains("<w:tr><w:trPr><w:tblHeader/></w:trPr>"),
"{xml}"
);
}
#[test]
fn writes_a_row_height_with_at_least_rule() {
use crate::model::{Table, TableCell, TableRow};
let document = Document::new().with_table(
Table::new().with_row(
TableRow::new()
.with_cell(TableCell::with_text("Tall"))
.with_height_twips(800),
),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:trHeight w:val="800" w:hRule="atLeast"/>"#),
"{xml}"
);
}
#[test]
fn writes_cant_split_before_tr_height_before_tbl_header() {
use crate::model::{Table, TableCell, TableRow};
let document = Document::new().with_table(
Table::new().with_row(
TableRow::new()
.with_cell(TableCell::with_text("Row"))
.with_cant_split(true)
.with_height_twips(500)
.with_repeat_as_header_row(true),
),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:trPr><w:cantSplit/><w:trHeight w:val="500" w:hRule="atLeast"/><w:tblHeader/></w:trPr>"#),
"{xml}"
);
}
#[test]
fn a_row_with_no_special_properties_omits_trpr() {
use crate::model::{Table, TableCell, TableRow};
let document = Document::new().with_table(
Table::new().with_row(TableRow::new().with_cell(TableCell::with_text("Row"))),
);
let xml = document_xml(&document);
assert!(!xml.contains("w:trPr"), "{xml}");
}
#[test]
fn writes_an_inline_image_and_registers_it_starting_after_settings() {
use crate::model::{Image, ImageFormat, Run};
let document = Document::new().with_paragraph(
Paragraph::new().with_run(Run::with_image(
Image::new(vec![0u8, 1, 2, 3], ImageFormat::Png, 914_400, 457_200)
.with_description("a test image"),
)),
);
let mut state = PackageState::new();
let mut relationships = PartRelationships::new();
relationships.add(SETTINGS_RELATIONSHIP_TYPE, SETTINGS_ENTRY_NAME);
let xml = document
.to_document_xml(
&mut state,
&mut relationships,
SectionReferenceIds::default(),
)
.unwrap();
let xml = String::from_utf8(xml).unwrap();
assert_eq!(state.images.media_parts.len(), 1);
assert_eq!(state.images.media_parts[0].entry_name, "media/image1.png");
assert_eq!(state.images.media_parts[0].content_type, "image/png");
assert_eq!(state.images.media_parts[0].data, vec![0u8, 1, 2, 3]);
let relationship = relationships
.relationships
.by_id("rId2")
.expect("missing image relationship");
assert_eq!(relationship.target, "media/image1.png");
assert!(
xml.contains(r#"<wp:extent cx="914400" cy="457200"/>"#),
"{xml}"
);
assert!(xml.contains(r#"descr="a test image""#), "{xml}");
assert!(xml.contains(r#"<a:blip r:embed="rId2"/>"#), "{xml}");
assert!(!xml.contains("<w:t>"), "{xml}");
}
#[test]
fn writes_header_and_footer_references_before_pgsz() {
let document = Document::new()
.with_paragraph(Paragraph::with_text("Body"))
.with_header(HeaderFooter::new().with_paragraph(Paragraph::with_text("Header text")))
.with_footer(HeaderFooter::new().with_paragraph(Paragraph::with_text("Footer text")));
let mut state = PackageState::new();
let mut relationships = PartRelationships::new();
relationships.add(SETTINGS_RELATIONSHIP_TYPE, SETTINGS_ENTRY_NAME); let header_id = relationships.add(HEADER_RELATIONSHIP_TYPE, HEADER_ENTRY_NAME); let footer_id = relationships.add(FOOTER_RELATIONSHIP_TYPE, FOOTER_ENTRY_NAME);
let xml = document
.to_document_xml(
&mut state,
&mut relationships,
SectionReferenceIds {
header: Some(&header_id),
footer: Some(&footer_id),
..SectionReferenceIds::default()
},
)
.unwrap();
let xml = String::from_utf8(xml).unwrap();
let header_reference = xml.find(r#"<w:headerReference w:type="default" r:id="rId2"/>"#);
let footer_reference = xml.find(r#"<w:footerReference w:type="default" r:id="rId3"/>"#);
let sect_pr = xml.find("<w:sectPr>").expect("missing w:sectPr");
let pg_sz = xml.find("<w:pgSz").expect("missing w:pgSz");
assert!(header_reference.is_some(), "{xml}");
assert!(footer_reference.is_some(), "{xml}");
assert!(
sect_pr < header_reference.unwrap() && header_reference.unwrap() < pg_sz,
"{xml}"
);
assert!(footer_reference.unwrap() < pg_sz, "{xml}");
}
#[test]
fn writes_header_and_footer_content_as_hdr_and_ftr_parts() {
let header = HeaderFooter::new().with_paragraph(Paragraph::with_text("Header text"));
let footer = HeaderFooter::new().with_paragraph(Paragraph::with_text("Footer text"));
let mut state = PackageState::new();
let mut relationships = PartRelationships::new();
let header_xml =
to_header_footer_xml("w:hdr", &header.blocks, &mut state, &mut relationships).unwrap();
let header_xml = String::from_utf8(header_xml).unwrap();
assert!(header_xml.starts_with("<?xml"), "{header_xml}");
assert!(header_xml.contains("<w:hdr "), "{header_xml}");
assert!(
header_xml.contains("<w:t>Header text</w:t>"),
"{header_xml}"
);
assert!(!header_xml.contains("w:body"), "{header_xml}");
assert!(!header_xml.contains("w:sectPr"), "{header_xml}");
let footer_xml =
to_header_footer_xml("w:ftr", &footer.blocks, &mut state, &mut relationships).unwrap();
let footer_xml = String::from_utf8(footer_xml).unwrap();
assert!(footer_xml.contains("<w:ftr "), "{footer_xml}");
assert!(
footer_xml.contains("<w:t>Footer text</w:t>"),
"{footer_xml}"
);
}
#[test]
fn write_to_produces_header1_and_footer1_parts_with_relationships() {
use std::io::Cursor;
let document = Document::new()
.with_paragraph(Paragraph::with_text("Body"))
.with_header(HeaderFooter::new().with_paragraph(Paragraph::with_text("Header text")))
.with_footer(HeaderFooter::new().with_paragraph(Paragraph::with_text("Footer text")));
let buffer = document.write_to(Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let document_part = package
.part(MAIN_DOCUMENT_PART_NAME)
.expect("missing document.xml");
let header_relationship = document_part
.relationships
.iter()
.find(|relationship| relationship.rel_type == HEADER_RELATIONSHIP_TYPE)
.expect("document.xml has no header relationship");
let footer_relationship = document_part
.relationships
.iter()
.find(|relationship| relationship.rel_type == FOOTER_RELATIONSHIP_TYPE)
.expect("document.xml has no footer relationship");
assert_eq!(header_relationship.target, HEADER_ENTRY_NAME);
assert_eq!(footer_relationship.target, FOOTER_ENTRY_NAME);
let header_part = package.part(HEADER_PART_NAME).expect("missing header1.xml");
let header_xml = std::str::from_utf8(&header_part.data).unwrap();
assert!(
header_xml.contains("<w:t>Header text</w:t>"),
"{header_xml}"
);
let footer_part = package.part(FOOTER_PART_NAME).expect("missing footer1.xml");
let footer_xml = std::str::from_utf8(&footer_part.data).unwrap();
assert!(
footer_xml.contains("<w:t>Footer text</w:t>"),
"{footer_xml}"
);
let document_xml = std::str::from_utf8(&document_part.data).unwrap();
assert!(
document_xml.contains(&format!(
r#"w:type="default" r:id="{}""#,
header_relationship.id
)),
"{document_xml}"
);
}
#[test]
fn writes_a_page_number_field_as_fldsimple_not_a_run() {
use crate::model::Run;
let document = Document::new().with_paragraph(
Paragraph::new()
.with_run(Run::new("Page "))
.with_run(Run::with_field(Field::PageNumber).with_bold(true)),
);
let xml = document_xml(&document);
assert!(xml.contains(r#"<w:fldSimple w:instr="PAGE">"#), "{xml}");
assert!(xml.contains(r#"<w:fldSimple w:instr="PAGE"><w:r><w:rPr><w:b/></w:rPr><w:t>1</w:t></w:r></w:fldSimple>"#), "{xml}");
assert!(!xml.contains("<w:r><w:fldSimple"), "{xml}");
}
#[test]
fn settings_xml_never_sets_update_fields_even_with_a_field_present() {
use crate::model::Run;
let document = Document::new()
.with_paragraph(Paragraph::new().with_run(Run::with_field(Field::TotalPages)));
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let settings_part = package
.part(SETTINGS_PART_NAME)
.expect("missing word/settings.xml");
let settings_xml = std::str::from_utf8(&settings_part.data).unwrap();
assert!(!settings_xml.contains("updateFields"), "{settings_xml}");
}
#[test]
fn writes_styles_xml_with_name_based_on_ppr_then_rpr_in_order() {
let heading = Style::new("Heading1", "Heading 1", StyleKind::Paragraph)
.with_bold(true)
.with_alignment(Alignment::Center);
let emphasis = Style::new("Strong", "Strong", StyleKind::Character)
.with_based_on("DefaultParagraphFont")
.with_bold(true)
.with_alignment(Alignment::Center);
let xml = String::from_utf8(to_styles_xml(&[heading, emphasis]).unwrap()).unwrap();
assert!(xml.starts_with("<?xml"), "{xml}");
assert!(
xml.contains(r#"<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="Heading 1"/><w:pPr><w:jc w:val="center"/></w:pPr><w:rPr><w:b/></w:rPr></w:style>"#),
"{xml}"
);
assert!(
xml.contains(
r#"<w:style w:type="character" w:styleId="Strong"><w:name w:val="Strong"/><w:basedOn w:val="DefaultParagraphFont"/><w:rPr><w:b/></w:rPr></w:style>"#
),
"{xml}"
);
}
#[test]
fn writes_a_paragraph_style_with_spacing_shading_and_border() {
let heading = Style::new("Heading1", "Heading 1", StyleKind::Paragraph)
.with_line_spacing(360)
.with_space_before(240)
.with_space_after(120)
.with_paragraph_shading_color("D9D9D9")
.with_paragraph_border(true);
let xml = String::from_utf8(to_styles_xml(&[heading]).unwrap()).unwrap();
assert!(
xml.contains(
r#"<w:pPr><w:pBdr><w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/></w:pBdr><w:shd w:val="clear" w:color="auto" w:fill="D9D9D9"/><w:spacing w:before="240" w:after="120" w:line="360" w:lineRule="auto"/></w:pPr>"#
),
"{xml}"
);
}
#[test]
fn writes_a_paragraph_style_with_keep_settings_tabs_and_contextual_spacing() {
use crate::model::TabStopAlignment;
let list_paragraph = Style::new("ListParagraph", "List Paragraph", StyleKind::Paragraph)
.with_keep_with_next(true)
.with_keep_lines_together(true)
.with_page_break_before(true)
.with_tab(TabStop::new(720).with_alignment(TabStopAlignment::Left))
.with_contextual_spacing(true);
let xml = String::from_utf8(to_styles_xml(&[list_paragraph]).unwrap()).unwrap();
assert!(
xml.contains(
r#"<w:pPr><w:keepNext/><w:keepLines/><w:pageBreakBefore/><w:tabs><w:tab w:val="left" w:pos="720"/></w:tabs><w:contextualSpacing/></w:pPr>"#
),
"{xml}"
);
}
#[test]
fn a_character_style_ignores_keep_settings_tabs_and_contextual_spacing() {
let emphasis = Style::new("Strong", "Strong", StyleKind::Character)
.with_bold(true)
.with_keep_with_next(true)
.with_tab(TabStop::new(720))
.with_contextual_spacing(true);
let xml = String::from_utf8(to_styles_xml(&[emphasis]).unwrap()).unwrap();
assert!(
xml.contains(r#"<w:style w:type="character" w:styleId="Strong"><w:name w:val="Strong"/><w:rPr><w:b/></w:rPr></w:style>"#),
"{xml}"
);
}
#[test]
fn a_character_style_ignores_paragraph_level_spacing_shading_and_border() {
let emphasis = Style::new("Strong", "Strong", StyleKind::Character)
.with_bold(true)
.with_line_spacing(360)
.with_paragraph_shading_color("D9D9D9")
.with_paragraph_border(true);
let xml = String::from_utf8(to_styles_xml(&[emphasis]).unwrap()).unwrap();
assert!(
xml.contains(r#"<w:style w:type="character" w:styleId="Strong"><w:name w:val="Strong"/><w:rPr><w:b/></w:rPr></w:style>"#),
"{xml}"
);
}
#[test]
fn writes_a_style_with_color_size_and_font_family() {
let heading = Style::new("Heading1", "Heading 1", StyleKind::Paragraph)
.with_color("2E74B5")
.with_font_size(16)
.with_font_family("Cambria");
let xml = String::from_utf8(to_styles_xml(&[heading]).unwrap()).unwrap();
assert!(
xml.contains(
r#"<w:rPr><w:rFonts w:ascii="Cambria" w:hAnsi="Cambria"/><w:color w:val="2E74B5"/><w:sz w:val="32"/><w:szCs w:val="32"/></w:rPr>"#
),
"{xml}"
);
}
#[test]
fn writes_a_style_with_a_highlight() {
use crate::model::Highlight;
let heading = Style::new("Heading1", "Heading 1", StyleKind::Paragraph)
.with_highlight(Highlight::LightGray);
let xml = String::from_utf8(to_styles_xml(&[heading]).unwrap()).unwrap();
assert!(
xml.contains(r#"<w:rPr><w:highlight w:val="lightGray"/></w:rPr>"#),
"{xml}"
);
}
#[test]
fn writes_a_style_with_strike_and_subscript() {
use crate::model::VerticalAlign;
let footnote_ref = Style::new(
"FootnoteReference",
"footnote reference",
StyleKind::Character,
)
.with_strike(true)
.with_vertical_align(VerticalAlign::Subscript);
let xml = String::from_utf8(to_styles_xml(&[footnote_ref]).unwrap()).unwrap();
assert!(
xml.contains(r#"<w:rPr><w:strike/><w:vertAlign w:val="subscript"/></w:rPr>"#),
"{xml}"
);
}
#[test]
fn writes_a_style_with_a_double_underline_and_color() {
use crate::model::UnderlineStyle;
let heading = Style::new("Heading1", "Heading 1", StyleKind::Paragraph)
.with_underline(UnderlineStyle::Double)
.with_underline_color("2E74B5");
let xml = String::from_utf8(to_styles_xml(&[heading]).unwrap()).unwrap();
assert!(
xml.contains(r#"<w:rPr><w:u w:val="double" w:color="2E74B5"/></w:rPr>"#),
"{xml}"
);
}
#[test]
fn writes_a_style_with_small_caps_shading_border_spacing_and_hidden() {
let heading = Style::new("Redacted", "Redacted", StyleKind::Character)
.with_small_caps(true)
.with_shading_color("000000")
.with_text_border(true)
.with_character_spacing(10)
.with_vertical_position(-2)
.with_hidden(true);
let xml = String::from_utf8(to_styles_xml(&[heading]).unwrap()).unwrap();
assert!(
xml.contains(
r#"<w:rPr><w:smallCaps/><w:shd w:val="clear" w:color="auto" w:fill="000000"/><w:bdr w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:spacing w:val="10"/><w:position w:val="-2"/><w:vanish/></w:rPr>"#
),
"{xml}"
);
}
#[test]
fn a_document_with_no_styles_does_not_write_a_styles_part() {
let document = Document::new().with_paragraph(Paragraph::with_text("No styles here"));
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
assert!(
package.part(STYLES_PART_NAME).is_none(),
"word/styles.xml should not be written"
);
}
#[test]
fn writes_pstyle_before_jc_and_rstyle_in_rpr() {
let document = Document::new().with_paragraph(
Paragraph::new()
.with_style_id("Heading1")
.with_alignment(Alignment::Center)
.with_run(Run::new("Title").with_style_id("Strong")),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:pPr><w:pStyle w:val="Heading1"/><w:jc w:val="center"/></w:pPr>"#),
"{xml}"
);
assert!(
xml.contains(r#"<w:rPr><w:rStyle w:val="Strong"/></w:rPr><w:t>Title</w:t>"#),
"{xml}"
);
}
#[test]
fn write_to_declares_a_styles_relationship_when_styles_are_present() {
use std::io::Cursor;
let document = Document::new()
.with_paragraph(Paragraph::with_text("Body"))
.with_style(Style::new("Heading1", "Heading 1", StyleKind::Paragraph));
let buffer = document.write_to(Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let document_part = package
.part(MAIN_DOCUMENT_PART_NAME)
.expect("missing document.xml");
let styles_relationship = document_part
.relationships
.iter()
.find(|relationship| relationship.rel_type == STYLES_RELATIONSHIP_TYPE)
.expect("document.xml has no styles relationship");
assert_eq!(styles_relationship.target, STYLES_ENTRY_NAME);
let styles_part = package
.part(STYLES_PART_NAME)
.expect("missing word/styles.xml");
let styles_xml = std::str::from_utf8(&styles_part.data).unwrap();
assert!(
styles_xml.contains(r#"w:styleId="Heading1""#),
"{styles_xml}"
);
}
#[test]
fn writes_an_external_hyperlink_wrapping_a_run_not_wrapped_in_it() {
let document = Document::new().with_paragraph(
Paragraph::new().with_run(
Run::new("Rust")
.with_hyperlink(Hyperlink::External("https://www.rust-lang.org".to_string())),
),
);
let mut state = PackageState::new();
let mut relationships = PartRelationships::new();
relationships.add(SETTINGS_RELATIONSHIP_TYPE, SETTINGS_ENTRY_NAME);
let xml = document
.to_document_xml(
&mut state,
&mut relationships,
SectionReferenceIds::default(),
)
.unwrap();
let xml = String::from_utf8(xml).unwrap();
assert!(
xml.contains(r#"<w:hyperlink r:id="rId2"><w:r><w:t>Rust</w:t></w:r></w:hyperlink>"#),
"{xml}"
);
assert!(!xml.contains("<w:r><w:hyperlink"), "{xml}");
let relationship = relationships
.relationships
.by_id("rId2")
.expect("missing hyperlink relationship");
assert_eq!(relationship.rel_type, HYPERLINK_RELATIONSHIP_TYPE);
assert_eq!(relationship.target, "https://www.rust-lang.org");
assert_eq!(relationship.target_mode, TargetMode::External);
}
#[test]
fn writes_an_internal_hyperlink_as_an_anchor_with_no_relationship() {
let document = Document::new().with_paragraph(Paragraph::new().with_run(
Run::new("See above").with_hyperlink(Hyperlink::Internal("Section1".to_string())),
));
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:hyperlink w:anchor="Section1"><w:r><w:t>See above</w:t></w:r></w:hyperlink>"#
),
"{xml}"
);
assert!(!xml.contains("r:id"), "{xml}");
}
#[test]
fn two_runs_with_the_same_url_each_get_their_own_relationship() {
let url = "https://example.com";
let document = Document::new().with_paragraph(
Paragraph::new()
.with_run(Run::new("first").with_hyperlink(Hyperlink::External(url.to_string())))
.with_run(Run::new("second").with_hyperlink(Hyperlink::External(url.to_string()))),
);
let mut state = PackageState::new();
let mut relationships = PartRelationships::new();
let xml = document
.to_document_xml(
&mut state,
&mut relationships,
SectionReferenceIds::default(),
)
.unwrap();
let xml = String::from_utf8(xml).unwrap();
let hyperlink_relationships: Vec<_> = relationships
.relationships
.iter()
.filter(|relationship| relationship.rel_type == HYPERLINK_RELATIONSHIP_TYPE)
.collect();
assert_eq!(hyperlink_relationships.len(), 2, "{xml}");
assert_ne!(hyperlink_relationships[0].id, hyperlink_relationships[1].id);
}
#[test]
fn a_hyperlinked_run_still_carries_its_own_formatting() {
let document = Document::new().with_paragraph(
Paragraph::new().with_run(
Run::new("Bold link")
.with_bold(true)
.with_hyperlink(Hyperlink::External("https://example.com".to_string())),
),
);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:hyperlink r:id="rId1"><w:r><w:rPr><w:b/></w:rPr><w:t>Bold link</w:t></w:r></w:hyperlink>"#),
"{xml}"
);
}
#[test]
fn writes_numbering_xml_with_one_abstractnum_and_num_per_definition() {
use crate::model::{ListLevel, NumberFormat};
let bullet = NumberingDefinition::new(
1,
vec![ListLevel::new(NumberFormat::Bullet, "\u{2022}", 720, 360)],
);
let decimal = NumberingDefinition::new(
5,
vec![ListLevel::new(NumberFormat::Decimal, "%1.", 360, 360)],
);
let xml = String::from_utf8(to_numbering_xml(&[bullet, decimal]).unwrap()).unwrap();
assert!(xml.starts_with("<?xml"), "{xml}");
let last_abstract_num = xml.rfind("<w:abstractNum ").expect("missing w:abstractNum");
let first_num = xml.find("<w:num ").expect("missing w:num");
assert!(last_abstract_num < first_num, "{xml}");
assert!(
xml.contains(
r#"<w:abstractNum w:abstractNumId="1"><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val="•"/><w:lvlJc w:val="left"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum>"#
),
"{xml}"
);
assert!(
xml.contains(r#"<w:num w:numId="1"><w:abstractNumId w:val="1"/></w:num>"#),
"{xml}"
);
assert!(
xml.contains(r#"<w:num w:numId="5"><w:abstractNumId w:val="5"/></w:num>"#),
"{xml}"
);
}
#[test]
fn writes_pstyle_before_numpr_before_jc_in_ppr() {
let document = Document::new().with_paragraph(
Paragraph::new()
.with_style_id("ListParagraph")
.with_numbering(1, 2)
.with_alignment(Alignment::Center)
.with_run(Run::new("Item")),
);
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:pPr><w:pStyle w:val="ListParagraph"/><w:numPr><w:ilvl w:val="2"/><w:numId w:val="1"/></w:numPr><w:jc w:val="center"/></w:pPr>"#
),
"{xml}"
);
}
#[test]
fn a_document_with_no_numbering_definitions_does_not_write_a_numbering_part() {
let document = Document::new().with_paragraph(Paragraph::with_text("No lists here"));
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
assert!(
package.part(NUMBERING_PART_NAME).is_none(),
"word/numbering.xml should not be written"
);
}
#[test]
fn write_to_declares_a_numbering_relationship_when_definitions_are_present() {
use std::io::Cursor;
let document = Document::new()
.with_paragraph(Paragraph::with_text("Item one").with_numbering(1, 0))
.with_numbering_definition(NumberingDefinition::bullet(1));
let buffer = document.write_to(Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let document_part = package
.part(MAIN_DOCUMENT_PART_NAME)
.expect("missing document.xml");
let numbering_relationship = document_part
.relationships
.iter()
.find(|relationship| relationship.rel_type == NUMBERING_RELATIONSHIP_TYPE)
.expect("document.xml has no numbering relationship");
assert_eq!(numbering_relationship.target, NUMBERING_ENTRY_NAME);
let numbering_part = package
.part(NUMBERING_PART_NAME)
.expect("missing word/numbering.xml");
let numbering_xml = std::str::from_utf8(&numbering_part.data).unwrap();
assert!(numbering_xml.contains(r#"w:numId="1""#), "{numbering_xml}");
let document_xml = std::str::from_utf8(&document_part.data).unwrap();
assert!(
document_xml.contains(r#"<w:numId w:val="1"/>"#),
"{document_xml}"
);
}
#[test]
fn bullet_and_decimal_convenience_definitions_have_three_levels() {
let bullet = NumberingDefinition::bullet(1);
let decimal = NumberingDefinition::decimal(2);
assert_eq!(bullet.levels.len(), 3);
assert_eq!(decimal.levels.len(), 3);
assert_eq!(decimal.levels[1].text, "%1.%2.");
}
#[test]
fn writes_a_note_reference_run_with_its_id_and_reference_style() {
let document = Document::new().with_paragraph(
Paragraph::with_text("See the note.")
.with_run(Run::with_note_reference(NoteReference::Footnote(1))),
);
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:r><w:rPr><w:rStyle w:val="FootnoteReference"/></w:rPr><w:footnoteReference w:id="1"/></w:r>"#
),
"{xml}"
);
}
#[test]
fn writes_an_endnote_reference_with_the_endnote_element_and_style() {
let document = Document::new().with_paragraph(
Paragraph::new().with_run(Run::with_note_reference(NoteReference::Endnote(3))),
);
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:rPr><w:rStyle w:val="EndnoteReference"/></w:rPr><w:endnoteReference w:id="3"/>"#
),
"{xml}"
);
}
#[test]
fn writes_footnotes_xml_with_one_footnote_element_per_note() {
let note = Note::footnote_with_text(1, "This is a footnote.");
let xml = String::from_utf8(
to_notes_xml(
"w:footnotes",
"w:footnote",
&[note],
&mut PackageState::new(),
&mut PartRelationships::new(),
)
.unwrap(),
)
.unwrap();
assert!(xml.starts_with("<?xml"), "{xml}");
assert!(xml.contains(r#"<w:footnote w:id="1">"#), "{xml}");
assert!(
xml.contains(
r#"<w:r><w:rPr><w:rStyle w:val="FootnoteReference"/></w:rPr><w:footnoteRef/></w:r><w:r><w:t xml:space="preserve"> </w:t></w:r><w:r><w:t>This is a footnote.</w:t></w:r>"#
),
"{xml}"
);
}
#[test]
fn writes_an_endnote_marker_with_the_endnote_ref_element() {
let note = Note::endnote_with_text(1, "This is an endnote.");
let xml = String::from_utf8(
to_notes_xml(
"w:endnotes",
"w:endnote",
&[note],
&mut PackageState::new(),
&mut PartRelationships::new(),
)
.unwrap(),
)
.unwrap();
assert!(xml.contains(r#"<w:endnote w:id="1">"#), "{xml}");
assert!(xml.contains("<w:endnoteRef/>"), "{xml}");
}
#[test]
fn a_document_with_no_footnotes_or_endnotes_does_not_write_those_parts() {
let document = Document::new().with_paragraph(Paragraph::with_text("No notes here"));
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
assert!(
package.part(FOOTNOTES_PART_NAME).is_none(),
"word/footnotes.xml should not be written"
);
assert!(
package.part(ENDNOTES_PART_NAME).is_none(),
"word/endnotes.xml should not be written"
);
}
#[test]
fn write_to_declares_footnote_and_endnote_relationships_when_notes_are_present() {
use std::io::Cursor;
let document = Document::new()
.with_paragraph(
Paragraph::with_text("See the footnote")
.with_run(Run::with_note_reference(NoteReference::Footnote(1))),
)
.with_footnote(Note::footnote_with_text(1, "Footnote text."))
.with_endnote(Note::endnote_with_text(1, "Endnote text."));
let buffer = document.write_to(Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let document_part = package
.part(MAIN_DOCUMENT_PART_NAME)
.expect("missing document.xml");
assert!(
document_part
.relationships
.iter()
.any(|relationship| relationship.rel_type == FOOTNOTES_RELATIONSHIP_TYPE),
"document.xml has no footnotes relationship"
);
assert!(
document_part
.relationships
.iter()
.any(|relationship| relationship.rel_type == ENDNOTES_RELATIONSHIP_TYPE),
"document.xml has no endnotes relationship"
);
let footnotes_part = package
.part(FOOTNOTES_PART_NAME)
.expect("missing word/footnotes.xml");
let footnotes_xml = std::str::from_utf8(&footnotes_part.data).unwrap();
assert!(footnotes_xml.contains("Footnote text."), "{footnotes_xml}");
let endnotes_part = package
.part(ENDNOTES_PART_NAME)
.expect("missing word/endnotes.xml");
let endnotes_xml = std::str::from_utf8(&endnotes_part.data).unwrap();
assert!(endnotes_xml.contains("Endnote text."), "{endnotes_xml}");
}
#[test]
fn footnote_and_endnote_with_text_convenience_builds_marker_space_and_text() {
let footnote = Note::footnote_with_text(7, "Some detail.");
assert_eq!(footnote.id, 7);
assert_eq!(footnote.blocks.len(), 1);
let paragraph = footnote.paragraphs().next().expect("missing paragraph");
assert_eq!(paragraph.runs.len(), 3);
assert_eq!(
paragraph.runs[0].content,
RunContent::NoteMarker(NoteKind::Footnote)
);
assert_eq!(paragraph.runs[1].text(), Some(" "));
assert_eq!(paragraph.runs[2].text(), Some("Some detail."));
}
#[test]
fn a_run_marked_with_a_comment_id_gets_a_range_start_end_and_reference() {
let document = Document::new().with_paragraph(
Paragraph::new()
.with_run(Run::new("Some "))
.with_run(Run::new("text.").with_comment(0)),
);
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:r><w:t xml:space="preserve">Some </w:t></w:r><w:commentRangeStart w:id="0"/><w:r><w:t>text.</w:t></w:r><w:commentRangeEnd w:id="0"/><w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="0"/></w:r>"#
),
"{xml}"
);
}
#[test]
fn consecutive_runs_sharing_a_comment_id_get_a_single_start_end_pair() {
let document = Document::new().with_paragraph(
Paragraph::new()
.with_run(Run::new("first ").with_comment(0))
.with_run(Run::new("second").with_comment(0)),
);
let xml = document_xml(&document);
assert_eq!(xml.matches("w:commentRangeStart").count(), 1, "{xml}");
assert_eq!(xml.matches("w:commentRangeEnd").count(), 1, "{xml}");
assert_eq!(xml.matches("w:commentReference").count(), 1, "{xml}");
assert!(
xml.contains(r#"<w:commentRangeStart w:id="0"/><w:r>"#),
"{xml}"
);
assert!(
xml.contains(r#"</w:r><w:commentRangeEnd w:id="0"/>"#),
"{xml}"
);
}
#[test]
fn overlapping_comment_ranges_each_get_their_own_start_end_pair() {
let document = Document::new().with_paragraph(
Paragraph::new()
.with_run(Run::new("a").with_comment(0))
.with_run(Run::new("b").with_comment(0).with_comment(1))
.with_run(Run::new("c").with_comment(0)),
);
let xml = document_xml(&document);
assert_eq!(xml.matches("w:commentRangeStart").count(), 2, "{xml}");
assert_eq!(xml.matches("w:commentRangeEnd").count(), 2, "{xml}");
assert!(
xml.contains(r#"w:id="1"/><w:r><w:t>b</w:t></w:r><w:commentRangeEnd w:id="1"/>"#),
"{xml}"
);
}
#[test]
fn a_comment_range_left_open_at_the_paragraph_s_end_still_closes() {
let document = Document::new()
.with_paragraph(Paragraph::new().with_run(Run::new("last run").with_comment(0)));
let xml = document_xml(&document);
assert!(xml.contains(r#"<w:commentRangeEnd w:id="0"/>"#), "{xml}");
assert!(xml.contains("<w:commentReference"), "{xml}");
}
#[test]
fn a_run_marked_with_a_bookmark_gets_a_start_and_end_pair() {
use crate::model::Bookmark;
let document = Document::new().with_paragraph(
Paragraph::new()
.with_run(Run::new("Some "))
.with_run(Run::new("text.").with_bookmark(Bookmark::new(0, "MyBookmark"))),
);
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:r><w:t xml:space="preserve">Some </w:t></w:r><w:bookmarkStart w:id="0" w:name="MyBookmark"/><w:r><w:t>text.</w:t></w:r><w:bookmarkEnd w:id="0"/>"#
),
"{xml}"
);
}
#[test]
fn consecutive_runs_sharing_a_bookmark_get_a_single_start_end_pair() {
use crate::model::Bookmark;
let document = Document::new().with_paragraph(
Paragraph::new()
.with_run(Run::new("first ").with_bookmark(Bookmark::new(0, "Target")))
.with_run(Run::new("second").with_bookmark(Bookmark::new(0, "Target"))),
);
let xml = document_xml(&document);
assert_eq!(xml.matches("w:bookmarkStart").count(), 1, "{xml}");
assert_eq!(xml.matches("w:bookmarkEnd").count(), 1, "{xml}");
assert!(
xml.contains(r#"<w:bookmarkStart w:id="0" w:name="Target"/><w:r>"#),
"{xml}"
);
assert!(xml.contains(r#"</w:r><w:bookmarkEnd w:id="0"/>"#), "{xml}");
}
#[test]
fn overlapping_bookmarks_each_get_their_own_start_end_pair() {
use crate::model::Bookmark;
let document = Document::new().with_paragraph(
Paragraph::new()
.with_run(Run::new("a").with_bookmark(Bookmark::new(0, "Outer")))
.with_run(
Run::new("b")
.with_bookmark(Bookmark::new(0, "Outer"))
.with_bookmark(Bookmark::new(1, "Inner")),
)
.with_run(Run::new("c").with_bookmark(Bookmark::new(0, "Outer"))),
);
let xml = document_xml(&document);
assert_eq!(xml.matches("w:bookmarkStart").count(), 2, "{xml}");
assert_eq!(xml.matches("w:bookmarkEnd").count(), 2, "{xml}");
assert!(
xml.contains(
r#"w:id="1" w:name="Inner"/><w:r><w:t>b</w:t></w:r><w:bookmarkEnd w:id="1"/>"#
),
"{xml}"
);
}
#[test]
fn a_bookmark_left_open_at_the_paragraph_s_end_still_closes() {
use crate::model::Bookmark;
let document = Document::new().with_paragraph(
Paragraph::new().with_run(Run::new("last run").with_bookmark(Bookmark::new(0, "End"))),
);
let xml = document_xml(&document);
assert!(xml.contains(r#"<w:bookmarkEnd w:id="0"/>"#), "{xml}");
}
#[test]
fn an_internal_hyperlink_can_point_to_a_real_bookmark_written_elsewhere() {
use crate::model::Bookmark;
let document = Document::new()
.with_paragraph(Paragraph::new().with_run(
Run::new("See below").with_hyperlink(Hyperlink::Internal("Target".to_string())),
))
.with_paragraph(
Paragraph::new()
.with_run(Run::new("Here").with_bookmark(Bookmark::new(0, "Target"))),
);
let xml = document_xml(&document);
assert!(xml.contains(r#"<w:hyperlink w:anchor="Target">"#), "{xml}");
assert!(
xml.contains(r#"<w:bookmarkStart w:id="0" w:name="Target"/>"#),
"{xml}"
);
}
#[test]
fn writes_comments_xml_with_author_date_and_initials() {
let comment = Comment::with_text(0, "Please rephrase.")
.with_author("Morgan")
.with_initials("MR")
.with_date("2026-07-16T12:00:00Z");
let xml = String::from_utf8(
to_comments_xml(
&[comment],
&mut PackageState::new(),
&mut PartRelationships::new(),
)
.unwrap(),
)
.unwrap();
assert!(xml.starts_with("<?xml"), "{xml}");
assert!(
xml.contains(r#"<w:comment w:id="0" w:author="Morgan" w:date="2026-07-16T12:00:00Z" w:initials="MR">"#),
"{xml}"
);
assert!(xml.contains("Please rephrase."), "{xml}");
}
#[test]
fn a_document_with_no_comments_does_not_write_that_part() {
let document = Document::new().with_paragraph(Paragraph::with_text("No comments here"));
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
assert!(
package.part(COMMENTS_PART_NAME).is_none(),
"word/comments.xml should not be written"
);
}
#[test]
fn write_to_declares_a_comments_relationship_when_comments_are_present() {
use std::io::Cursor;
let document = Document::new()
.with_paragraph(
Paragraph::with_text("Reviewed text").with_run(Run::new("").with_comment(0)),
)
.with_comment(Comment::with_text(0, "A remark."));
let buffer = document.write_to(Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let document_part = package
.part(MAIN_DOCUMENT_PART_NAME)
.expect("missing document.xml");
assert!(
document_part
.relationships
.iter()
.any(|relationship| relationship.rel_type == COMMENTS_RELATIONSHIP_TYPE),
"document.xml has no comments relationship"
);
let comments_part = package
.part(COMMENTS_PART_NAME)
.expect("missing word/comments.xml");
let comments_xml = std::str::from_utf8(&comments_part.data).unwrap();
assert!(comments_xml.contains("A remark."), "{comments_xml}");
}
#[test]
fn writes_a_structured_document_tag_with_its_properties_and_content() {
let document = Document::new().with_structured_document_tag(
StructuredDocumentTag::new()
.with_id(1)
.with_tag("CustomerName")
.with_alias("Customer Name")
.with_paragraph(Paragraph::with_text("Acme Corp")),
);
let xml = document_xml(&document);
assert!(
xml.contains(
r#"<w:sdt><w:sdtPr><w:alias w:val="Customer Name"/><w:tag w:val="CustomerName"/><w:id w:val="1"/></w:sdtPr><w:sdtContent><w:p><w:r><w:t>Acme Corp</w:t></w:r></w:p></w:sdtContent></w:sdt>"#
),
"{xml}"
);
}
#[test]
fn a_structured_document_tag_with_no_properties_omits_sdtpr() {
let document = Document::new().with_structured_document_tag(
StructuredDocumentTag::new().with_paragraph(Paragraph::with_text("Plain")),
);
let xml = document_xml(&document);
assert!(!xml.contains("w:sdtPr"), "{xml}");
assert!(
xml.contains(r#"<w:sdt><w:sdtContent><w:p><w:r><w:t>Plain</w:t></w:r></w:p></w:sdtContent></w:sdt>"#),
"{xml}"
);
}
#[test]
fn a_nested_structured_document_tag_writes_correctly() {
let mut outer = StructuredDocumentTag::new().with_tag("outer");
outer.blocks.push(Block::StructuredDocumentTag(
StructuredDocumentTag::new()
.with_tag("inner")
.with_paragraph(Paragraph::with_text("nested")),
));
let document = Document::new().with_structured_document_tag(outer);
let xml = document_xml(&document);
assert!(xml.contains(r#"<w:tag w:val="outer"/>"#), "{xml}");
assert!(xml.contains(r#"<w:tag w:val="inner"/>"#), "{xml}");
assert!(xml.contains("nested"), "{xml}");
}
#[test]
fn a_document_with_a_structured_document_tag_interleaved_with_paragraphs_writes_all_blocks() {
let document = Document::new()
.with_paragraph(Paragraph::with_text("Before"))
.with_structured_document_tag(
StructuredDocumentTag::new().with_paragraph(Paragraph::with_text("Inside")),
)
.with_paragraph(Paragraph::with_text("After"));
let xml = document_xml(&document);
let before = xml.find("Before").expect("missing Before");
let inside = xml.find("Inside").expect("missing Inside");
let after = xml.find("After").expect("missing After");
assert!(before < inside && inside < after, "{xml}");
}
#[test]
fn writes_a_theme_part_with_colors_and_fonts() {
use std::io::Cursor;
let theme = Theme::new(
"Custom",
ColorScheme::new(
"111111", "EEEEEE", "222222", "DDDDDD", "AA0000", "BB1100", "CC2200", "DD3300",
"EE4400", "FF5500", "0000AA", "AA00AA",
),
FontScheme::new("Georgia", "Verdana"),
);
let document = Document::new()
.with_paragraph(Paragraph::with_text("Hello"))
.with_theme(theme);
let buffer = document.write_to(Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let theme_part = package
.part(THEME_PART_NAME)
.expect("missing word/theme/theme1.xml");
let theme_xml = std::str::from_utf8(&theme_part.data).unwrap();
assert!(theme_xml.contains(r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Custom">"#), "{theme_xml}");
assert!(
theme_xml.contains(r#"<a:dk1><a:srgbClr val="111111"/></a:dk1>"#),
"{theme_xml}"
);
assert!(
theme_xml.contains(r#"<a:accent6><a:srgbClr val="FF5500"/></a:accent6>"#),
"{theme_xml}"
);
assert!(
theme_xml.contains(r#"<a:folHlink><a:srgbClr val="AA00AA"/></a:folHlink>"#),
"{theme_xml}"
);
assert!(
theme_xml.contains(r#"<a:latin typeface="Georgia"/>"#),
"{theme_xml}"
);
assert!(
theme_xml.contains(r#"<a:latin typeface="Verdana"/>"#),
"{theme_xml}"
);
assert!(
theme_xml.contains("<a:fmtScheme"),
"fmtScheme boilerplate missing: {theme_xml}"
);
}
#[test]
fn a_document_with_no_theme_does_not_write_that_part() {
let document = Document::new().with_paragraph(Paragraph::with_text("No theme here"));
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
assert!(
package.part(THEME_PART_NAME).is_none(),
"word/theme/theme1.xml should not be written"
);
}
#[test]
fn write_to_declares_a_theme_relationship_when_a_theme_is_present() {
use std::io::Cursor;
let document = Document::new().with_theme(Theme::office_default());
let buffer = document.write_to(Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let document_part = package
.part(MAIN_DOCUMENT_PART_NAME)
.expect("missing document.xml");
assert!(
document_part
.relationships
.iter()
.any(|relationship| relationship.rel_type == THEME_RELATIONSHIP_TYPE),
"document.xml has no theme relationship"
);
}
#[test]
fn a_document_with_no_explicit_page_setup_matches_the_old_fixed_a4_values() {
let document = Document::new().with_paragraph(Paragraph::with_text("Body"));
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:pgSz w:w="11906" w:h="16838"/>"#),
"{xml}"
);
assert!(
xml.contains(r#"<w:pgMar w:top="1417" w:right="1417" w:bottom="1417" w:left="1417" w:header="708" w:footer="708" w:gutter="0"/>"#),
"{xml}"
);
assert!(!xml.contains("w:orient"), "{xml}");
assert!(!xml.contains("<w:cols"), "{xml}");
assert!(!xml.contains("<w:titlePg"), "{xml}");
}
#[test]
fn write_to_uses_a_configurable_landscape_page_setup_with_columns() {
use crate::model::PageSetup;
let page_setup = PageSetup::new()
.with_size_twips(16_838, 11_906)
.with_orientation(Orientation::Landscape)
.with_margins_twips(1000, 1000, 1000, 1000)
.with_columns(2);
let document = Document::new()
.with_paragraph(Paragraph::with_text("Body"))
.with_page_setup(page_setup);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:pgSz w:w="16838" w:h="11906" w:orient="landscape"/>"#),
"{xml}"
);
assert!(
xml.contains(r#"w:top="1000" w:right="1000" w:bottom="1000" w:left="1000""#),
"{xml}"
);
assert!(
xml.contains(r#"<w:cols w:num="2" w:space="720" w:equalWidth="true"/>"#),
"{xml}"
);
}
#[test]
fn write_to_writes_an_a3_page_size_distinct_from_the_default_a4() {
use crate::model::PageSetup;
let page_setup = PageSetup::new().with_size_twips(16_838, 23_811);
let document = Document::new()
.with_paragraph(Paragraph::with_text("Body"))
.with_page_setup(page_setup);
let xml = document_xml(&document);
assert!(
xml.contains(r#"<w:pgSz w:w="16838" w:h="23811"/>"#),
"{xml}"
);
assert!(!xml.contains("w:orient"), "{xml}");
}
#[test]
fn writes_first_and_even_header_footer_references_titlepg_and_evenandoddheaders() {
use std::io::Cursor;
let document = Document::new()
.with_paragraph(Paragraph::with_text("Body"))
.with_header(HeaderFooter::new().with_paragraph(Paragraph::with_text("Default header")))
.with_header_first(
HeaderFooter::new().with_paragraph(Paragraph::with_text("First page header")),
)
.with_footer_first(
HeaderFooter::new().with_paragraph(Paragraph::with_text("First page footer")),
)
.with_header_even(
HeaderFooter::new().with_paragraph(Paragraph::with_text("Even page header")),
)
.with_footer_even(
HeaderFooter::new().with_paragraph(Paragraph::with_text("Even page footer")),
);
let buffer = document.write_to(Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let header_first_part = package
.part(HEADER_FIRST_PART_NAME)
.expect("missing header2.xml");
assert!(
std::str::from_utf8(&header_first_part.data)
.unwrap()
.contains("First page header")
);
let footer_first_part = package
.part(FOOTER_FIRST_PART_NAME)
.expect("missing footer2.xml");
assert!(
std::str::from_utf8(&footer_first_part.data)
.unwrap()
.contains("First page footer")
);
let header_even_part = package
.part(HEADER_EVEN_PART_NAME)
.expect("missing header3.xml");
assert!(
std::str::from_utf8(&header_even_part.data)
.unwrap()
.contains("Even page header")
);
let footer_even_part = package
.part(FOOTER_EVEN_PART_NAME)
.expect("missing footer3.xml");
assert!(
std::str::from_utf8(&footer_even_part.data)
.unwrap()
.contains("Even page footer")
);
let document_part = package
.part(MAIN_DOCUMENT_PART_NAME)
.expect("missing document.xml");
let document_xml = std::str::from_utf8(&document_part.data).unwrap();
assert!(
document_xml.contains(r#"w:headerReference w:type="first""#),
"{document_xml}"
);
assert!(
document_xml.contains(r#"w:footerReference w:type="first""#),
"{document_xml}"
);
assert!(
document_xml.contains(r#"w:headerReference w:type="even""#),
"{document_xml}"
);
assert!(
document_xml.contains(r#"w:footerReference w:type="even""#),
"{document_xml}"
);
assert!(document_xml.contains("<w:titlePg/>"), "{document_xml}");
let settings_part = package
.part(SETTINGS_PART_NAME)
.expect("missing word/settings.xml");
let settings_xml = std::str::from_utf8(&settings_part.data).unwrap();
assert!(
settings_xml.contains("<w:evenAndOddHeaders/>"),
"{settings_xml}"
);
}
#[test]
fn omits_titlepg_and_evenandoddheaders_when_only_the_default_header_is_set() {
let document = Document::new()
.with_paragraph(Paragraph::with_text("Body"))
.with_header(
HeaderFooter::new().with_paragraph(Paragraph::with_text("Default header")),
);
let xml = document_xml(&document);
assert!(!xml.contains("titlePg"), "{xml}");
}
#[test]
fn writes_trackrevisions_when_track_changes_is_set() {
let document = Document::new()
.with_paragraph(Paragraph::with_text("Body"))
.with_track_changes(true);
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let settings_part = package
.part(SETTINGS_PART_NAME)
.expect("missing word/settings.xml");
let settings_xml = std::str::from_utf8(&settings_part.data).unwrap();
assert!(
settings_xml.contains("<w:trackRevisions/>"),
"{settings_xml}"
);
}
#[test]
fn a_document_with_track_changes_unset_omits_trackrevisions() {
let document = Document::new().with_paragraph(Paragraph::with_text("Body"));
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let settings_part = package
.part(SETTINGS_PART_NAME)
.expect("missing word/settings.xml");
let settings_xml = std::str::from_utf8(&settings_part.data).unwrap();
assert!(!settings_xml.contains("trackRevisions"), "{settings_xml}");
}
#[test]
fn writes_trackrevisions_before_evenandoddheaders_when_both_are_set() {
let document = Document::new()
.with_paragraph(Paragraph::with_text("Body"))
.with_track_changes(true)
.with_header_even(
HeaderFooter::new().with_paragraph(Paragraph::with_text("Even header")),
);
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let settings_part = package
.part(SETTINGS_PART_NAME)
.expect("missing word/settings.xml");
let settings_xml = std::str::from_utf8(&settings_part.data).unwrap();
assert!(
settings_xml.contains("<w:trackRevisions/><w:evenAndOddHeaders/>"),
"{settings_xml}"
);
}
#[test]
fn writes_documentprotection_with_the_given_edit_value_and_enforcement() {
let document = Document::new()
.with_paragraph(Paragraph::with_text("Body"))
.with_protection(ProtectionKind::ReadOnly);
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let settings_part = package
.part(SETTINGS_PART_NAME)
.expect("missing word/settings.xml");
let settings_xml = std::str::from_utf8(&settings_part.data).unwrap();
assert!(
settings_xml.contains(r#"<w:documentProtection w:edit="readOnly" w:enforcement="1"/>"#),
"{settings_xml}"
);
}
#[test]
fn a_document_with_no_protection_set_omits_documentprotection() {
let document = Document::new().with_paragraph(Paragraph::with_text("Body"));
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let settings_part = package
.part(SETTINGS_PART_NAME)
.expect("missing word/settings.xml");
let settings_xml = std::str::from_utf8(&settings_part.data).unwrap();
assert!(
!settings_xml.contains("documentProtection"),
"{settings_xml}"
);
}
#[test]
fn writes_documentprotection_between_trackrevisions_and_evenandoddheaders() {
let document = Document::new()
.with_paragraph(Paragraph::with_text("Body"))
.with_track_changes(true)
.with_protection(ProtectionKind::TrackedChanges)
.with_header_even(
HeaderFooter::new().with_paragraph(Paragraph::with_text("Even header")),
);
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let settings_part = package
.part(SETTINGS_PART_NAME)
.expect("missing word/settings.xml");
let settings_xml = std::str::from_utf8(&settings_part.data).unwrap();
assert!(
settings_xml.contains(
r#"<w:trackRevisions/><w:documentProtection w:edit="trackedChanges" w:enforcement="1"/><w:evenAndOddHeaders/>"#
),
"{settings_xml}"
);
}
#[test]
fn writes_core_properties_with_every_field_set() {
let properties = DocumentProperties::new()
.with_title("Rapport trimestriel")
.with_subject("Finances")
.with_creator("Morgan")
.with_keywords("rapport, finances, Q2")
.with_description("Notes internes")
.with_last_modified_by("Morgan")
.with_revision("3")
.with_created("2026-07-17T10:00:00Z")
.with_modified("2026-07-17T11:30:00Z")
.with_category("Interne")
.with_content_status("Draft");
let document = Document::new()
.with_paragraph(Paragraph::with_text("Body"))
.with_properties(properties);
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let core_properties_part = package
.part(CORE_PROPERTIES_PART_NAME)
.expect("missing docProps/core.xml");
let core_properties_xml = std::str::from_utf8(&core_properties_part.data).unwrap();
assert!(
core_properties_xml.contains("<dc:title>Rapport trimestriel</dc:title>"),
"{core_properties_xml}"
);
assert!(
core_properties_xml.contains("<dc:subject>Finances</dc:subject>"),
"{core_properties_xml}"
);
assert!(
core_properties_xml.contains("<dc:creator>Morgan</dc:creator>"),
"{core_properties_xml}"
);
assert!(
core_properties_xml.contains("<cp:keywords>rapport, finances, Q2</cp:keywords>"),
"{core_properties_xml}"
);
assert!(
core_properties_xml.contains("<dc:description>Notes internes</dc:description>"),
"{core_properties_xml}"
);
assert!(
core_properties_xml.contains("<cp:lastModifiedBy>Morgan</cp:lastModifiedBy>"),
"{core_properties_xml}"
);
assert!(
core_properties_xml.contains("<cp:revision>3</cp:revision>"),
"{core_properties_xml}"
);
assert!(
core_properties_xml.contains(r#"<dcterms:created xsi:type="dcterms:W3CDTF">2026-07-17T10:00:00Z</dcterms:created>"#),
"{core_properties_xml}"
);
assert!(
core_properties_xml.contains(r#"<dcterms:modified xsi:type="dcterms:W3CDTF">2026-07-17T11:30:00Z</dcterms:modified>"#),
"{core_properties_xml}"
);
assert!(
core_properties_xml.contains("<cp:category>Interne</cp:category>"),
"{core_properties_xml}"
);
assert!(
core_properties_xml.contains("<cp:contentStatus>Draft</cp:contentStatus>"),
"{core_properties_xml}"
);
}
#[test]
fn a_document_with_no_properties_set_writes_an_empty_coreproperties_element() {
let document = Document::new().with_paragraph(Paragraph::with_text("Body"));
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let core_properties_part = package
.part(CORE_PROPERTIES_PART_NAME)
.expect("missing docProps/core.xml");
let core_properties_xml = std::str::from_utf8(&core_properties_part.data).unwrap();
assert!(
core_properties_xml.contains("<cp:coreProperties "),
"{core_properties_xml}"
);
assert!(
!core_properties_xml.contains("<dc:title>"),
"{core_properties_xml}"
);
}
#[test]
fn writes_custom_tab_stops_in_the_order_they_were_added() {
use crate::model::{TabLeader, TabStop, TabStopAlignment};
let paragraph = Paragraph::with_text("Tabbed")
.with_tab(TabStop::new(720))
.with_tab(
TabStop::new(-360)
.with_alignment(TabStopAlignment::Decimal)
.with_leader(TabLeader::Dot),
);
let document = Document::new().with_paragraph(paragraph);
let xml = document_xml(&document);
let tabs_start = xml.find("<w:tabs>").expect("missing w:tabs");
let first_tab = xml
.find(r#"<w:tab w:val="left" w:pos="720"/>"#)
.expect("missing first tab");
let second_tab = xml
.find(r#"<w:tab w:val="decimal" w:leader="dot" w:pos="-360"/>"#)
.expect("missing second tab");
assert!(tabs_start < first_tab && first_tab < second_tab, "{xml}");
}
#[test]
fn writes_keep_next_keep_lines_page_break_before_and_contextual_spacing() {
let paragraph = Paragraph::with_text("Heading")
.with_keep_with_next(true)
.with_keep_lines_together(true)
.with_page_break_before(true)
.with_contextual_spacing(true);
let document = Document::new().with_paragraph(paragraph);
let xml = document_xml(&document);
assert!(xml.contains("<w:keepNext/>"), "{xml}");
assert!(xml.contains("<w:keepLines/>"), "{xml}");
assert!(xml.contains("<w:pageBreakBefore/>"), "{xml}");
assert!(xml.contains("<w:contextualSpacing/>"), "{xml}");
let p_pr = xml.find("<w:pPr>").expect("missing w:pPr");
let keep_next = xml.find("<w:keepNext/>").unwrap();
assert!(p_pr < keep_next, "{xml}");
}
#[test]
fn writes_page_and_column_breaks_and_a_carriage_return_in_runs() {
use crate::model::BreakKind;
let document = Document::new().with_paragraph(
Paragraph::new()
.with_run(Run::with_break(BreakKind::TextWrapping))
.with_run(Run::with_break(BreakKind::Page))
.with_run(Run::with_break(BreakKind::Column))
.with_run(Run::with_carriage_return()),
);
let xml = document_xml(&document);
assert!(xml.contains("<w:br/>"), "{xml}");
assert!(xml.contains(r#"<w:br w:type="page"/>"#), "{xml}");
assert!(xml.contains(r#"<w:br w:type="column"/>"#), "{xml}");
assert!(xml.contains("<w:cr/>"), "{xml}");
}
#[test]
fn writes_company_and_manager_in_extended_properties_when_set() {
use crate::model::ExtendedProperties;
let extended_properties = ExtendedProperties::new()
.with_company("Acme Corp")
.with_manager("Morgan");
let document = Document::new()
.with_paragraph(Paragraph::with_text("Body"))
.with_extended_properties(extended_properties);
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let extended_properties_part = package
.part(EXTENDED_PROPERTIES_PART_NAME)
.expect("missing docProps/app.xml");
let extended_properties_xml = std::str::from_utf8(&extended_properties_part.data).unwrap();
assert!(
extended_properties_xml.contains("<Application>office-toolkit</Application>"),
"{extended_properties_xml}"
);
assert!(
extended_properties_xml.contains("<Manager>Morgan</Manager>"),
"{extended_properties_xml}"
);
assert!(
extended_properties_xml.contains("<Company>Acme Corp</Company>"),
"{extended_properties_xml}"
);
}
#[test]
fn a_document_with_no_extended_properties_set_only_writes_application() {
let document = Document::new().with_paragraph(Paragraph::with_text("Body"));
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let extended_properties_part = package
.part(EXTENDED_PROPERTIES_PART_NAME)
.expect("missing docProps/app.xml");
let extended_properties_xml = std::str::from_utf8(&extended_properties_part.data).unwrap();
assert!(
extended_properties_xml.contains("<Application>office-toolkit</Application>"),
"{extended_properties_xml}"
);
assert!(
!extended_properties_xml.contains("<Company>"),
"{extended_properties_xml}"
);
assert!(
!extended_properties_xml.contains("<Manager>"),
"{extended_properties_xml}"
);
}
#[test]
fn writes_custom_properties_with_sequential_pids_and_the_right_variant_types() {
use crate::model::CustomPropertyValue;
let document = Document::new()
.with_paragraph(Paragraph::with_text("Body"))
.with_custom_property(
"Client",
CustomPropertyValue::Text("Northwind Traders".to_string()),
)
.with_custom_property("Confidentiel", CustomPropertyValue::Bool(true))
.with_custom_property("Version interne", CustomPropertyValue::Int(7))
.with_custom_property("Taux de remise", CustomPropertyValue::Number(0.15));
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
let custom_properties_part = package
.part(CUSTOM_PROPERTIES_PART_NAME)
.expect("missing docProps/custom.xml");
let custom_properties_xml = std::str::from_utf8(&custom_properties_part.data).unwrap();
assert!(
custom_properties_xml.contains(
r#"<property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="2" name="Client"><vt:lpwstr>Northwind Traders</vt:lpwstr></property>"#
),
"{custom_properties_xml}"
);
assert!(
custom_properties_xml.contains(
r#"<property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="3" name="Confidentiel"><vt:bool>true</vt:bool></property>"#
),
"{custom_properties_xml}"
);
assert!(
custom_properties_xml.contains(
r#"<property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="4" name="Version interne"><vt:i4>7</vt:i4></property>"#
),
"{custom_properties_xml}"
);
assert!(
custom_properties_xml.contains(
r#"<property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="5" name="Taux de remise"><vt:r8>0.15</vt:r8></property>"#
),
"{custom_properties_xml}"
);
}
#[test]
fn a_document_with_no_custom_properties_omits_the_custom_properties_part() {
let document = Document::new().with_paragraph(Paragraph::with_text("Body"));
let buffer = document.write_to(std::io::Cursor::new(Vec::new())).unwrap();
let package = Package::read_from(buffer).unwrap();
assert!(package.part(CUSTOM_PROPERTIES_PART_NAME).is_none());
}
}