use oxml_opc::OpcPackage;
use oxml_opc::relationship::rel_types;
use rdocx::paragraph::Alignment;
use rdocx::table::VerticalAlignment;
use rdocx::{
BodyItemRef, BorderStyle, Length, ListLevel, RunPosition, RunRange, SectionBreak, StyleBuilder,
TabAlignment, TabLeader, UnderlineStyle,
};
use rdocx::{Document, PackageReadLimits, RevisionKind};
#[test]
fn settings_relationship_target_is_resolved_instead_of_assumed() {
let settings = br#"<?xml version="1.0"?><w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:documentProtection w:edit="comments" w:enforcement="true" w:hash="custom-hash" w:salt="custom-salt"/></w:settings>"#;
let mut seed = Document::new();
let bytes = seed.to_bytes().unwrap();
let mut package = OpcPackage::from_reader(std::io::Cursor::new(bytes)).unwrap();
package.set_part("/word/config/protection.xml", settings.to_vec());
package.content_types.add_override(
"/word/config/protection.xml",
"application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml",
);
package
.get_or_create_part_rels("/word/document.xml")
.add(rel_types::SETTINGS, "config/protection.xml");
let mut input = std::io::Cursor::new(Vec::new());
package.write_to(&mut input).unwrap();
let mut document = Document::from_bytes(input.get_ref()).unwrap();
let protection = document.document_protection().unwrap();
assert_eq!(protection.mode, rdocx::ProtectionMode::Comments);
assert_eq!(protection.hash.as_deref(), Some("custom-hash"));
let saved = document.to_bytes().unwrap();
let package = OpcPackage::from_reader(std::io::Cursor::new(saved)).unwrap();
assert_eq!(
package.get_part("/word/config/protection.xml").unwrap(),
settings
);
assert!(package.get_part("/word/settings.xml").is_none());
let relationship = package
.get_part_rels("/word/document.xml")
.and_then(|relationships| relationships.get_by_type(rel_types::SETTINGS))
.unwrap();
assert_eq!(relationship.target, "config/protection.xml");
}
fn document_xml(document: &mut Document) -> Vec<u8> {
let bytes = document.to_bytes().unwrap();
let package = OpcPackage::from_reader(std::io::Cursor::new(bytes)).unwrap();
package.get_part("/word/document.xml").unwrap().to_vec()
}
#[test]
fn public_body_items_preserve_opened_document_order() {
let mut seed = Document::new();
let bytes = seed.to_bytes().unwrap();
let mut package = OpcPackage::from_reader(std::io::Cursor::new(bytes)).unwrap();
let document_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:p="urn:producer">
<w:body>
<w:p><w:r><w:t>first</w:t></w:r></w:p>
<w:p/>
<w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w="1440"/></w:tblGrid><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
<w:sdt><w:sdtPr><w:tag w:val="public"/></w:sdtPr><w:sdtContent><w:p><w:r><w:t>control</w:t></w:r></w:p></w:sdtContent></w:sdt>
<p:opaque p:flag="keep"><p:child/></p:opaque>
<w:p><w:r><w:t>last</w:t></w:r></w:p>
<w:sectPr/>
</w:body>
</w:document>"#;
package.set_part("/word/document.xml", document_xml.to_vec());
let mut input = std::io::Cursor::new(Vec::new());
package.write_to(&mut input).unwrap();
let document = Document::from_bytes(input.get_ref()).unwrap();
let items = document
.body_items()
.map(|item| match item {
BodyItemRef::Paragraph(paragraph) => format!("paragraph:{}", paragraph.text()),
BodyItemRef::Table(table) => format!(
"table:{}:{}",
table.row_count(),
table.cell(0, 0).unwrap().text()
),
BodyItemRef::ContentControl(control) => {
format!("control:{}:{}", control.tag().unwrap(), control.text())
}
BodyItemRef::UnsupportedXml(raw) => {
format!("raw:{}", std::str::from_utf8(raw).unwrap())
}
})
.collect::<Vec<_>>();
assert_eq!(
items,
[
"paragraph:first",
"paragraph:",
"table:1:cell",
"control:public:control",
"raw:<p:opaque p:flag=\"keep\"><p:child/></p:opaque>",
"paragraph:last",
]
);
assert_eq!(
document
.paragraphs()
.iter()
.map(|paragraph| paragraph.text())
.collect::<Vec<_>>(),
["first", "", "control", "last"]
);
assert_eq!(document.tables().len(), 1);
}
#[test]
fn bounded_document_reader_rejects_package_expansion() {
let bytes = Document::new().to_bytes().unwrap();
let result = Document::from_bytes_with_limits(
&bytes,
PackageReadLimits {
max_entries: 1,
max_part_uncompressed_bytes: 1_024 * 1_024,
max_total_uncompressed_bytes: 2 * 1_024 * 1_024,
},
);
assert!(matches!(
result,
Err(rdocx::Error::Opc(
oxml_opc::OpcError::PackageLimitExceeded {
kind: "entry count",
limit: 1
}
))
));
}
#[test]
fn nested_revisions_are_reported_once_in_document_order() {
let mut source = Document::new();
let bytes = source.to_bytes().unwrap();
let mut package = OpcPackage::from_reader(std::io::Cursor::new(bytes)).unwrap();
let document_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:ins w:id="1" w:author="Ada"><w:r><w:t>top</w:t></w:r></w:ins></w:p>
<w:tbl>
<w:tblPr><w:tblPrChange w:id="2" w:author="Ben"><w:tblPr><w:jc w:val="center"/></w:tblPr></w:tblPrChange></w:tblPr>
<w:tblGrid/><w:tr><w:trPr><w:ins w:id="3" w:author="Cy"/></w:trPr><w:tc>
<w:p><w:del w:id="4" w:author="Dee"><w:r><w:delText>cell</w:delText></w:r></w:del></w:p>
</w:tc></w:tr>
</w:tbl>
<w:sdt><w:sdtPr><w:tag w:val="outer"/></w:sdtPr><w:sdtContent><w:p>
<w:moveFrom w:id="5" w:author="Eve"><w:r><w:t>control</w:t></w:r></w:moveFrom>
<w:sdt><w:sdtPr><w:tag w:val="inner"/></w:sdtPr><w:sdtContent><w:r><w:rPr><w:del w:id="6" w:author="Fox"/></w:rPr><w:t>nested</w:t></w:r></w:sdtContent></w:sdt>
</w:p></w:sdtContent></w:sdt>
<w:sectPr><w:sectPrChange w:id="7" w:author="Gia"><w:sectPr><w:titlePg/></w:sectPr></w:sectPrChange></w:sectPr>
</w:body>
</w:document>"#;
package.set_part("/word/document.xml", document_xml.to_vec());
let mut input = std::io::Cursor::new(Vec::new());
package.write_to(&mut input).unwrap();
let document = Document::from_bytes(input.get_ref()).unwrap();
let revisions = document.revisions();
assert_eq!(
revisions
.iter()
.map(|revision| revision.id())
.collect::<Vec<_>>(),
vec![1, 2, 3, 4, 5, 6, 7]
);
assert_eq!(revisions[0].author(), "Ada");
assert_eq!(revisions[0].timestamp(), None);
assert_eq!(revisions[0].kind(), RevisionKind::Insertion);
assert_eq!(revisions[3].kind(), RevisionKind::Deletion);
assert_eq!(revisions[4].kind(), RevisionKind::MoveFrom);
assert_eq!(revisions[6].kind(), RevisionKind::SectionPropertyChange);
}
#[test]
fn m14_collaboration_models_coexist_and_preserve_unmodelled_xml() {
const PRESERVED_PART: &[u8] = b"producer-private-bytes\x00\xff";
const OPAQUE_CONTROL: &[u8] =
br#"<p:opaque-control p:flag="keep"><p:child/></p:opaque-control>"#;
const OPAQUE_COMMENT: &[u8] = br#"<p:opaque-comment p:flag="keep"/>"#;
let mut source = Document::new();
let bytes = source.to_bytes().unwrap();
let mut package = OpcPackage::from_reader(std::io::Cursor::new(bytes)).unwrap();
let document_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:p="urn:producer">
<w:body>
<w:p><w:r><w:t>plain </w:t></w:r><w:ins w:id="10" w:author="Ada"><w:r><w:t>inserted</w:t></w:r></w:ins><w:del w:id="11" w:author="Ben"><w:r><w:delText>deleted</w:delText></w:r></w:del></w:p>
<w:sdt><w:sdtPr><w:tag w:val="customer"/><p:opaque-control p:flag="keep"><p:child/></p:opaque-control></w:sdtPr><w:sdtContent><w:p><w:r><w:t>old value</w:t></w:r></w:p></w:sdtContent></w:sdt>
<w:p><w:bookmarkStart w:id="30" w:name="existing"/><w:commentRangeStart w:id="20"/><w:r><w:t>anchor</w:t></w:r><w:bookmarkEnd w:id="30"/><w:commentRangeEnd w:id="20"/><w:r><w:commentReference w:id="20"/></w:r><w:r><w:t> tail</w:t></w:r></w:p>
<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr>
</w:body>
</w:document>"#;
let comments_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:p="urn:producer"><w:comment w:id="20" w:author="Ada"><w:p w14:paraId="A1B2C3D4"><w:r><w:t>existing comment</w:t></w:r><p:opaque-comment p:flag="keep"/></w:p></w:comment></w:comments>"#;
package.set_part("/word/document.xml", document_xml.to_vec());
package.set_part("/word/comments.xml", comments_xml.to_vec());
package.content_types.add_override(
"/word/comments.xml",
"application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
);
package
.get_or_create_part_rels("/word/document.xml")
.add(rel_types::COMMENTS, "comments.xml");
package.set_part("/custom/producer.bin", PRESERVED_PART.to_vec());
package
.content_types
.add_override("/custom/producer.bin", "application/octet-stream");
let mut input = std::io::Cursor::new(Vec::new());
package.write_to(&mut input).unwrap();
let mut document = Document::from_bytes(input.get_ref()).unwrap();
assert_eq!(
document
.revisions()
.iter()
.map(|revision| revision.id())
.collect::<Vec<_>>(),
vec![10, 11]
);
assert_eq!(document.comments()[0].text(), "existing comment");
assert_eq!(document.content_controls()[0].tag(), Some("customer"));
assert_eq!(document.content_controls()[0].text(), "old value");
assert_eq!(document.bookmarks()[0].name(), Some("existing"));
assert_eq!(document.accept_revision_id(10).unwrap(), 1);
let added_comment = document
.add_comment(
RunRange {
start: RunPosition {
body_index: 0,
run_index: 0,
},
end: RunPosition {
body_index: 0,
run_index: 1,
},
},
"Cy",
Some("CY"),
"added comment",
)
.unwrap();
assert_eq!(
document
.set_content_control_value_by_tag("customer", "new value")
.unwrap(),
1
);
document
.add_bookmark(
"added_bookmark",
RunRange {
start: RunPosition {
body_index: 2,
run_index: 0,
},
end: RunPosition {
body_index: 2,
run_index: 1,
},
},
)
.unwrap();
let saved = document.to_bytes().unwrap();
let saved_package = OpcPackage::from_reader(std::io::Cursor::new(&saved)).unwrap();
assert_eq!(
saved_package.get_part("/custom/producer.bin"),
Some(PRESERVED_PART)
);
assert!(
saved_package
.get_part("/word/document.xml")
.unwrap()
.windows(OPAQUE_CONTROL.len())
.any(|window| window == OPAQUE_CONTROL)
);
assert!(
saved_package
.get_part("/word/comments.xml")
.unwrap()
.windows(OPAQUE_COMMENT.len())
.any(|window| window == OPAQUE_COMMENT)
);
let reopened = Document::from_bytes(&saved).unwrap();
assert_eq!(
reopened
.revisions()
.iter()
.map(|revision| revision.id())
.collect::<Vec<_>>(),
vec![11]
);
assert_eq!(
reopened
.comments()
.iter()
.find(|comment| comment.id() == added_comment)
.unwrap()
.text(),
"added comment"
);
assert_eq!(reopened.content_controls()[0].text(), "new value");
assert!(
reopened
.bookmarks()
.iter()
.any(|bookmark| bookmark.name() == Some("added_bookmark"))
);
}
const PNG_2_BY_3: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x08, 0x02, 0x00, 0x00, 0x00, 0x36, 0x88, 0x49,
0xd6, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
0x44, 0x0c, 0x28, 0x14, 0x00, 0x44, 0xd0, 0x05, 0xfb, 0xa4, 0xcf, 0xde, 0x80, 0x00, 0x00, 0x00,
0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
#[test]
fn rdocx_error_opc_wraps_the_shared_error_type() {
let shared_error = oxml_opc::OpcError::PartNotFound("missing.xml".to_string());
let error: rdocx::Error = shared_error.into();
match error {
rdocx::Error::Opc(inner) => {
let _: oxml_opc::OpcError = inner;
}
other => panic!("expected shared OPC error, got {other}"),
}
}
#[test]
fn new_document_uses_the_shared_word_package_setup() {
let mut document = Document::new();
let bytes = document.to_bytes().unwrap();
let package = OpcPackage::from_reader(std::io::Cursor::new(bytes)).unwrap();
assert_eq!(
package.main_document_part().as_deref(),
Some("/word/document.xml")
);
assert_eq!(
package.content_types.content_type_for("/word/document.xml"),
Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml")
);
assert_eq!(
package.content_types.content_type_for("/word/styles.xml"),
Some("application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml")
);
assert!(package.get_part("/word/styles.xml").is_some());
let styles = package
.get_part_rels("/word/document.xml")
.and_then(|rels| rels.get_by_type(rel_types::STYLES))
.expect("new document must relate its styles part");
assert_eq!(styles.target, "styles.xml");
}
#[test]
fn create_and_round_trip_simple_document() {
let mut doc = Document::new();
doc.add_paragraph("Hello, World!");
doc.add_paragraph("This is a test document.");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraph_count(), 2);
let paras = doc2.paragraphs();
assert_eq!(paras[0].text(), "Hello, World!");
assert_eq!(paras[1].text(), "This is a test document.");
}
#[test]
fn create_and_round_trip_formatted_document() {
let mut doc = Document::new();
doc.add_paragraph("Document Title")
.style("Heading1")
.alignment(Alignment::Center);
let mut para = doc.add_paragraph("");
para.add_run("This is ").font("Arial").size(11.0);
para.add_run("bold").bold(true).font("Arial").size(11.0);
para.add_run(" and this is ").font("Arial").size(11.0);
para.add_run("italic").italic(true).font("Arial").size(11.0);
para.add_run(".").font("Arial").size(11.0);
doc.add_paragraph("This paragraph has special formatting.")
.alignment(Alignment::Justify)
.indent_left(Length::inches(0.5))
.space_before(Length::pt(12.0))
.space_after(Length::pt(6.0));
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraph_count(), 3);
let paras = doc2.paragraphs();
assert_eq!(paras[0].text(), "Document Title");
assert_eq!(paras[0].style_id(), Some("Heading1"));
assert_eq!(paras[0].alignment(), Some(Alignment::Center));
let runs: Vec<_> = paras[1].runs().collect();
assert_eq!(runs.len(), 5);
assert!(!runs[0].is_bold());
assert!(runs[1].is_bold());
assert!(!runs[2].is_italic());
assert!(runs[3].is_italic());
assert_eq!(paras[2].alignment(), Some(Alignment::Justify));
}
#[test]
fn round_trip_preserves_styles() {
let mut doc = Document::new();
doc.add_paragraph("Normal paragraph");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert!(doc2.style("Normal").is_some());
assert!(doc2.style("Heading1").is_some());
let normal = doc2.style("Normal").unwrap();
assert!(normal.is_default());
assert_eq!(normal.name(), Some("Normal"));
}
#[test]
fn save_and_load_file() {
let dir = std::env::temp_dir();
let process_id = std::process::id().to_string();
let path = dir.join(format!("rdocx_test_output_{process_id}.docx"));
assert!(
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.contains(&process_id)),
"temporary output path must contain the test process ID"
);
let mut doc = Document::new();
doc.add_paragraph("Saved to disk");
doc.save(&path).unwrap();
let doc2 = Document::open(&path).unwrap();
assert_eq!(doc2.paragraph_count(), 1);
assert_eq!(doc2.paragraphs()[0].text(), "Saved to disk");
std::fs::remove_file(&path).ok();
}
#[test]
fn section_properties_preserved() {
let doc = Document::new();
let sect = doc.section_properties().unwrap();
assert_eq!(sect.page_width.unwrap().0, 12240); assert_eq!(sect.page_height.unwrap().0, 15840); assert_eq!(sect.margin_top.unwrap().0, 1440);
let mut doc2 = Document::new();
let bytes = doc2.to_bytes().unwrap();
let doc3 = Document::from_bytes(&bytes).unwrap();
let sect3 = doc3.section_properties().unwrap();
assert_eq!(sect3.page_width.unwrap().0, 12240);
}
#[test]
fn empty_document_round_trip() {
let mut doc = Document::new();
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraph_count(), 0);
}
#[test]
fn run_color_and_font_round_trip() {
let mut doc = Document::new();
let mut para = doc.add_paragraph("");
para.add_run("Red text")
.color("FF0000")
.font("Times New Roman")
.size(16.0);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
let runs: Vec<_> = paras[0].runs().collect();
assert_eq!(runs[0].color(), Some("FF0000"));
assert_eq!(runs[0].font_name(), Some("Times New Roman"));
assert_eq!(runs[0].size(), Some(16.0));
}
#[test]
fn facade_table_and_tristate_accessors_are_total() {
let mut document = Document::new();
document.add_paragraph("").add_run("value");
let paragraph = document.paragraph(0).unwrap();
let run = paragraph.run(0).unwrap();
assert_eq!(run.bold_value(), None);
assert_eq!(run.italic_value(), None);
assert_eq!(run.strike_value(), None);
{
let mut paragraph = document.paragraph_mut(0).unwrap();
let mut run = paragraph.run_mut(0).unwrap();
run.set_bold_value(Some(false));
run.set_italic_value(Some(true));
run.set_strike_value(Some(false));
assert!(run.set_underline_code_value(Some(9)));
assert!(!run.set_underline_code_value(Some(5)));
}
assert_eq!(
document
.paragraph(0)
.unwrap()
.run(0)
.unwrap()
.underline_code_value(),
Some(9)
);
{
let mut paragraph = document.paragraph_mut(0).unwrap();
let mut run = paragraph.run_mut(0).unwrap();
assert!(run.set_underline_code_value(Some(10)));
}
assert_eq!(document.table_count(), 0);
assert!(document.table(0).is_none());
assert!(document.table_mut(0).is_none());
document.add_table(1, 1);
assert_eq!(document.table_count(), 1);
assert_eq!(document.table(0).unwrap().row_count(), 1);
assert_eq!(
document
.table(0)
.unwrap()
.cell(0, 0)
.unwrap()
.paragraph_count(),
1
);
assert!(
document
.table_mut(0)
.unwrap()
.cell(0, 0)
.unwrap()
.paragraph_mut(0)
.is_some()
);
let bytes = document.to_bytes().unwrap();
let reopened = Document::from_bytes(&bytes).unwrap();
let paragraph = reopened.paragraph(0).unwrap();
let run = paragraph.run(0).unwrap();
assert_eq!(run.bold_value(), Some(false));
assert_eq!(run.italic_value(), Some(true));
assert_eq!(run.strike_value(), Some(false));
assert_eq!(run.underline_code_value(), Some(10));
}
#[test]
fn established_underline_enum_and_first_line_indent_remain_compatible() {
fn established_underline_code(style: UnderlineStyle) -> i32 {
match style {
UnderlineStyle::None => 0,
UnderlineStyle::Single => 1,
UnderlineStyle::Double => 3,
UnderlineStyle::Thick => 6,
UnderlineStyle::Dotted => 4,
UnderlineStyle::Dash => 7,
UnderlineStyle::Wave => 11,
UnderlineStyle::Words => 2,
}
}
assert_eq!(established_underline_code(UnderlineStyle::Wave), 11);
let mut document = Document::new();
document
.add_paragraph("legacy")
.set_first_line_indent(Length::inches(-0.25));
let xml = String::from_utf8(document_xml(&mut document)).unwrap();
assert!(xml.contains("w:firstLine=\"-360\""));
assert!(!xml.contains("w:hanging=\"360\""));
}
#[test]
fn paragraph_borders_round_trip() {
let mut doc = Document::new();
doc.add_paragraph("Bordered paragraph")
.border_all(BorderStyle::Single, 4, "000000");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
assert!(paras[0].has_borders());
}
#[test]
fn paragraph_tab_stops_round_trip() {
let mut doc = Document::new();
doc.add_paragraph("Tab text")
.add_tab_stop(TabAlignment::Right, Length::inches(6.0))
.add_tab_stop_with_leader(TabAlignment::Right, Length::inches(6.5), TabLeader::Dot);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
assert_eq!(paras[0].tab_stop_count(), 2);
}
#[test]
fn paragraph_shading_round_trip() {
let mut doc = Document::new();
doc.add_paragraph("Highlighted paragraph").shading("FFFF00");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
assert_eq!(paras[0].shading_fill(), Some("FFFF00"));
}
#[test]
fn paragraph_spacing_and_indent_round_trip() {
let mut doc = Document::new();
doc.add_paragraph("Indented text")
.indent_left(Length::inches(1.0))
.indent_right(Length::inches(0.5))
.first_line_indent(Length::inches(0.25))
.space_before(Length::pt(12.0))
.space_after(Length::pt(6.0))
.line_spacing_multiple(1.5);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraph_count(), 1);
assert_eq!(doc2.paragraphs()[0].text(), "Indented text");
}
#[test]
fn paragraph_pagination_controls_round_trip() {
let mut doc = Document::new();
doc.add_paragraph("Keep with next")
.keep_with_next(true)
.keep_together(true)
.widow_control(true);
doc.add_paragraph("Page break before")
.page_break_before(true);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraph_count(), 2);
}
#[test]
fn run_underline_styles_round_trip() {
let mut doc = Document::new();
let mut para = doc.add_paragraph("");
para.add_run("Simple underline").underline(true);
para.add_run("Wave underline")
.underline_style(UnderlineStyle::Wave);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
assert_eq!(paras[0].text(), "Simple underlineWave underline");
}
#[test]
fn run_advanced_formatting_round_trip() {
let mut doc = Document::new();
let mut para = doc.add_paragraph("");
para.add_run("Strike").strike(true);
para.add_run("DStrike").double_strike(true);
para.add_run("CAPS").all_caps(true);
para.add_run("SmallCaps").small_caps(true);
para.add_run("Super").superscript();
para.add_run("Sub").subscript();
para.add_run("Hidden").hidden(true);
para.add_run("Spaced").character_spacing(Length::pt(2.0));
para.add_run("Wide").width_scale(150);
para.add_run("Raised").position(6);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
let runs: Vec<_> = paras[0].runs().collect();
assert_eq!(runs.len(), 10);
assert!(runs[0].is_strike());
assert_eq!(runs[4].vert_align(), Some("superscript"));
assert_eq!(runs[5].vert_align(), Some("subscript"));
}
#[test]
fn run_style_assignment_round_trip() {
let mut doc = Document::new();
let mut para = doc.add_paragraph("");
para.add_run("Styled run").style("Heading1Char");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
let runs: Vec<_> = paras[0].runs().collect();
assert_eq!(runs[0].style_id(), Some("Heading1Char"));
}
#[test]
fn custom_style_round_trip() {
let mut doc = Document::new();
doc.add_style(
StyleBuilder::paragraph("CustomHeading", "Custom Heading")
.based_on("Heading1")
.next_style("Normal"),
);
doc.add_style(StyleBuilder::character("Emphasis", "Emphasis Style"));
doc.add_paragraph("Custom styled").style("CustomHeading");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let s = doc2.style("CustomHeading").unwrap();
assert_eq!(s.name(), Some("Custom Heading"));
assert_eq!(s.based_on(), Some("Heading1"));
assert!(doc2.style("Emphasis").is_some());
let paras = doc2.paragraphs();
assert_eq!(paras[0].style_id(), Some("CustomHeading"));
}
#[test]
fn style_inheritance_resolution() {
let doc = Document::new();
let rpr = doc.resolve_run_properties(Some("Heading1"), None);
assert_eq!(rpr.bold, Some(true));
assert_eq!(rpr.font_ascii, Some("Calibri".to_string()));
let ppr = doc.resolve_paragraph_properties(Some("Normal"));
assert!(ppr.space_after.is_some());
}
#[test]
fn section_landscape_round_trip() {
let mut doc = Document::new();
doc.set_landscape();
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let sect = doc2.section_properties().unwrap();
assert!(sect.page_width.unwrap().0 > sect.page_height.unwrap().0);
}
#[test]
fn section_margins_round_trip() {
let mut doc = Document::new();
doc.set_margins(
Length::inches(0.5),
Length::inches(0.75),
Length::inches(0.5),
Length::inches(0.75),
);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let sect = doc2.section_properties().unwrap();
assert_eq!(sect.margin_top.unwrap().0, 720);
assert_eq!(sect.margin_right.unwrap().0, 1080);
assert_eq!(sect.margin_bottom.unwrap().0, 720);
assert_eq!(sect.margin_left.unwrap().0, 1080);
}
#[test]
fn section_columns_round_trip() {
let mut doc = Document::new();
doc.set_columns(3, Length::inches(0.25));
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let sect = doc2.section_properties().unwrap();
let cols = sect.columns.as_ref().unwrap();
assert_eq!(cols.num, Some(3));
assert_eq!(cols.equal_width, Some(true));
}
#[test]
fn section_a4_page_size() {
let mut doc = Document::new();
doc.set_page_size(Length::cm(21.0), Length::cm(29.7));
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let sect = doc2.section_properties().unwrap();
let w = sect.page_width.unwrap().0;
let h = sect.page_height.unwrap().0;
assert!((w - 11906).abs() < 5, "Expected ~11906, got {w}");
assert!((h - 16838).abs() < 5, "Expected ~16838, got {h}");
}
#[test]
fn comprehensive_document_round_trip() {
let mut doc = Document::new();
doc.add_style(StyleBuilder::paragraph("BlockQuote", "Block Quote").based_on("Normal"));
doc.set_margins(
Length::inches(1.0),
Length::inches(1.25),
Length::inches(1.0),
Length::inches(1.25),
);
doc.add_paragraph("My Document")
.style("Heading1")
.alignment(Alignment::Center)
.space_after(Length::pt(24.0));
let mut para = doc.add_paragraph("");
para.add_run("This is ").font("Calibri").size(11.0);
para.add_run("important")
.bold(true)
.color("FF0000")
.font("Calibri")
.size(11.0);
para.add_run(" text with ").font("Calibri").size(11.0);
para.add_run("underline")
.underline(true)
.font("Calibri")
.size(11.0);
para.add_run(".").font("Calibri").size(11.0);
doc.add_paragraph("This is a block quote.")
.style("BlockQuote")
.indent_left(Length::inches(0.5))
.indent_right(Length::inches(0.5))
.shading("F2F2F2")
.space_before(Length::pt(6.0))
.space_after(Length::pt(6.0));
doc.add_paragraph("Important note")
.border_all(BorderStyle::Single, 4, "000000")
.shading("FFFFCC");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraph_count(), 4);
assert!(doc2.style("BlockQuote").is_some());
let paras = doc2.paragraphs();
assert_eq!(paras[0].style_id(), Some("Heading1"));
assert_eq!(paras[0].alignment(), Some(Alignment::Center));
let runs: Vec<_> = paras[1].runs().collect();
assert_eq!(runs.len(), 5);
assert!(runs[1].is_bold());
assert_eq!(runs[1].color(), Some("FF0000"));
assert_eq!(paras[2].shading_fill(), Some("F2F2F2"));
assert!(paras[3].has_borders());
assert_eq!(paras[3].shading_fill(), Some("FFFFCC"));
}
#[test]
fn table_basic_creation_round_trip() {
let mut doc = Document::new();
let mut table = doc.add_table(3, 4);
table.cell(0, 0).unwrap().set_text("A1");
table.cell(0, 1).unwrap().set_text("B1");
table.cell(1, 0).unwrap().set_text("A2");
table.cell(2, 3).unwrap().set_text("D3");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.table_count(), 1);
let tables = doc2.tables();
let t = &tables[0];
assert_eq!(t.row_count(), 3);
assert_eq!(t.column_count(), 4);
assert_eq!(t.cell(0, 0).unwrap().text(), "A1");
assert_eq!(t.cell(0, 1).unwrap().text(), "B1");
assert_eq!(t.cell(1, 0).unwrap().text(), "A2");
assert_eq!(t.cell(2, 3).unwrap().text(), "D3");
}
#[test]
fn table_column_width_updates_grid_and_cells() {
let mut doc = Document::new();
let mut table = doc.add_table(2, 2);
assert!(table.set_column_width(0, Length::twips(2_000)));
assert!(table.set_column_width(1, Length::twips(3_000)));
assert!(!table.set_column_width(2, Length::twips(1_000)));
let xml = String::from_utf8(document_xml(&mut doc)).unwrap();
assert_eq!(
xml.matches("<w:tblW w:w=\"5000\" w:type=\"dxa\"").count(),
1,
"the table width must equal the synchronized grid width"
);
assert_eq!(xml.matches("<w:gridCol w:w=\"2000\"").count(), 1);
assert_eq!(xml.matches("<w:gridCol w:w=\"3000\"").count(), 1);
assert_eq!(xml.matches("<w:tcW w:w=\"2000\" w:type=\"dxa\"").count(), 2);
assert_eq!(xml.matches("<w:tcW w:w=\"3000\" w:type=\"dxa\"").count(), 2);
}
#[test]
fn table_with_formatting_round_trip() {
let mut doc = Document::new();
doc.add_table(2, 2)
.borders(BorderStyle::Single, 4, "000000")
.alignment(Alignment::Center)
.layout_fixed();
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.table_count(), 1);
let tables = doc2.tables();
assert_eq!(tables[0].row_count(), 2);
assert_eq!(tables[0].column_count(), 2);
}
#[test]
fn table_cell_shading_and_alignment() {
let mut doc = Document::new();
let mut table = doc.add_table(2, 2);
table.cell(0, 0).unwrap().set_text("Header");
table
.cell(0, 0)
.unwrap()
.shading("4472C4")
.vertical_alignment(VerticalAlignment::Center);
table
.cell(1, 0)
.unwrap()
.shading("D9E2F3")
.vertical_alignment(VerticalAlignment::Bottom);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let tables = doc2.tables();
let cell_00 = tables[0].cell(0, 0).unwrap();
assert_eq!(cell_00.shading_fill(), Some("4472C4"));
assert_eq!(
cell_00.vertical_alignment(),
Some(VerticalAlignment::Center)
);
let cell_10 = tables[0].cell(1, 0).unwrap();
assert_eq!(cell_10.shading_fill(), Some("D9E2F3"));
assert_eq!(
cell_10.vertical_alignment(),
Some(VerticalAlignment::Bottom)
);
}
#[test]
fn table_header_row_round_trip() {
let mut doc = Document::new();
let mut table = doc.add_table(3, 2);
table.row(0).unwrap().header();
table.cell(0, 0).unwrap().set_text("Col A");
table.cell(0, 1).unwrap().set_text("Col B");
table.cell(1, 0).unwrap().set_text("Data 1");
table.cell(2, 0).unwrap().set_text("Data 2");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let tables = doc2.tables();
assert!(tables[0].row(0).unwrap().is_header());
assert!(!tables[0].row(1).unwrap().is_header());
}
#[test]
fn table_cell_grid_span_round_trip() {
let mut doc = Document::new();
let mut table = doc.add_table(2, 3);
table.cell(0, 0).unwrap().set_text("Merged");
table.cell(0, 0).unwrap().grid_span(2);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let tables = doc2.tables();
assert_eq!(tables[0].cell(0, 0).unwrap().grid_span(), Some(2));
}
#[test]
fn table_mixed_with_paragraphs() {
let mut doc = Document::new();
doc.add_paragraph("Before the table");
let mut table = doc.add_table(2, 2);
table.cell(0, 0).unwrap().set_text("Cell");
doc.add_paragraph("After the table");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraph_count(), 2);
assert_eq!(doc2.table_count(), 1);
let paras = doc2.paragraphs();
assert_eq!(paras[0].text(), "Before the table");
assert_eq!(paras[1].text(), "After the table");
let tables = doc2.tables();
assert_eq!(tables[0].cell(0, 0).unwrap().text(), "Cell");
}
#[test]
fn table_cell_multiple_paragraphs() {
let mut doc = Document::new();
let mut table = doc.add_table(1, 1);
let mut cell = table.cell(0, 0).unwrap();
cell.set_text("First line");
let mut para = cell.add_paragraph("");
para.add_run("Second line").bold(true);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let tables = doc2.tables();
let cell = tables[0].cell(0, 0).unwrap();
let paras: Vec<_> = cell.paragraphs().collect();
assert_eq!(paras.len(), 2);
assert_eq!(paras[0].text(), "First line");
assert_eq!(paras[1].text(), "Second line");
}
#[test]
fn inline_image_round_trip() {
let mut doc = Document::new();
doc.add_paragraph("Before image");
let png_data: Vec<u8> = vec![
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC,
0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
doc.add_picture(
&png_data,
"test.png",
Length::inches(2.0),
Length::inches(1.5),
);
doc.add_paragraph("After image");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraph_count(), 3);
assert_eq!(doc2.paragraphs()[0].text(), "Before image");
assert_eq!(doc2.paragraphs()[2].text(), "After image");
}
#[test]
fn add_picture_auto_uses_native_size_at_72_dpi() {
let mut document = Document::new();
document
.add_picture_auto(PNG_2_BY_3, "two-by-three.png")
.expect("valid image dimensions");
let bytes = document.to_bytes().unwrap();
let package = OpcPackage::from_reader(std::io::Cursor::new(&bytes)).unwrap();
let xml = std::str::from_utf8(package.get_part("/word/document.xml").unwrap()).unwrap();
assert!(xml.contains(r#"<wp:extent cx="25400" cy="38100"/>"#));
let mut reopened = Document::from_bytes(&bytes).unwrap();
let round_trip_xml = String::from_utf8(document_xml(&mut reopened)).unwrap();
assert!(round_trip_xml.contains(r#"<wp:extent cx="25400" cy="38100"/>"#));
}
#[test]
fn add_picture_auto_rejects_unavailable_dimensions_without_mutation() {
let mut document = Document::new();
document.add_paragraph("unchanged");
let before = document.to_bytes().unwrap();
let error = match document.add_picture_auto(b"not an image", "broken.bin") {
Ok(_) => panic!("malformed image should fail"),
Err(error) => error,
};
match error {
rdocx::Error::UnavailableImageDimensions { filename } => {
assert_eq!(filename, "broken.bin");
}
other => panic!("expected unavailable image dimensions, got {other}"),
}
assert_eq!(document.content_count(), 1);
assert!(document.images().is_empty());
let after = document.to_bytes().unwrap();
assert_eq!(after, before);
let package = OpcPackage::from_reader(std::io::Cursor::new(after)).unwrap();
assert!(
package
.get_part_rels("/word/document.xml")
.unwrap()
.get_all_by_type(rel_types::IMAGE)
.is_empty()
);
assert!(
!package
.parts
.keys()
.any(|name| name.starts_with("/word/media/"))
);
let xml = std::str::from_utf8(package.get_part("/word/document.xml").unwrap()).unwrap();
assert!(!xml.contains("<w:drawing>"));
}
#[test]
fn header_footer_round_trip() {
let mut doc = Document::new();
doc.set_header("Page Header");
doc.set_footer("Page Footer");
doc.add_paragraph("Body text");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.header_text(), Some("Page Header".to_string()));
assert_eq!(doc2.footer_text(), Some("Page Footer".to_string()));
assert_eq!(doc2.paragraph_count(), 1);
}
#[test]
fn first_page_header_footer() {
let mut doc = Document::new();
doc.set_header("Default Header");
doc.set_footer("Default Footer");
doc.set_first_page_header("First Page Header");
doc.set_first_page_footer("First Page Footer");
doc.add_paragraph("Content");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.header_text(), Some("Default Header".to_string()));
assert_eq!(doc2.footer_text(), Some("Default Footer".to_string()));
let sect = doc2.section_properties().unwrap();
assert_eq!(sect.title_pg, Some(true));
}
#[test]
fn bullet_list_round_trip() {
let mut doc = Document::new();
doc.add_paragraph("Items:");
doc.add_bullet_list_item("First item", 0);
doc.add_bullet_list_item("Second item", 0);
doc.add_bullet_list_item("Sub-item", 1);
doc.add_bullet_list_item("Third item", 0);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraph_count(), 5);
let paras = doc2.paragraphs();
assert_eq!(paras[0].text(), "Items:");
assert_eq!(paras[1].text(), "First item");
assert_eq!(paras[2].text(), "Second item");
assert_eq!(paras[3].text(), "Sub-item");
assert_eq!(paras[4].text(), "Third item");
}
#[test]
fn numbered_list_round_trip() {
let mut doc = Document::new();
doc.add_numbered_list_item("Step one", 0);
doc.add_numbered_list_item("Step two", 0);
doc.add_numbered_list_item("Sub-step a", 1);
doc.add_numbered_list_item("Sub-step b", 1);
doc.add_numbered_list_item("Step three", 0);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraph_count(), 5);
let paras = doc2.paragraphs();
assert_eq!(paras[0].text(), "Step one");
assert_eq!(paras[4].text(), "Step three");
}
#[test]
fn mixed_lists_round_trip() {
let mut doc = Document::new();
doc.add_paragraph("Introduction");
doc.add_bullet_list_item("Bullet 1", 0);
doc.add_bullet_list_item("Bullet 2", 0);
doc.add_paragraph("Transition");
doc.add_numbered_list_item("Step 1", 0);
doc.add_numbered_list_item("Step 2", 0);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraph_count(), 6);
let paras = doc2.paragraphs();
assert_eq!(paras[0].text(), "Introduction");
assert_eq!(paras[1].text(), "Bullet 1");
assert_eq!(paras[3].text(), "Transition");
assert_eq!(paras[4].text(), "Step 1");
}
#[test]
fn paragraph_facade_exposes_exact_bottom_border_facts() {
let mut document = Document::new();
document
.add_paragraph("")
.border_bottom(BorderStyle::Single, 8, "808080");
let bytes = document.to_bytes().unwrap();
let reopened = Document::from_bytes(&bytes).unwrap();
let paragraph = reopened.paragraph(0).unwrap();
let border = paragraph.bottom_border().unwrap();
assert_eq!(paragraph.border_count(), 1);
assert_eq!(border.style(), "single");
assert_eq!(border.size_eighths_pt(), Some(8));
assert_eq!(border.space_points(), Some(1));
assert_eq!(border.color(), Some("808080"));
}
#[test]
fn custom_list_definitions_restart_numbering_per_list() {
let mut doc = Document::new();
let first = doc.add_list_definition(&[ListLevel::decimal()]);
let second = doc.add_list_definition(&[ListLevel::decimal()]);
assert_ne!(first, second);
doc.add_paragraph("List one, item one")
.set_numbering(first, 0);
doc.add_paragraph("List two, item one")
.set_numbering(second, 0);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
assert_eq!(paras[0].numbering(), Some((first, 0)));
assert_eq!(paras[1].numbering(), Some((second, 0)));
assert_eq!(doc2.numbering_is_bullet(first), Some(false));
assert_eq!(doc2.numbering_is_bullet(second), Some(false));
}
#[test]
fn custom_list_definition_mixes_formats_across_levels() {
let mut doc = Document::new();
let num_id = doc.add_list_definition(&[ListLevel::bullet(), ListLevel::decimal().start(3)]);
doc.add_paragraph("bullet item").set_numbering(num_id, 0);
doc.add_paragraph("third decimal item")
.set_numbering(num_id, 1);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
assert_eq!(paras[0].numbering(), Some((num_id, 0)));
assert_eq!(paras[1].numbering(), Some((num_id, 1)));
assert_eq!(doc2.numbering_is_bullet(num_id), Some(true));
let mut doc3 = Document::from_bytes(&bytes).unwrap();
assert!(doc3.set_list_level(num_id, 2, ListLevel::decimal().start(7)));
}
#[test]
fn set_list_level_upgrades_a_deeper_level_after_the_fact() {
let mut doc = Document::new();
let num_id = doc.add_list_definition(&[ListLevel::bullet()]);
assert!(doc.set_list_level(num_id, 1, ListLevel::decimal().start(3)));
assert!(!doc.set_list_level(999, 1, ListLevel::decimal()));
doc.add_paragraph("bullet").set_numbering(num_id, 0);
doc.add_paragraph("decimal from three")
.set_numbering(num_id, 1);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraphs()[1].numbering(), Some((num_id, 1)));
}
#[test]
fn paragraph_numbering_value_can_be_cleared() {
let mut doc = Document::new();
let num_id = doc.add_list_definition(&[ListLevel::bullet()]);
let mut para = doc.add_paragraph("was a list item");
para.set_numbering(num_id, 0);
para.set_numbering_value(None);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraphs()[0].numbering(), None);
}
#[test]
fn comprehensive_phase3_document() {
let mut doc = Document::new();
doc.set_margins(
Length::inches(1.0),
Length::inches(1.0),
Length::inches(1.0),
Length::inches(1.0),
);
doc.set_header("Phase 3 Test Document");
doc.set_footer("Confidential");
doc.add_paragraph("Project Report")
.style("Heading1")
.alignment(Alignment::Center);
doc.add_paragraph("This document demonstrates Phase 3 features.");
doc.add_paragraph("Key Points:").style("Heading2");
doc.add_bullet_list_item("Tables with formatting", 0);
doc.add_bullet_list_item("Inline images", 0);
doc.add_bullet_list_item("Headers and footers", 0);
doc.add_bullet_list_item("Numbered and bulleted lists", 0);
doc.add_paragraph("Data Summary:").style("Heading2");
let mut table = doc
.add_table(3, 3)
.borders(BorderStyle::Single, 4, "000000");
table.row(0).unwrap().header();
table.cell(0, 0).unwrap().set_text("Category");
table
.cell(0, 0)
.unwrap()
.shading("4472C4")
.vertical_alignment(VerticalAlignment::Center);
table.cell(0, 1).unwrap().set_text("Q1");
table.cell(0, 1).unwrap().shading("4472C4");
table.cell(0, 2).unwrap().set_text("Q2");
table.cell(0, 2).unwrap().shading("4472C4");
table.cell(1, 0).unwrap().set_text("Revenue");
table.cell(1, 1).unwrap().set_text("$1,200");
table.cell(1, 2).unwrap().set_text("$1,500");
table.cell(2, 0).unwrap().set_text("Expenses");
table.cell(2, 1).unwrap().set_text("$800");
table.cell(2, 2).unwrap().set_text("$900");
doc.add_paragraph("Next Steps:").style("Heading2");
doc.add_numbered_list_item("Review Q2 financials", 0);
doc.add_numbered_list_item("Prepare Q3 forecast", 0);
doc.add_numbered_list_item("Gather input from teams", 1);
doc.add_numbered_list_item("Consolidate data", 1);
doc.add_numbered_list_item("Submit report", 0);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.table_count(), 1);
let tables = doc2.tables();
assert_eq!(tables[0].row_count(), 3);
assert_eq!(tables[0].column_count(), 3);
assert!(tables[0].row(0).unwrap().is_header());
assert_eq!(tables[0].cell(1, 1).unwrap().text(), "$1,200");
assert_eq!(
doc2.header_text(),
Some("Phase 3 Test Document".to_string())
);
assert_eq!(doc2.footer_text(), Some("Confidential".to_string()));
assert!(doc2.paragraph_count() > 10);
}
#[test]
fn metadata_round_trip() {
let mut doc = Document::new();
doc.set_title("Test Title");
doc.set_author("Test Author");
doc.set_subject("Test Subject");
doc.set_keywords("rust, docx, test");
assert_eq!(doc.title(), Some("Test Title"));
assert_eq!(doc.author(), Some("Test Author"));
assert_eq!(doc.subject(), Some("Test Subject"));
assert_eq!(doc.keywords(), Some("rust, docx, test"));
let bytes = doc.to_bytes().unwrap();
let package = OpcPackage::from_reader(std::io::Cursor::new(&bytes)).unwrap();
let relationship = package
.package_rels
.get_by_type(rel_types::CORE_PROPERTIES)
.unwrap();
assert_eq!(relationship.target, "docProps/core.xml");
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.title(), Some("Test Title"));
assert_eq!(doc2.author(), Some("Test Author"));
assert_eq!(doc2.subject(), Some("Test Subject"));
assert_eq!(doc2.keywords(), Some("rust, docx, test"));
}
#[test]
fn core_properties_at_relationship_target_round_trip_in_place() {
let mut source = Document::new();
source.set_title("Original title");
source.set_author("Original author");
let bytes = source.to_bytes().unwrap();
let mut package = OpcPackage::from_reader(std::io::Cursor::new(bytes)).unwrap();
let core_xml = package.parts.remove("/docProps/core.xml").unwrap();
package.set_part("/custom/metadata.xml", core_xml);
package.content_types.overrides.remove("/docProps/core.xml");
package.content_types.add_override(
"/custom/metadata.xml",
"application/vnd.openxmlformats-package.core-properties+xml",
);
package
.package_rels
.items
.retain(|rel| rel.rel_type != rel_types::CORE_PROPERTIES);
package
.package_rels
.add(rel_types::CORE_PROPERTIES, "custom/metadata.xml");
let mut custom_package = std::io::Cursor::new(Vec::new());
package.write_to(&mut custom_package).unwrap();
let mut document = Document::from_bytes(custom_package.get_ref()).unwrap();
assert_eq!(document.title(), Some("Original title"));
assert_eq!(document.author(), Some("Original author"));
document.set_title("Updated title");
let saved = document.to_bytes().unwrap();
let saved_package = OpcPackage::from_reader(std::io::Cursor::new(&saved)).unwrap();
assert!(saved_package.get_part("/docProps/core.xml").is_none());
assert!(saved_package.get_part("/custom/metadata.xml").is_some());
let relationship = saved_package
.package_rels
.get_by_type(rel_types::CORE_PROPERTIES)
.unwrap();
assert_eq!(relationship.target, "custom/metadata.xml");
let reopened = Document::from_bytes(&saved).unwrap();
assert_eq!(reopened.title(), Some("Updated title"));
assert_eq!(reopened.author(), Some("Original author"));
}
#[test]
fn three_comments_and_cross_paragraph_anchors_round_trip_byte_identically() {
let mut source = Document::new();
source.add_paragraph("first");
source.add_paragraph("second");
let bytes = source.to_bytes().unwrap();
let mut package = OpcPackage::from_reader(std::io::Cursor::new(bytes)).unwrap();
let document_xml = package.get_part("/word/document.xml").unwrap();
let document_xml = String::from_utf8(document_xml.to_vec())
.unwrap()
.replace(
"<w:p><w:r><w:t>first</w:t></w:r></w:p><w:p><w:r><w:t>second</w:t></w:r></w:p>",
"<w:p><w:commentRangeStart w:id=\"1\"/><w:r><w:t>first</w:t></w:r><w:commentRangeStart w:id=\"2\"/><w:commentRangeEnd w:id=\"2\"/><w:r><w:commentReference w:id=\"2\"/></w:r></w:p><w:p><w:r><w:t>second</w:t></w:r><w:commentRangeEnd w:id=\"1\"/><w:r><w:commentReference w:id=\"1\"/></w:r><w:commentRangeStart w:id=\"3\"/><w:commentRangeEnd w:id=\"3\"/><w:r><w:commentReference w:id=\"3\"/></w:r></w:p>",
);
package.set_part("/word/document.xml", document_xml.as_bytes().to_vec());
let comments_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:comment w:author="Ada" w:id="1"><w:p><w:r><w:t>one</w:t></w:r></w:p></w:comment><w:comment w:author="Ben" w:id="2"><w:p><w:r><w:t>two</w:t></w:r></w:p></w:comment><w:comment w:author="Cy" w:id="3"><w:p><w:r><w:t>three</w:t></w:r></w:p></w:comment></w:comments>"#;
package.set_part("/word/comments.xml", comments_xml.to_vec());
package.content_types.add_override(
"/word/comments.xml",
"application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
);
package
.get_or_create_part_rels("/word/document.xml")
.add(rel_types::COMMENTS, "comments.xml");
let mut input = std::io::Cursor::new(Vec::new());
package.write_to(&mut input).unwrap();
let mut document = Document::from_bytes(input.get_ref()).unwrap();
let saved = document.to_bytes().unwrap();
let saved_package = OpcPackage::from_reader(std::io::Cursor::new(saved)).unwrap();
assert_eq!(
saved_package.get_part("/word/comments.xml"),
Some(comments_xml.as_slice())
);
assert_eq!(
saved_package.get_part("/word/document.xml"),
Some(document_xml.as_bytes())
);
}
#[test]
fn comments_part_uses_its_existing_relationship_target() {
let mut source = Document::new();
source.add_paragraph("body");
let bytes = source.to_bytes().unwrap();
let mut package = OpcPackage::from_reader(std::io::Cursor::new(bytes)).unwrap();
let comments_xml = br#"<x:comments xmlns:x="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><x:comment x:id="9"><x:p><x:r><x:t>custom target</x:t></x:r></x:p></x:comment></x:comments>"#;
package.set_part("/custom/comments-data.xml", comments_xml.to_vec());
package.content_types.add_override(
"/custom/comments-data.xml",
"application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
);
package
.get_or_create_part_rels("/word/document.xml")
.add(rel_types::COMMENTS, "../custom/comments-data.xml");
let mut input = std::io::Cursor::new(Vec::new());
package.write_to(&mut input).unwrap();
let mut document = Document::from_bytes(input.get_ref()).unwrap();
let saved = document.to_bytes().unwrap();
let saved_package = OpcPackage::from_reader(std::io::Cursor::new(saved)).unwrap();
assert!(saved_package.get_part("/word/comments.xml").is_none());
let output = String::from_utf8(
saved_package
.get_part("/custom/comments-data.xml")
.unwrap()
.to_vec(),
)
.unwrap();
assert!(output.contains("<w:comments"));
assert!(output.contains("custom target"));
}
#[test]
fn saving_without_comments_does_not_manufacture_a_comments_part() {
let mut document = Document::new();
document.add_paragraph("No review thread");
let saved = document.to_bytes().unwrap();
let package = OpcPackage::from_reader(std::io::Cursor::new(saved)).unwrap();
assert!(package.get_part("/word/comments.xml").is_none());
assert!(
!package
.content_types
.overrides
.contains_key("/word/comments.xml")
);
assert!(
package
.get_part_rels("/word/document.xml")
.and_then(|rels| rels.get_by_type(rel_types::COMMENTS))
.is_none()
);
}
#[test]
fn comment_parts_relationships_and_content_types_are_word_compatible() {
let mut document = Document::new();
document.add_paragraph("review this");
let id = document
.add_comment(
RunRange {
start: RunPosition {
body_index: 0,
run_index: 0,
},
end: RunPosition {
body_index: 0,
run_index: 1,
},
},
"Ada",
Some("AL"),
"Please review",
)
.unwrap();
document.reply_to(id, "Ben", "Done").unwrap();
document.resolve_comment(id, true).unwrap();
let bytes = document.to_bytes().unwrap();
let package = OpcPackage::from_reader(std::io::Cursor::new(bytes)).unwrap();
let relationships = package.get_part_rels("/word/document.xml").unwrap();
let comments = relationships.get_by_type(rel_types::COMMENTS).unwrap();
let extended = relationships
.get_by_type("http://schemas.microsoft.com/office/2011/relationships/commentsExtended")
.unwrap();
let comments_part = OpcPackage::resolve_rel_target("/word/document.xml", &comments.target);
let extended_part = OpcPackage::resolve_rel_target("/word/document.xml", &extended.target);
assert_eq!(
package.content_types.content_type_for(&comments_part),
Some("application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml")
);
assert_eq!(
package.content_types.content_type_for(&extended_part),
Some("application/vnd.ms-word.commentsExtended+xml")
);
let comments_xml =
String::from_utf8(package.get_part(&comments_part).unwrap().to_vec()).unwrap();
let extended_xml =
String::from_utf8(package.get_part(&extended_part).unwrap().to_vec()).unwrap();
assert!(
comments_xml.contains("xmlns:w14=\"http://schemas.microsoft.com/office/word/2010/wordml\"")
);
assert!(comments_xml.contains("w14:paraId="));
assert!(
extended_xml.contains("xmlns:w15=\"http://schemas.microsoft.com/office/word/2012/wordml\"")
);
assert!(extended_xml.contains("w15:paraIdParent="));
assert!(extended_xml.contains("w15:done=\"1\""));
}
#[test]
fn comments_extended_part_uses_its_existing_relationship_target() {
const COMMENTS_EXTENDED_REL_TYPE: &str =
"http://schemas.microsoft.com/office/2011/relationships/commentsExtended";
const COMMENTS_EXTENDED_CONTENT_TYPE: &str = "application/vnd.ms-word.commentsExtended+xml";
let mut document = Document::new();
document.add_paragraph("review this");
let id = document
.add_comment(
RunRange {
start: RunPosition {
body_index: 0,
run_index: 0,
},
end: RunPosition {
body_index: 0,
run_index: 1,
},
},
"Ada",
None,
"Review",
)
.unwrap();
let bytes = document.to_bytes().unwrap();
let mut package = OpcPackage::from_reader(std::io::Cursor::new(bytes)).unwrap();
let extension = package.parts.remove("/word/commentsExtended.xml").unwrap();
package.set_part("/custom/thread-data.xml", extension);
package
.content_types
.overrides
.remove("/word/commentsExtended.xml");
package
.content_types
.add_override("/custom/thread-data.xml", COMMENTS_EXTENDED_CONTENT_TYPE);
package
.part_rels
.get_mut("/word/document.xml")
.unwrap()
.items
.iter_mut()
.find(|relationship| relationship.rel_type == COMMENTS_EXTENDED_REL_TYPE)
.unwrap()
.target = "../custom/thread-data.xml".to_owned();
let mut seeded = std::io::Cursor::new(Vec::new());
package.write_to(&mut seeded).unwrap();
let mut reopened = Document::from_bytes(seeded.get_ref()).unwrap();
assert!(reopened.resolve_comment(id, true).unwrap());
let saved = reopened.to_bytes().unwrap();
let saved_package = OpcPackage::from_reader(std::io::Cursor::new(saved)).unwrap();
assert!(
saved_package
.get_part("/word/commentsExtended.xml")
.is_none()
);
assert!(saved_package.get_part("/custom/thread-data.xml").is_some());
let relationship = saved_package
.get_part_rels("/word/document.xml")
.and_then(|relationships| relationships.get_by_type(COMMENTS_EXTENDED_REL_TYPE))
.unwrap();
assert_eq!(relationship.target, "../custom/thread-data.xml");
}
#[test]
fn nested_table_round_trip() {
let mut doc = Document::new();
let mut tbl = doc.add_table(2, 2);
tbl.cell(0, 0).unwrap().set_text("Outer A1");
tbl.cell(0, 1).unwrap().set_text("Outer A2");
tbl.cell(1, 1).unwrap().set_text("Outer B2");
let mut cell_b1 = tbl.cell(1, 0).unwrap();
cell_b1.set_text("Before nested");
let mut nested = cell_b1.add_table(2, 1);
nested.cell(0, 0).unwrap().set_text("Inner R1");
nested.cell(1, 0).unwrap().set_text("Inner R2");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.table_count(), 1);
let tables = doc2.tables();
assert!(!tables.is_empty());
let tbl2 = &tables[0];
assert_eq!(tbl2.row_count(), 2);
assert_eq!(tbl2.cell(0, 0).unwrap().text(), "Outer A1");
assert_eq!(tbl2.cell(0, 1).unwrap().text(), "Outer A2");
assert_eq!(tbl2.cell(1, 1).unwrap().text(), "Outer B2");
let cell_b1_ref = tbl2.cell(1, 0).unwrap();
assert_eq!(cell_b1_ref.text(), "Before nested");
}
#[test]
fn comprehensive_document_round_trip_with_nested() {
use rdocx::paragraph::Alignment;
let mut doc = Document::new();
doc.set_title("Comprehensive Test Document");
doc.set_author("rdocx Test Suite");
doc.add_paragraph("Chapter 1: Introduction")
.style("Heading1");
doc.add_paragraph("This is a normal paragraph with some body text.")
.alignment(Alignment::Left);
doc.add_paragraph("This paragraph is centered for emphasis.")
.alignment(Alignment::Center);
doc.add_paragraph("This paragraph is justified for a clean look.")
.alignment(Alignment::Justify);
doc.add_paragraph("Section 1.1: Data Table")
.style("Heading2");
let mut tbl = doc.add_table(3, 3);
tbl = tbl.borders(rdocx::BorderStyle::Single, 4, "000000");
tbl.row(0).unwrap().header();
tbl.cell(0, 0).unwrap().set_text("Name");
tbl.cell(0, 1).unwrap().set_text("Value");
tbl.cell(0, 2).unwrap().set_text("Status");
tbl.cell(1, 0).unwrap().set_text("Alpha");
tbl.cell(1, 1).unwrap().set_text("100");
tbl.cell(1, 2).unwrap().set_text("Active");
tbl.cell(2, 0).unwrap().set_text("Beta");
tbl.cell(2, 1).unwrap().set_text("200");
tbl.cell(2, 2).unwrap().set_text("Pending");
doc.add_paragraph("Chapter 2: Nested Content")
.style("Heading1");
let mut tbl2 = doc.add_table(2, 2);
tbl2.cell(0, 0).unwrap().set_text("Outer cell");
tbl2.cell(0, 1).unwrap().set_text("Another outer cell");
tbl2.cell(1, 0).unwrap().set_text("Simple cell");
let mut nested_cell = tbl2.cell(1, 1).unwrap();
nested_cell.set_text("Contains nested table:");
let mut nested = nested_cell.add_table(2, 2);
nested.cell(0, 0).unwrap().set_text("N1");
nested.cell(0, 1).unwrap().set_text("N2");
nested.cell(1, 0).unwrap().set_text("N3");
nested.cell(1, 1).unwrap().set_text("N4");
doc.add_paragraph("Chapter 3: Lists").style("Heading1");
doc.add_paragraph("First bullet point").style("ListBullet");
doc.add_paragraph("Second bullet point").style("ListBullet");
doc.add_paragraph("Third bullet point").style("ListBullet");
doc.add_paragraph("End of document.");
let docx_bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&docx_bytes).unwrap();
assert_eq!(doc2.title(), Some("Comprehensive Test Document"));
assert_eq!(doc2.author(), Some("rdocx Test Suite"));
assert!(doc2.table_count() >= 2);
}
#[test]
fn template_open_replace_save() {
let mut template = Document::new();
template.set_header("Company: {{company}}");
template.set_footer("Date: {{date}}");
template.add_paragraph("Dear {{name}},").style("Heading1");
template.add_paragraph("Welcome to {{company}}. Your role starts on {{date}}.");
template.add_paragraph("{{INSERT_CONTENT}}");
template.add_paragraph("Best regards,");
template.add_paragraph("HR Department");
let template_bytes = template.to_bytes().unwrap();
let mut doc = Document::from_bytes(&template_bytes).unwrap();
doc.replace_text("{{company}}", "Acme Corp");
doc.replace_text("{{name}}", "Alice");
doc.replace_text("{{date}}", "2026-03-01");
if let Some(idx) = doc.find_content_index("{{INSERT_CONTENT}}") {
doc.remove_content(idx);
doc.insert_paragraph(idx, "Your onboarding schedule is attached.");
doc.insert_paragraph(idx + 1, "Please review and confirm.");
}
let paras = doc.paragraphs();
assert_eq!(paras[0].text(), "Dear Alice,");
assert_eq!(
paras[1].text(),
"Welcome to Acme Corp. Your role starts on 2026-03-01."
);
assert_eq!(paras[2].text(), "Your onboarding schedule is attached.");
assert_eq!(paras[3].text(), "Please review and confirm.");
assert_eq!(paras[4].text(), "Best regards,");
assert_eq!(doc.header_text().unwrap(), "Company: Acme Corp");
assert_eq!(doc.footer_text().unwrap(), "Date: 2026-03-01");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraphs()[0].text(), "Dear Alice,");
}
#[test]
fn replace_all_batch_workflow() {
let mut doc = Document::new();
doc.add_paragraph("{{a}} {{b}} {{c}}");
let mut map = std::collections::HashMap::new();
map.insert("{{a}}", "X");
map.insert("{{b}}", "Y");
map.insert("{{c}}", "Z");
let count = doc.replace_all(&map);
assert_eq!(count, 3);
assert_eq!(doc.paragraphs()[0].text(), "X Y Z");
}
#[test]
fn background_image_end_to_end() {
let png_data: Vec<u8> = vec![
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8,
0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00,
0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
let mut doc = Document::new();
doc.add_paragraph("Page content here");
doc.add_background_image(&png_data, "background.png");
assert_eq!(doc.content_count(), 2);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.content_count(), 2);
}
#[test]
fn full_phase7_workflow() {
let png_data: Vec<u8> = vec![
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8,
0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00,
0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
let mut doc = Document::new();
doc.set_title("Template Output");
doc.set_header("{{company}} - Confidential");
doc.add_paragraph("Report for {{company}}")
.style("Heading1");
doc.add_paragraph("Date: {{date}}");
doc.add_paragraph("{{INSERT_HERE}}");
doc.add_paragraph("Summary: {{company}} performed well in {{date}}.");
doc.add_background_image(&png_data, "bg.png");
doc.replace_text("{{company}}", "Acme Corp");
doc.replace_text("{{date}}", "2026-02-22");
if let Some(idx) = doc.find_content_index("{{INSERT_HERE}}") {
doc.remove_content(idx);
doc.insert_paragraph(idx, "Revenue increased by 15%.");
}
let paras = doc.paragraphs();
let text_paras: Vec<_> = paras.iter().filter(|p| !p.text().is_empty()).collect();
assert!(
text_paras
.iter()
.any(|p| p.text() == "Report for Acme Corp")
);
assert!(text_paras.iter().any(|p| p.text() == "Date: 2026-02-22"));
assert!(
text_paras
.iter()
.any(|p| p.text() == "Revenue increased by 15%.")
);
assert!(
text_paras
.iter()
.any(|p| p.text() == "Summary: Acme Corp performed well in 2026-02-22.")
);
assert_eq!(doc.header_text().unwrap(), "Acme Corp - Confidential");
let bytes = doc.to_bytes().unwrap();
assert!(!bytes.is_empty());
}
#[test]
fn section_break_builders_round_trip() {
let mut doc = Document::new();
doc.add_paragraph("First section");
doc.add_paragraph("Landscape section break")
.section_break(SectionBreak::NextPage)
.section_landscape();
doc.add_paragraph("In landscape section");
doc.add_paragraph("Portrait section break")
.section_break(SectionBreak::NextPage)
.section_portrait();
doc.add_paragraph("Back to portrait");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraph_count(), 5);
}
#[test]
fn section_page_size_custom() {
let mut doc = Document::new();
doc.add_paragraph("Small page")
.section_break(SectionBreak::NextPage)
.section_page_size(Length::inches(6.0), Length::inches(9.0));
doc.add_paragraph("On the next section");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraph_count(), 2);
}
#[test]
fn section_break_continuous() {
let mut doc = Document::new();
doc.add_paragraph("Before continuous break")
.section_break(SectionBreak::Continuous);
doc.add_paragraph("After continuous break");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraph_count(), 2);
}
#[test]
fn tab_stops_all_leader_styles() {
let mut doc = Document::new();
doc.add_paragraph("Left\tRight")
.add_tab_stop(TabAlignment::Right, Length::inches(6.0));
doc.add_paragraph("Item\t100").add_tab_stop_with_leader(
TabAlignment::Right,
Length::inches(6.0),
TabLeader::Dot,
);
doc.add_paragraph("Section\tPage 5")
.add_tab_stop_with_leader(TabAlignment::Right, Length::inches(6.0), TabLeader::Hyphen);
doc.add_paragraph("Name\t").add_tab_stop_with_leader(
TabAlignment::Right,
Length::inches(6.0),
TabLeader::Underscore,
);
doc.add_paragraph("A\tB\tC")
.add_tab_stop(TabAlignment::Center, Length::inches(3.0))
.add_tab_stop(TabAlignment::Right, Length::inches(6.0))
.add_tab_stop(TabAlignment::Decimal, Length::inches(4.5));
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
assert_eq!(paras[0].tab_stop_count(), 1);
assert_eq!(paras[1].tab_stop_count(), 1);
assert_eq!(paras[2].tab_stop_count(), 1);
assert_eq!(paras[3].tab_stop_count(), 1);
assert_eq!(paras[4].tab_stop_count(), 3);
}
#[test]
fn run_formatting_all_caps_small_caps() {
let mut doc = Document::new();
let mut para = doc.add_paragraph("");
para.add_run("UPPERCASE").all_caps(true);
para.add_run("SmallCaps").small_caps(true);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
assert_eq!(paras[0].text(), "UPPERCASESmallCaps");
}
#[test]
fn run_formatting_double_strike_and_spacing() {
let mut doc = Document::new();
let mut para = doc.add_paragraph("");
para.add_run("DStrike").double_strike(true);
para.add_run("Spaced").character_spacing(Length::pt(3.0));
para.add_run("Super").superscript();
para.add_run("Sub").subscript();
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
let runs: Vec<_> = paras[0].runs().collect();
assert_eq!(runs.len(), 4);
assert_eq!(runs[2].vert_align(), Some("superscript"));
assert_eq!(runs[3].vert_align(), Some("subscript"));
assert!(runs[1].character_spacing().is_some());
}
#[test]
fn paragraph_border_bottom_only() {
let mut doc = Document::new();
doc.add_paragraph("Bottom bordered")
.border_bottom(BorderStyle::Single, 4, "000000");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
assert!(paras[0].has_borders());
}
#[test]
fn paragraph_shading_and_indent_combined() {
let mut doc = Document::new();
doc.add_paragraph("Shaded and indented")
.shading("E0E0E0")
.indent_left(Length::inches(0.75))
.indent_right(Length::inches(0.5))
.hanging_indent(Length::inches(0.25));
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
assert_eq!(paras[0].shading_fill(), Some("E0E0E0"));
assert_eq!(paras[0].text(), "Shaded and indented");
}
#[test]
fn document_header_footer_first_page() {
let mut doc = Document::new();
doc.set_header("Default Header");
doc.set_footer("Default Footer");
doc.set_first_page_header("First Page Header");
doc.set_first_page_footer("First Page Footer");
doc.add_paragraph("Body content");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.header_text(), Some("Default Header".to_string()));
assert_eq!(doc2.footer_text(), Some("Default Footer".to_string()));
let sect = doc2.section_properties().unwrap();
assert_eq!(sect.title_pg, Some(true));
}
#[test]
fn insert_paragraph_at_beginning_and_end() {
let mut doc = Document::new();
doc.add_paragraph("Middle");
doc.insert_paragraph(0, "First");
let count = doc.content_count();
doc.insert_paragraph(count, "Last");
assert_eq!(doc.content_count(), 3);
let paras = doc.paragraphs();
assert_eq!(paras[0].text(), "First");
assert_eq!(paras[1].text(), "Middle");
assert_eq!(paras[2].text(), "Last");
}
#[test]
fn insert_table_at_index() {
let mut doc = Document::new();
doc.add_paragraph("Before");
doc.add_paragraph("After");
let mut table = doc.insert_table(1, 2, 2);
table.cell(0, 0).unwrap().set_text("Cell");
assert_eq!(doc.content_count(), 3);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.table_count(), 1);
assert_eq!(doc2.paragraph_count(), 2);
}
#[test]
fn remove_content_basic() {
let mut doc = Document::new();
doc.add_paragraph("Keep");
doc.add_paragraph("Remove");
doc.add_paragraph("Keep too");
assert_eq!(doc.content_count(), 3);
assert!(doc.remove_content(1));
assert_eq!(doc.content_count(), 2);
assert_eq!(doc.paragraphs()[0].text(), "Keep");
assert_eq!(doc.paragraphs()[1].text(), "Keep too");
}
#[test]
fn remove_content_out_of_bounds() {
let mut doc = Document::new();
doc.add_paragraph("Only");
assert!(!doc.remove_content(5));
assert_eq!(doc.content_count(), 1);
}
#[test]
fn find_content_index_returns_none_for_missing() {
let mut doc = Document::new();
doc.add_paragraph("Hello");
assert_eq!(doc.find_content_index("nonexistent"), None);
}
#[test]
fn section_break_round_trip_preserves() {
let mut doc = Document::new();
doc.add_paragraph("Section 1")
.section_break(SectionBreak::NextPage)
.section_landscape();
doc.add_paragraph("Section 2 (landscape)");
doc.add_paragraph("Section 2 end")
.section_break(SectionBreak::NextPage)
.section_portrait();
doc.add_paragraph("Section 3 (portrait)");
let bytes = doc.to_bytes().unwrap();
let mut doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraph_count(), 4);
let paras = doc2.paragraphs();
assert_eq!(paras[0].text(), "Section 1");
assert_eq!(paras[1].text(), "Section 2 (landscape)");
assert_eq!(paras[2].text(), "Section 2 end");
assert_eq!(paras[3].text(), "Section 3 (portrait)");
let bytes2 = doc2.to_bytes().unwrap();
let doc3 = Document::from_bytes(&bytes2).unwrap();
assert_eq!(doc3.paragraph_count(), 4);
}
#[test]
fn empty_document_insert_and_remove() {
let mut doc = Document::new();
assert_eq!(doc.content_count(), 0);
doc.insert_paragraph(0, "Inserted");
assert_eq!(doc.content_count(), 1);
assert_eq!(doc.paragraphs()[0].text(), "Inserted");
assert!(doc.remove_content(0));
assert_eq!(doc.content_count(), 0);
}
#[test]
fn to_pdf_simple_document() {
let mut doc = Document::new();
doc.add_paragraph("Hello, World!");
doc.add_paragraph("This is a test document.");
let result = doc.to_pdf();
if let Ok(pdf_bytes) = result {
assert!(pdf_bytes.starts_with(b"%PDF"));
assert!(pdf_bytes.len() > 100);
let tail = String::from_utf8_lossy(&pdf_bytes[pdf_bytes.len().saturating_sub(10)..]);
assert!(tail.contains("%%EOF"));
}
}
#[test]
fn to_pdf_with_formatting() {
let mut doc = Document::new();
doc.add_paragraph("Title")
.style("Heading1")
.alignment(Alignment::Center);
doc.add_paragraph("Normal text with ")
.add_run("bold")
.bold(true);
doc.add_paragraph("Another paragraph");
let result = doc.to_pdf();
if let Ok(pdf_bytes) = result {
assert!(pdf_bytes.starts_with(b"%PDF"));
assert!(pdf_bytes.len() > 200);
}
}
#[test]
fn to_pdf_with_table() {
let mut doc = Document::new();
doc.add_paragraph("Table test");
{
let mut table = doc.add_table(2, 3);
table.cell(0, 0).unwrap().set_text("A1");
table.cell(0, 1).unwrap().set_text("B1");
table.cell(0, 2).unwrap().set_text("C1");
table.cell(1, 0).unwrap().set_text("A2");
table.cell(1, 1).unwrap().set_text("B2");
table.cell(1, 2).unwrap().set_text("C2");
}
doc.add_paragraph("After table");
let result = doc.to_pdf();
if let Ok(pdf_bytes) = result {
assert!(pdf_bytes.starts_with(b"%PDF"));
}
}
#[test]
fn to_pdf_with_metadata() {
let mut doc = Document::new();
doc.set_title("Test Document");
doc.set_author("rdocx");
doc.add_paragraph("Content");
let result = doc.to_pdf();
if let Ok(pdf_bytes) = result {
assert!(pdf_bytes.starts_with(b"%PDF"));
let pdf_str = String::from_utf8_lossy(&pdf_bytes);
assert!(pdf_str.contains("Test Document") || pdf_str.contains("rdocx-pdf"));
}
}
#[test]
fn save_pdf_to_file() {
let mut doc = Document::new();
doc.add_paragraph("PDF file test");
let path = std::env::temp_dir().join(format!("rdocx_test_output_{}.pdf", std::process::id()));
let result = doc.save_pdf(&path);
if result.is_ok() {
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(b"%PDF"));
std::fs::remove_file(&path).ok();
}
let tracked_path = std::env::temp_dir().join(format!(
"rdocx_test_tracked_output_{}.pdf",
std::process::id()
));
let result = doc.save_pdf_with_options(
&tracked_path,
rdocx::RenderOptions {
revision_view: rdocx::RevisionView::Tracked,
},
);
if result.is_ok() {
let bytes = std::fs::read(&tracked_path).unwrap();
assert!(bytes.starts_with(b"%PDF"));
std::fs::remove_file(&tracked_path).ok();
}
}
#[test]
fn line_spacing_multiple_round_trips() {
let mut doc = Document::new();
doc.add_paragraph("double spaced")
.line_spacing_multiple(2.0);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
let p = paras.first().unwrap();
assert_eq!(p.line_spacing_multiple(), Some(2.0));
}
#[test]
fn non_consuming_setters_mutate_borrowed_wrappers() {
let mut doc = Document::new();
doc.add_paragraph("Borrowed paragraph");
doc.paragraph_mut(0)
.unwrap()
.set_alignment(Alignment::Right);
doc.paragraph_mut(0)
.unwrap()
.add_run(" borrowed run")
.set_bold(true);
{
let mut table = doc.add_table(1, 1);
table.set_layout_fixed();
table.row(0).unwrap().set_header();
table
.cell(0, 0)
.unwrap()
.set_vertical_alignment(VerticalAlignment::Center);
}
let bytes = doc.to_bytes().unwrap();
let reopened = Document::from_bytes(&bytes).unwrap();
let paragraphs = reopened.paragraphs();
assert_eq!(paragraphs[0].alignment(), Some(Alignment::Right));
assert!(paragraphs[0].runs().last().unwrap().is_bold());
let tables = reopened.tables();
assert!(tables[0].row(0).unwrap().is_header());
assert_eq!(
tables[0].cell(0, 0).unwrap().vertical_alignment(),
Some(VerticalAlignment::Center)
);
}
#[test]
fn non_consuming_setters_match_consuming_builders() {
let mut builders = Document::new();
{
let mut paragraph = builders
.add_paragraph("Paragraph")
.alignment(Alignment::Center)
.space_after(Length::pt(6.0));
paragraph
.add_run(" run")
.bold(true)
.font("Arial")
.color("123456");
}
{
let mut table = builders
.add_table(1, 1)
.style("TableGrid")
.alignment(Alignment::Center)
.layout_fixed();
table.row(0).unwrap().height(Length::pt(18.0)).header();
table
.cell(0, 0)
.unwrap()
.width(Length::inches(2.0))
.shading("D9EAF7")
.vertical_alignment(VerticalAlignment::Center);
}
let mut setters = Document::new();
{
let mut paragraph = setters.add_paragraph("Paragraph");
paragraph.set_alignment(Alignment::Center);
paragraph.set_space_after(Length::pt(6.0));
let mut run = paragraph.add_run(" run");
run.set_bold(true);
run.set_font("Arial");
run.set_color("123456");
}
{
let mut table = setters.add_table(1, 1);
table.set_style("TableGrid");
table.set_alignment(Alignment::Center);
table.set_layout_fixed();
let mut row = table.row(0).unwrap();
row.set_height(Length::pt(18.0));
row.set_header();
let mut cell = row.cell(0).unwrap();
cell.set_width(Length::inches(2.0));
cell.set_shading("D9EAF7");
cell.set_vertical_alignment(VerticalAlignment::Center);
}
assert_eq!(document_xml(&mut setters), document_xml(&mut builders));
}
#[test]
fn direct_facade_accessors_are_total() {
let mut doc = Document::new();
doc.add_paragraph("first").add_run(" run");
assert_eq!(doc.paragraph(0).unwrap().text(), "first run");
assert!(doc.paragraph(1).is_none());
let mut paragraph = doc.paragraph_mut(0).unwrap();
assert_eq!(paragraph.run_count(), 2);
assert_eq!(paragraph.run(1).unwrap().text(), " run");
assert!(paragraph.run(2).is_none());
paragraph.run_mut(1).unwrap().set_text(" changed");
assert!(paragraph.run_mut(2).is_none());
assert_eq!(doc.paragraph(0).unwrap().text(), "first changed");
}
#[test]
fn immutable_run_accessors_are_total() {
let mut doc = Document::new();
doc.add_paragraph("first").add_run(" run");
let paragraph = doc.paragraph(0).unwrap();
assert_eq!(paragraph.run_count(), 2);
assert_eq!(paragraph.run(1).unwrap().text(), " run");
assert!(paragraph.run(2).is_none());
}
mod header_footer_pdf {
use std::collections::{BTreeMap, BTreeSet, HashMap};
use super::*;
use oxml_layout::{PageFrame, PositionedElement, walk};
use rdocx_oxml::header_footer::HdrFtrType;
const DEFAULT_HEADER: &str = "DEFAULTHEADER";
const DEFAULT_FOOTER: &str = "DEFAULTFOOTER";
const FIRST_HEADER: &str = "FIRSTHEADER";
const FIRST_FOOTER: &str = "FIRSTFOOTER";
const EVEN_HEADER: &str = "EVENHEADER";
const EVEN_FOOTER: &str = "EVENFOOTER";
const STORY_MARKERS: [&str; 6] = [
DEFAULT_HEADER,
DEFAULT_FOOTER,
FIRST_HEADER,
FIRST_FOOTER,
EVEN_HEADER,
EVEN_FOOTER,
];
const PRODUCER_PART: &[u8] = b"producer-private-bytes\x00\xff";
const PRODUCER_REL_TYPE: &str = "urn:producer:relationships/private-state";
const PRODUCER_XML: &[u8] = b"<x:opaque x:flag=\"keep\"><x:child/></x:opaque>";
struct Fixture {
document: Document,
saved: Vec<u8>,
}
struct PdfObject {
body: Vec<u8>,
stream: Option<Vec<u8>>,
}
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
fn parse_decimal(bytes: &[u8], start: usize) -> (usize, usize) {
let mut end = start;
while end < bytes.len() && bytes[end].is_ascii_digit() {
end += 1;
}
assert!(end > start, "expected decimal at byte {start}");
let value = std::str::from_utf8(&bytes[start..end])
.unwrap()
.parse()
.unwrap();
(value, end)
}
fn reference_after(bytes: &[u8], key: &[u8]) -> u32 {
let start = find_bytes(bytes, key)
.unwrap_or_else(|| panic!("missing PDF key {}", String::from_utf8_lossy(key)))
+ key.len();
let mut number_start = start;
while number_start < bytes.len() && bytes[number_start].is_ascii_whitespace() {
number_start += 1;
}
let (number, end) = parse_decimal(bytes, number_start);
assert!(bytes[end..].starts_with(b" 0 R"));
number as u32
}
fn references(bytes: &[u8]) -> Vec<u32> {
let mut refs = Vec::new();
let mut cursor = 0;
while cursor < bytes.len() {
let Some(relative) = find_bytes(&bytes[cursor..], b" 0 R") else {
break;
};
let marker = cursor + relative;
let mut start = marker;
while start > 0 && bytes[start - 1].is_ascii_digit() {
start -= 1;
}
if start < marker {
refs.push(parse_decimal(bytes, start).0 as u32);
}
cursor = marker + 4;
}
refs
}
fn structure_role(bytes: &[u8]) -> Option<&[u8]> {
let start = find_bytes(bytes, b"/S /")? + b"/S /".len();
let end = bytes[start..]
.iter()
.position(|byte| byte.is_ascii_whitespace())?;
Some(&bytes[start..start + end])
}
fn structure_children(bytes: &[u8]) -> Vec<u32> {
let Some(start) = find_bytes(bytes, b"/K [").map(|start| start + b"/K [".len()) else {
return Vec::new();
};
let end = start + find_bytes(&bytes[start..], b"]").expect("structure child terminator");
references(&bytes[start..end])
}
fn has_nested_list_depth(
objects: &BTreeMap<u32, PdfObject>,
list_ref: u32,
depth: usize,
) -> bool {
let list = &objects[&list_ref].body;
if structure_role(list) != Some(b"L") {
return false;
}
if depth == 1 {
return true;
}
structure_children(list)
.into_iter()
.filter(|child| structure_role(&objects[child].body) == Some(b"LI"))
.flat_map(|item| structure_children(&objects[&item].body))
.filter(|child| structure_role(&objects[child].body) == Some(b"LBody"))
.flat_map(|body| structure_children(&objects[&body].body))
.any(|child| has_nested_list_depth(objects, child, depth - 1))
}
fn number_tree_entries(bytes: &[u8]) -> Vec<(usize, u32)> {
let start = find_bytes(bytes, b"/Nums [").expect("parent number tree") + b"/Nums [".len();
let end = start + find_bytes(&bytes[start..], b"]").expect("parent number tree end");
let entries = &bytes[start..end];
let mut cursor = 0;
let mut result = Vec::new();
while cursor < entries.len() {
while cursor < entries.len() && entries[cursor].is_ascii_whitespace() {
cursor += 1;
}
if cursor == entries.len() {
break;
}
let (key, key_end) = parse_decimal(entries, cursor);
cursor = key_end;
while cursor < entries.len() && entries[cursor].is_ascii_whitespace() {
cursor += 1;
}
let (reference, reference_end) = parse_decimal(entries, cursor);
assert!(entries[reference_end..].starts_with(b" 0 R"));
result.push((key, reference as u32));
cursor = reference_end + b" 0 R".len();
}
result
}
fn marked_content_ids(bytes: &[u8]) -> Vec<usize> {
let mut result = Vec::new();
let mut cursor = 0;
while let Some(relative) = find_bytes(&bytes[cursor..], b"/MCID ") {
let start = cursor + relative + b"/MCID ".len();
let (mcid, end) = parse_decimal(bytes, start);
result.push(mcid);
cursor = end;
}
result
}
fn marked_content_references(bytes: &[u8]) -> Vec<(u32, usize)> {
let mut result = Vec::new();
let mut cursor = 0;
while let Some(relative) = find_bytes(&bytes[cursor..], b"/Type /MCR") {
let start = cursor + relative;
let body = &bytes[start..];
let page = reference_after(body, b"/Pg");
let mcid_start = find_bytes(body, b"/MCID ").expect("MCR MCID") + b"/MCID ".len();
let (mcid, _) = parse_decimal(body, mcid_start);
result.push((page, mcid));
cursor = start + b"/Type /MCR".len();
}
result
}
fn pdf_objects(pdf: &[u8]) -> BTreeMap<u32, PdfObject> {
let mut objects = BTreeMap::new();
let mut cursor = 0;
while let Some(relative_marker) = find_bytes(&pdf[cursor..], b" 0 obj") {
let marker = cursor + relative_marker;
let mut number_start = marker;
while number_start > 0 && pdf[number_start - 1].is_ascii_digit() {
number_start -= 1;
}
let number = parse_decimal(pdf, number_start).0 as u32;
let content_start = marker + b" 0 obj".len();
let endobj = content_start
+ find_bytes(&pdf[content_start..], b"endobj").expect("PDF object terminator");
let stream_marker = find_bytes(&pdf[content_start..endobj], b"\nstream\n")
.map(|relative| content_start + relative);
let (body, stream, object_end) = if let Some(stream_marker) = stream_marker {
let body = pdf[content_start..stream_marker].to_vec();
let length_start =
find_bytes(&body, b"/Length ").expect("PDF stream length") + b"/Length ".len();
let length = parse_decimal(&body, length_start).0;
let data_start = stream_marker + b"\nstream\n".len();
let data_end = data_start + length;
assert!(pdf[data_end..].starts_with(b"\nendstream"));
let object_end = data_end
+ find_bytes(&pdf[data_end..], b"endobj").expect("stream object terminator")
+ b"endobj".len();
(body, Some(pdf[data_start..data_end].to_vec()), object_end)
} else {
(
pdf[content_start..endobj].to_vec(),
None,
endobj + b"endobj".len(),
)
};
assert!(objects.insert(number, PdfObject { body, stream }).is_none());
cursor = object_end;
}
assert!(
!objects.is_empty(),
"deterministic renderer returned no PDF objects"
);
objects
}
fn decoded_stream(object: &PdfObject) -> Vec<u8> {
let stream = object.stream.as_ref().expect("expected a PDF stream");
if find_bytes(&object.body, b"/Filter /FlateDecode").is_some() {
miniz_oxide::inflate::decompress_to_vec_zlib(stream)
.expect("deterministic PDF stream should inflate")
} else {
stream.clone()
}
}
fn parse_hex(value: &[u8]) -> Vec<u8> {
assert!(value.len().is_multiple_of(2));
value
.chunks_exact(2)
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
.collect()
}
fn pdf_literal(line: &[u8], open: usize) -> (Vec<u8>, usize) {
let mut bytes = Vec::new();
let mut cursor = open + 1;
while cursor < line.len() {
match line[cursor] {
b')' => return (bytes, cursor + 1),
b'\\' => {
cursor += 1;
assert!(cursor < line.len(), "truncated PDF string escape");
let escaped = line[cursor];
if escaped.is_ascii_digit() && escaped < b'8' {
let mut value = 0u16;
let mut digits = 0;
while cursor < line.len()
&& digits < 3
&& line[cursor].is_ascii_digit()
&& line[cursor] < b'8'
{
value = value * 8 + u16::from(line[cursor] - b'0');
cursor += 1;
digits += 1;
}
bytes.push(value as u8);
continue;
}
bytes.push(match escaped {
b'n' => b'\n',
b'r' => b'\r',
b't' => b'\t',
b'b' => 8,
b'f' => 12,
other => other,
});
cursor += 1;
}
byte => {
bytes.push(byte);
cursor += 1;
}
}
}
panic!("unterminated PDF literal string")
}
fn cmap(object: &PdfObject) -> BTreeMap<u16, String> {
let bytes = decoded_stream(object);
let mut mappings = BTreeMap::new();
let mut in_bfchar = false;
for line in bytes.split(|byte| *byte == b'\n') {
if find_bytes(line, b"beginbfchar").is_some() {
in_bfchar = true;
continue;
}
if find_bytes(line, b"endbfchar").is_some() {
in_bfchar = false;
continue;
}
if !in_bfchar {
continue;
}
let fields = line
.split(|byte| byte.is_ascii_whitespace())
.collect::<Vec<_>>();
if fields.len() != 2 {
continue;
}
let glyph_bytes = parse_hex(&fields[0][1..fields[0].len() - 1]);
let unicode_bytes = parse_hex(&fields[1][1..fields[1].len() - 1]);
let glyph = u16::from_be_bytes([glyph_bytes[0], glyph_bytes[1]]);
let utf16 = unicode_bytes
.chunks_exact(2)
.map(|pair| u16::from_be_bytes([pair[0], pair[1]]))
.collect::<Vec<_>>();
let text = char::decode_utf16(utf16)
.collect::<Result<String, _>>()
.expect("valid ToUnicode mapping");
mappings.insert(glyph, text);
}
assert!(!mappings.is_empty(), "missing ToUnicode mappings");
mappings
}
fn font_references(page: &[u8]) -> HashMap<String, u32> {
let start = find_bytes(page, b"/Font <<").expect("page font resources") + b"/Font <<".len();
let end = start + find_bytes(&page[start..], b">>").expect("page font dictionary");
let resources = &page[start..end];
let mut fonts = HashMap::new();
let mut cursor = 0;
while let Some(relative) = find_bytes(&resources[cursor..], b"/F") {
let name_start = cursor + relative + 1;
let mut name_end = name_start + 1;
while name_end < resources.len() && resources[name_end].is_ascii_digit() {
name_end += 1;
}
if name_end == name_start + 1 {
cursor = name_end;
continue;
}
let name = std::str::from_utf8(&resources[name_start..name_end])
.unwrap()
.to_owned();
let mut ref_start = name_end;
while ref_start < resources.len() && resources[ref_start].is_ascii_whitespace() {
ref_start += 1;
}
fonts.insert(name, parse_decimal(resources, ref_start).0 as u32);
cursor = name_end;
}
assert!(!fonts.is_empty(), "page has no font resources");
fonts
}
fn content_text(content: &[u8], cmaps: &HashMap<String, BTreeMap<u16, String>>) -> String {
let mut current_font = None;
let mut text = String::new();
for line in content.split(|byte| *byte == b'\n') {
let line = line
.iter()
.copied()
.skip_while(|byte| byte.is_ascii_whitespace())
.collect::<Vec<_>>();
if line.ends_with(b" Tf") && line.starts_with(b"/F") {
let end = line
.iter()
.position(|byte| byte.is_ascii_whitespace())
.expect("font operand");
current_font = Some(String::from_utf8(line[1..end].to_vec()).unwrap());
continue;
}
if !line.ends_with(b" TJ") {
continue;
}
let font = current_font.as_ref().expect("TJ without selected font");
let mapping = cmaps.get(font).expect("ToUnicode map for selected font");
let mut cursor = 0;
while cursor < line.len() {
let hex = line[cursor..].iter().position(|byte| *byte == b'<');
let literal = line[cursor..].iter().position(|byte| *byte == b'(');
let (glyph_bytes, next) = match (hex, literal) {
(None, None) => break,
(Some(hex), Some(literal)) if literal < hex => {
pdf_literal(&line, cursor + literal)
}
(_, Some(literal)) if hex.is_none() => pdf_literal(&line, cursor + literal),
(Some(hex), _) => {
let open = cursor + hex;
let close = open
+ line[open..]
.iter()
.position(|byte| *byte == b'>')
.expect("hex glyph string");
(parse_hex(&line[open + 1..close]), close + 1)
}
_ => unreachable!(),
};
for pair in glyph_bytes.chunks_exact(2) {
let glyph = u16::from_be_bytes([pair[0], pair[1]]);
text.push_str(mapping.get(&glyph).expect("mapped PDF glyph"));
}
cursor = next;
}
}
text
}
fn pdf_page_text(pdf: &[u8]) -> Vec<String> {
let objects = pdf_objects(pdf);
let pages_object = objects
.values()
.find(|object| {
find_bytes(&object.body, b"/Type /Pages").is_some()
&& find_bytes(&object.body, b"/Kids [").is_some()
})
.expect("PDF page tree");
let kids_start = find_bytes(&pages_object.body, b"/Kids [").unwrap() + b"/Kids [".len();
let kids_end = kids_start
+ find_bytes(&pages_object.body[kids_start..], b"]").expect("page tree kids");
let page_refs = references(&pages_object.body[kids_start..kids_end]);
let first_page = &objects[&page_refs[0]].body;
let font_refs = font_references(first_page);
let cmaps = font_refs
.into_iter()
.map(|(name, font_ref)| {
let cmap_ref = reference_after(&objects[&font_ref].body, b"/ToUnicode");
(name, cmap(&objects[&cmap_ref]))
})
.collect::<HashMap<_, _>>();
page_refs
.into_iter()
.map(|page_ref| {
let content_ref = reference_after(&objects[&page_ref].body, b"/Contents");
content_text(&decoded_stream(&objects[&content_ref]), &cmaps)
})
.collect()
}
#[test]
fn word_semantics_reach_owned_multi_page_pdf_structure() {
fn count_bytes(haystack: &[u8], needle: &[u8]) -> usize {
haystack
.windows(needle.len())
.filter(|window| *window == needle)
.count()
}
let mut document = Document::new();
for level in 1..=6 {
document
.add_paragraph(&format!("Heading level {level}"))
.style(&format!("Heading{level}"));
}
document.add_bullet_list_item("Outer list item", 0);
document.add_bullet_list_item("Nested list item", 1);
document.add_bullet_list_item("Deeply nested list item", 2);
document.add_bullet_list_item("Second outer list item", 0);
{
let mut table = document.add_table(120, 2);
table.row(0).unwrap().header();
table.cell(0, 0).unwrap().set_text("Name");
table.cell(0, 1).unwrap().set_text("Value");
for row in 1..120 {
table.cell(row, 0).unwrap().set_text(&format!("Item {row}"));
table
.cell(row, 1)
.unwrap()
.set_text(&format!("Value {row}"));
}
}
let pdf = document
.to_pdf_deterministic()
.expect("Word document should render deterministically");
let objects = pdf_objects(&pdf);
let pages_object = objects
.values()
.find(|object| {
find_bytes(&object.body, b"/Type /Pages").is_some()
&& find_bytes(&object.body, b"/Kids [").is_some()
})
.expect("PDF page tree");
let kids_start = find_bytes(&pages_object.body, b"/Kids [").unwrap() + b"/Kids [".len();
let kids_end = kids_start
+ find_bytes(&pages_object.body[kids_start..], b"]").expect("page tree kids");
let page_refs = references(&pages_object.body[kids_start..kids_end]);
assert!(
page_refs.len() > 1,
"fixture must exercise repeated headers"
);
let structure_root = objects
.values()
.find(|object| find_bytes(&object.body, b"/Type /StructTreeRoot").is_some())
.expect("PDF structure root");
let parent_tree = number_tree_entries(&structure_root.body);
assert_eq!(parent_tree.len(), page_refs.len());
let all_mcrs = objects
.iter()
.flat_map(|(owner, object)| {
marked_content_references(&object.body)
.into_iter()
.map(|(page, mcid)| (*owner, page, mcid))
})
.collect::<Vec<_>>();
let mut expected_mcrs = BTreeSet::new();
let mut struct_parent_keys = BTreeSet::new();
for (page_index, page_ref) in page_refs.iter().copied().enumerate() {
let page = &objects[&page_ref].body;
let key_start = find_bytes(page, b"/StructParents ")
.expect("every content-owning page needs a parent-tree key")
+ b"/StructParents ".len();
let struct_parent = parse_decimal(page, key_start).0;
assert!(
struct_parent_keys.insert(struct_parent),
"StructParents keys must be page-unique"
);
assert_eq!(
struct_parent, page_index,
"deterministic StructParents keys follow page order"
);
let parent_array_ref = parent_tree
.iter()
.find_map(|(key, reference)| (*key == struct_parent).then_some(*reference))
.expect("StructParents key must resolve through ParentTree");
let parent_refs = references(&objects[&parent_array_ref].body);
let content_ref = reference_after(page, b"/Contents");
let content = decoded_stream(&objects[&content_ref]);
let content_mcids = marked_content_ids(&content);
assert_eq!(
content_mcids,
(0..content_mcids.len()).collect::<Vec<_>>(),
"MCIDs must be page-local and contiguous"
);
assert_eq!(
parent_refs.len(),
content_mcids.len(),
"ParentTree array slots must cover every page MCID"
);
for (mcid, owner) in parent_refs.into_iter().enumerate() {
assert!(expected_mcrs.insert((owner, page_ref, mcid)));
assert_eq!(
all_mcrs
.iter()
.filter(|candidate| **candidate == (owner, page_ref, mcid))
.count(),
1,
"each ParentTree slot must have one matching owner MCR"
);
}
}
assert_eq!(struct_parent_keys.len(), page_refs.len());
assert_eq!(
all_mcrs.len(),
expected_mcrs.len(),
"MCR dictionaries and ParentTree slots must have exact ownership"
);
assert!(
all_mcrs.iter().all(|entry| expected_mcrs.contains(entry)),
"every MCR must point to the matching page-local ParentTree slot"
);
let heading_positions = (1..=6)
.map(|level| {
let tag = format!("/S /H{level}\n");
find_bytes(&pdf, tag.as_bytes()).expect("heading structure role")
})
.collect::<Vec<_>>();
assert!(
heading_positions.windows(2).all(|pair| pair[0] < pair[1]),
"heading roles must preserve Word source order"
);
let list_refs = objects
.iter()
.filter_map(|(reference, object)| {
(structure_role(&object.body) == Some(b"L")).then_some(*reference)
})
.collect::<Vec<_>>();
assert!(
list_refs
.iter()
.any(|reference| has_nested_list_depth(&objects, *reference, 3)),
"Word levels 0, 1, and 2 must form one three-level list tree"
);
assert!(count_bytes(&pdf, b"/S /LI\n") >= 4, "list item nodes");
assert_eq!(count_bytes(&pdf, b"/S /TH\n"), 2, "logical header cells");
let header_owned_ranges = objects
.values()
.filter(|object| find_bytes(&object.body, b"/S /TH\n").is_some())
.map(|header| {
let kids_start =
find_bytes(&header.body, b"/K [").expect("TH child array") + b"/K [".len();
let kids_end = kids_start
+ find_bytes(&header.body[kids_start..], b"]").expect("TH child terminator");
let kids = references(&header.body[kids_start..kids_end]);
assert_eq!(kids.len(), 1, "TH should own one paragraph");
count_bytes(&objects[&kids[0]].body, b"/Type /MCR")
})
.sum::<usize>();
assert!(
header_owned_ranges > 2,
"repeated header paragraph paint should remain below its logical TH"
);
assert_eq!(
expected_mcrs.len(),
count_bytes(&pdf, b"/Type /MCR"),
"each semantic content range belongs to exactly one structure node"
);
}
fn header_footer_xml(root: &str, text: &str) -> Vec<u8> {
format!(
r#"<w:{root} xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:t>{text}</w:t></w:r></w:p></w:{root}>"#
)
.into_bytes()
}
fn relationship_id(package: &OpcPackage, rel_type: &str, part_name: &str) -> String {
package
.get_part_rels("/word/document.xml")
.unwrap()
.items
.iter()
.find(|relationship| {
relationship.rel_type == rel_type
&& OpcPackage::resolve_rel_target("/word/document.xml", &relationship.target)
== part_name
})
.unwrap_or_else(|| panic!("missing relationship for {part_name}"))
.id
.clone()
}
fn section_references(package: &OpcPackage) -> String {
let default_header = relationship_id(package, rel_types::HEADER, "/word/header1.xml");
let first_header = relationship_id(package, rel_types::HEADER, "/word/headerFirst1.xml");
let even_header = relationship_id(package, rel_types::HEADER, "/word/headerEven1.xml");
let default_footer = relationship_id(package, rel_types::FOOTER, "/word/footer1.xml");
let first_footer = relationship_id(package, rel_types::FOOTER, "/word/footerFirst1.xml");
let even_footer = relationship_id(package, rel_types::FOOTER, "/word/footerEven1.xml");
format!(
r#"<w:headerReference w:type="default" r:id="{default_header}"/><w:headerReference w:type="first" r:id="{first_header}"/><w:headerReference w:type="even" r:id="{even_header}"/><w:footerReference w:type="default" r:id="{default_footer}"/><w:footerReference w:type="first" r:id="{first_footer}"/><w:footerReference w:type="even" r:id="{even_footer}"/>"#
)
}
fn page_paragraph(text: &str, page_break_before: bool, section: Option<&str>) -> String {
let page_break = if page_break_before {
"<w:pageBreakBefore/>"
} else {
""
};
let section = section.unwrap_or("");
format!("<w:p><w:pPr>{page_break}{section}</w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>")
}
fn enable_even_headers(package: &mut OpcPackage) {
package.set_part(
"/word/settings.xml",
br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:evenAndOddHeaders/></w:settings>"#.to_vec(),
);
package.content_types.add_override(
"/word/settings.xml",
"application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml",
);
let relationships = package.get_or_create_part_rels("/word/document.xml");
if relationships.get_by_type(rel_types::SETTINGS).is_none() {
relationships.add(rel_types::SETTINGS, "settings.xml");
}
}
fn save_and_reopen(package: OpcPackage) -> Fixture {
let mut staged = std::io::Cursor::new(Vec::new());
package.write_to(&mut staged).unwrap();
let mut document = Document::from_bytes(staged.get_ref()).unwrap();
let saved = document.to_bytes().unwrap();
let document = Document::from_bytes(&saved).unwrap();
Fixture { document, saved }
}
fn full_fixture() -> Fixture {
let mut authored = Document::new();
authored.set_header(DEFAULT_HEADER);
authored.set_footer(DEFAULT_FOOTER);
authored.set_first_page_header(FIRST_HEADER);
authored.set_first_page_footer(FIRST_FOOTER);
authored.set_raw_header_with_images(
header_footer_xml("hdr", EVEN_HEADER),
&[],
HdrFtrType::Even,
);
authored.set_raw_footer_with_images(
header_footer_xml("ftr", EVEN_FOOTER),
&[],
HdrFtrType::Even,
);
authored.add_paragraph("authoring seed");
let bytes = authored.to_bytes().unwrap();
let mut package = OpcPackage::from_reader(std::io::Cursor::new(bytes)).unwrap();
enable_even_headers(&mut package);
let references = section_references(&package);
let first_section = format!(
r#"<w:sectPr>{references}<w:type w:val="nextPage"/><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720"/><w:titlePg/></w:sectPr>"#
);
let final_section = r#"<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720"/><w:titlePg/></w:sectPr>"#;
let document_xml = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:x="urn:producer"><w:body>{}{}{}{}{}{}{}{}</w:body></w:document>"#,
page_paragraph("SECTIONONEFIRSTBODY", false, None),
page_paragraph("SECTIONONEEVENBODY", true, None),
page_paragraph("SECTIONONEDEFAULTBODY", true, Some(&first_section)),
page_paragraph("SECTIONTWOFIRSTBODY", false, None),
PRODUCER_XML
.iter()
.map(|byte| *byte as char)
.collect::<String>(),
page_paragraph("SECTIONTWODEFAULTBODY", true, None),
page_paragraph("SECTIONTWOEVENBODY", true, None),
final_section,
);
package.set_part("/word/document.xml", document_xml.into_bytes());
package.set_part("/custom/producer.bin", PRODUCER_PART.to_vec());
package
.content_types
.add_override("/custom/producer.bin", "application/x-producer-private");
package
.get_or_create_part_rels("/word/document.xml")
.add(PRODUCER_REL_TYPE, "../custom/producer.bin");
save_and_reopen(package)
}
fn blank_fixture() -> Fixture {
let mut authored = Document::new();
authored.set_header(DEFAULT_HEADER);
authored.set_footer(DEFAULT_FOOTER);
authored.set_first_page_header("");
authored.set_first_page_footer("");
authored.set_raw_header_with_images(header_footer_xml("hdr", ""), &[], HdrFtrType::Even);
authored.set_raw_footer_with_images(header_footer_xml("ftr", ""), &[], HdrFtrType::Even);
let bytes = authored.to_bytes().unwrap();
let mut package = OpcPackage::from_reader(std::io::Cursor::new(bytes)).unwrap();
enable_even_headers(&mut package);
let references = section_references(&package);
let final_section = format!(
r#"<w:sectPr>{references}<w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720"/><w:titlePg/></w:sectPr>"#
);
let document_xml = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>{}{}{}</w:body></w:document>"#,
page_paragraph("BLANKFIRSTBODY", false, None),
page_paragraph("BLANKEVENBODY", true, None),
final_section,
);
package.set_part("/word/document.xml", document_xml.into_bytes());
save_and_reopen(package)
}
fn page_text(page: &PageFrame) -> String {
let mut text = String::new();
walk(&page.elements, &mut |element, _| {
if let PositionedElement::Text(run) = element {
text.push_str(&run.text);
}
});
text
}
fn marker_y(page: &PageFrame, marker: &str) -> f64 {
let mut ys = Vec::new();
walk(&page.elements, &mut |element, _| {
if let PositionedElement::Text(run) = element
&& run.text == marker
{
ys.push(run.origin.y);
}
});
assert_eq!(ys.len(), 1, "expected one positioned {marker}");
ys[0]
}
fn assert_story_selection(text: &str, header: &str, body: &str, footer: &str) {
assert!(text.contains(header), "missing {header} in {text:?}");
assert!(text.contains(body), "missing {body} in {text:?}");
assert!(text.contains(footer), "missing {footer} in {text:?}");
for marker in STORY_MARKERS {
assert_eq!(
text.matches(marker).count(),
usize::from(marker == header || marker == footer),
"unexpected {marker} in {text:?}"
);
}
}
#[test]
fn authored_reopened_headers_and_footers_reach_pdf() {
let fixture = full_fixture();
let layout = fixture.document.layout().unwrap();
let expected = [
(FIRST_HEADER, "SECTIONONEFIRSTBODY", FIRST_FOOTER),
(EVEN_HEADER, "SECTIONONEEVENBODY", EVEN_FOOTER),
(DEFAULT_HEADER, "SECTIONONEDEFAULTBODY", DEFAULT_FOOTER),
(FIRST_HEADER, "SECTIONTWOFIRSTBODY", FIRST_FOOTER),
(DEFAULT_HEADER, "SECTIONTWODEFAULTBODY", DEFAULT_FOOTER),
(EVEN_HEADER, "SECTIONTWOEVENBODY", EVEN_FOOTER),
];
assert_eq!(
layout.layout.pages.len(),
expected.len(),
"page text: {:?}",
layout
.layout
.pages
.iter()
.map(|page| page_text(page))
.collect::<Vec<_>>()
);
for (page, (header, body, footer)) in layout.layout.pages.iter().zip(expected) {
assert_story_selection(&page_text(page), header, body, footer);
assert!(marker_y(page, header) < marker_y(page, body));
assert!(marker_y(page, body) < marker_y(page, footer));
}
let pdf_text = pdf_page_text(&fixture.document.to_pdf_deterministic().unwrap());
assert_eq!(pdf_text.len(), expected.len());
for (text, (header, body, footer)) in pdf_text.iter().zip(expected) {
assert_story_selection(text, header, body, footer);
}
}
#[test]
fn blank_first_and_even_variants_do_not_borrow_defaults() {
let fixture = blank_fixture();
let layout = fixture.document.layout().unwrap();
assert_eq!(
layout.layout.pages.len(),
2,
"page text: {:?}",
layout
.layout
.pages
.iter()
.map(|page| page_text(page))
.collect::<Vec<_>>()
);
assert_eq!(page_text(&layout.layout.pages[0]), "BLANKFIRSTBODY");
assert_eq!(page_text(&layout.layout.pages[1]), "BLANKEVENBODY");
let pdf_text = pdf_page_text(&fixture.document.to_pdf_deterministic().unwrap());
assert_eq!(pdf_text, ["BLANKFIRSTBODY", "BLANKEVENBODY"]);
}
#[test]
fn header_footer_pdf_fixture_preserves_unrelated_package_state() {
let fixture = full_fixture();
let package = OpcPackage::from_reader(std::io::Cursor::new(fixture.saved)).unwrap();
assert_eq!(
package.get_part("/custom/producer.bin").unwrap(),
PRODUCER_PART
);
assert_eq!(
package.content_types.overrides.get("/custom/producer.bin"),
Some(&"application/x-producer-private".to_owned())
);
let relationship = package
.get_part_rels("/word/document.xml")
.unwrap()
.items
.iter()
.find(|relationship| relationship.rel_type == PRODUCER_REL_TYPE)
.expect("preserved producer relationship");
assert_eq!(relationship.target, "../custom/producer.bin");
assert_eq!(relationship.target_mode, None);
let document_xml = package.get_part("/word/document.xml").unwrap();
assert!(
document_xml
.windows(PRODUCER_XML.len())
.any(|window| window == PRODUCER_XML),
"unmodelled producer subtree was not preserved byte for byte"
);
}
}