extern crate text_document_io as document_io;
use common::long_operation::{LongOperationManager, OperationStatus};
use document_io::docx_rs::{
AlignmentType, DocumentChild, Docx, HyperlinkData, Paragraph, ParagraphChild, RunChild,
SpecialIndentType,
};
use document_io::{ExportDocxDto, ImportDjotDto, document_io_controller};
use test_harness::{EventHub, setup};
use std::sync::Arc;
const RICH_DJOT: &str = "\
# Title
{alignment=center}
Centered intro with a [link](https://example.com).
- bullet one
- bullet two
1. first
2. second
- [x] done task
- [ ] pending task
> a quoted line
>
> > nested quote
```rust
let answer = 42;
```";
fn wait(mgr: &LongOperationManager, op_id: &str) {
while let Some(OperationStatus::Running) = mgr.get_operation_status(op_id) {
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
fn docx_from_djot(djot: &str) -> Docx {
let (db, ev, _) = setup().expect("setup");
import_djot(&db, &ev, djot);
document_io_controller::build_docx_document(&db, &ExportDocxDto::default())
.expect("build_docx_document")
}
fn docx_bytes_from_djot(djot: &str) -> Vec<u8> {
let (db, ev, _) = setup().expect("setup");
import_djot(&db, &ev, djot);
let dir = std::env::temp_dir();
let path = dir.join(format!(
"docx_export_footnotes_{}_{}.docx",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default()
));
let path_str = path.to_string_lossy().to_string();
let mut mgr = LongOperationManager::new();
let op = document_io_controller::export_docx(
&db,
&ev,
&mut mgr,
&ExportDocxDto {
output_path: path_str.clone(),
options: Default::default(),
},
)
.expect("export_docx");
wait(&mgr, &op);
assert_eq!(
mgr.get_operation_status(&op),
Some(OperationStatus::Completed),
"export of {djot:?} did not complete"
);
let bytes = std::fs::read(&path).expect("output file exists");
let _ = std::fs::remove_file(&path);
bytes
}
fn read_zip_entry(bytes: &[u8], name: &str) -> String {
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("packaged DOCX is a valid zip");
let mut file = archive
.by_name(name)
.unwrap_or_else(|_| panic!("entry {name:?} present in the DOCX package"));
let mut contents = String::new();
std::io::Read::read_to_string(&mut file, &mut contents).expect("entry is valid utf-8");
contents
}
fn import_djot(db: &test_harness::DbContext, ev: &Arc<EventHub>, djot: &str) {
let mut mgr = LongOperationManager::new();
let op = document_io_controller::import_djot(
db,
ev,
&mut mgr,
&ImportDjotDto {
djot_text: djot.to_string(),
options: Default::default(),
},
)
.expect("import_djot");
wait(&mgr, &op);
assert_eq!(
mgr.get_operation_status(&op),
Some(OperationStatus::Completed),
"import of {djot:?} did not complete"
);
}
fn paragraphs(docx: &Docx) -> Vec<&Paragraph> {
docx.document
.children
.iter()
.filter_map(|c| match c {
DocumentChild::Paragraph(p) => Some(&**p),
_ => None,
})
.collect()
}
fn collect_text(children: &[ParagraphChild], out: &mut String) {
for child in children {
match child {
ParagraphChild::Run(run) => {
for rc in &run.children {
if let RunChild::Text(t) = rc {
out.push_str(&t.text);
}
}
}
ParagraphChild::Hyperlink(h) => collect_text(&h.children, out),
_ => {}
}
}
}
fn para_text(p: &Paragraph) -> String {
let mut s = String::new();
collect_text(&p.children, &mut s);
s
}
fn alignment(p: &Paragraph) -> Option<&str> {
p.property.alignment.as_ref().map(|j| j.val.as_str())
}
fn numbering_id(p: &Paragraph) -> Option<usize> {
p.property
.numbering_property
.as_ref()
.and_then(|np| np.id.as_ref())
.map(|id| id.id)
}
fn left_indent(p: &Paragraph) -> Option<i32> {
p.property.indent.as_ref().and_then(|i| i.start)
}
fn first_line_indent(p: &Paragraph) -> Option<i32> {
match p.property.indent.as_ref()?.special_indent {
Some(SpecialIndentType::FirstLine(v)) => Some(v),
_ => None,
}
}
fn space_before(p: &Paragraph) -> Option<u32> {
let ls = p.property.line_spacing.as_ref()?;
serde_json::to_value(ls)
.ok()?
.get("before")?
.as_u64()
.map(|v| v as u32)
}
fn para_containing<'a>(docx: &'a Docx, needle: &str) -> &'a Paragraph {
paragraphs(docx)
.into_iter()
.find(|p| para_text(p).contains(needle))
.unwrap_or_else(|| panic!("no paragraph containing {needle:?}"))
}
fn hyperlink_paths(p: &Paragraph) -> Vec<String> {
p.children
.iter()
.filter_map(|c| match c {
ParagraphChild::Hyperlink(h) => match &h.link {
HyperlinkData::External { path, .. } => Some(path.clone()),
HyperlinkData::Anchor { anchor } => Some(anchor.clone()),
},
_ => None,
})
.collect()
}
fn page_break_before(p: &Paragraph) -> bool {
serde_json::to_value(&p.property)
.ok()
.and_then(|v| v.get("pageBreakBefore").and_then(|b| b.as_bool()))
.unwrap_or(false)
}
#[test]
fn a_flagged_block_carries_page_break_before() {
let docx = docx_from_djot("First.\n\n{page_break_before=true}\nSecond.");
assert!(!page_break_before(para_containing(&docx, "First")));
assert!(page_break_before(para_containing(&docx, "Second")));
}
#[test]
fn a_heading_keeps_its_page_break_alongside_its_style() {
let docx = docx_from_djot("Body.\n\n{page_break_before=true}\n# Chapter Two");
let p = para_containing(&docx, "Chapter Two");
assert!(page_break_before(p));
assert_eq!(
p.property.style.as_ref().map(|s| s.val.as_str()),
Some("Heading1"),
"the heading style must still be applied"
);
}
#[test]
fn an_unflagged_block_has_no_page_break() {
let docx = docx_from_djot("Just prose.");
assert!(!page_break_before(para_containing(&docx, "Just prose")));
}
#[test]
fn a_heading_keeps_its_own_space_above() {
let docx = docx_from_djot("{top_margin=288}\n# A Title");
let p = para_containing(&docx, "A Title");
assert_eq!(space_before(p), Some(4320));
}
#[test]
fn the_heading_styles_referenced_are_actually_defined() {
let docx =
docx_from_djot("# One\n\n## Two\n\n### Three\n\n#### Four\n\n##### Five\n\n###### Six");
let defined: Vec<&str> = docx
.styles
.styles
.iter()
.map(|s| s.style_id.as_str())
.collect();
for level in 1..=6 {
let id = format!("Heading{level}");
assert!(
defined.contains(&id.as_str()),
"{id} is referenced by a paragraph but never defined; defined: {defined:?}"
);
}
}
#[test]
fn a_defined_heading_style_is_sized_and_outlined() {
let docx = docx_from_djot("# One");
let h1 = docx
.styles
.styles
.iter()
.find(|s| s.style_id == "Heading1")
.expect("Heading1 defined");
let json = serde_json::to_value(h1).expect("serialize");
let size = json["runProperty"]["sz"].as_u64();
assert_eq!(
size,
Some(43),
"12 pt body x 1.8 rounds to 43 half-points, not {size:?}"
);
assert_eq!(json["paragraphProperty"]["outlineLvl"].as_u64(), Some(0));
assert_eq!(json["paragraphProperty"]["keepNext"].as_bool(), Some(true));
assert_eq!(
json["paragraphProperty"]["lineSpacing"]["before"].as_u64(),
Some(480)
);
}
#[test]
fn caller_supplied_heading_styles_win() {
use common::parser_tools::{DocxExportOptions, DocxHeadingStyle};
let (db, ev, _) = setup().expect("setup");
import_djot(&db, &ev, "# One");
let docx = document_io_controller::build_docx_document(
&db,
&ExportDocxDto {
options: DocxExportOptions {
heading_styles: vec![DocxHeadingStyle {
size_half_points: Some(24),
bold: false,
alignment: Some(common::entities::Alignment::Center),
page_break_before: true,
..DocxHeadingStyle::default()
}],
..DocxExportOptions::default()
},
..ExportDocxDto::default()
},
)
.expect("build_docx_document");
let h1 = docx
.styles
.styles
.iter()
.find(|s| s.style_id == "Heading1")
.expect("Heading1 defined");
let json = serde_json::to_value(h1).expect("serialize");
assert_eq!(json["runProperty"]["sz"].as_u64(), Some(24));
assert_eq!(
json["paragraphProperty"]["pageBreakBefore"].as_bool(),
Some(true)
);
assert_ne!(json["runProperty"]["bold"].as_bool(), Some(true));
}
#[test]
fn alignment_center_maps_to_jc() {
let docx = docx_from_djot("{alignment=center}\nCentered paragraph");
let p = para_containing(&docx, "Centered paragraph");
assert_eq!(alignment(p), Some("center"));
}
#[test]
fn alignment_all_variants_map() {
for (attr, expected) in [
("left", AlignmentType::Left),
("right", AlignmentType::Right),
("center", AlignmentType::Center),
("justify", AlignmentType::Justified),
] {
let marker = format!("aligned-{attr}");
let docx = docx_from_djot(&format!("{{alignment={attr}}}\n{marker}"));
let p = para_containing(&docx, &marker);
let got = alignment(p).expect("alignment set");
assert_eq!(got, expected.to_string(), "attr={attr}");
}
}
#[test]
fn no_alignment_leaves_jc_unset() {
let docx = docx_from_djot("Plain unaligned paragraph");
let p = para_containing(&docx, "Plain unaligned paragraph");
assert_eq!(alignment(p), None);
}
#[test]
fn hyperlink_is_emitted_with_destination() {
let docx = docx_from_djot("See [the site](https://example.com/page) now");
let p = para_containing(&docx, "the site");
let paths = hyperlink_paths(p);
assert_eq!(paths.len(), 1, "exactly one hyperlink");
assert!(
paths[0].contains("example.com/page"),
"href preserved, got {:?}",
paths[0]
);
assert!(para_text(p).contains("the site"));
}
#[test]
fn plain_text_has_no_hyperlink() {
let docx = docx_from_djot("Just words, no link here");
let p = para_containing(&docx, "Just words");
assert!(hyperlink_paths(p).is_empty());
}
#[test]
fn code_block_uses_monospace_font_and_preserves_text() {
let docx = docx_from_djot("```rust\nlet x = 41 + 1;\n```");
let p = para_containing(&docx, "let x = 41 + 1;");
assert!(!para_text(p).contains("```"));
assert_eq!(para_text(p), "let x = 41 + 1;");
let json = docx.json();
assert!(
json.contains("Courier New"),
"expected a Courier New run in the document"
);
}
#[test]
fn code_block_inline_formatting_is_flattened() {
let docx = docx_from_djot("```\na * b * c\n```");
let p = para_containing(&docx, "a * b * c");
assert_eq!(para_text(p), "a * b * c");
}
#[test]
fn bullet_list_items_carry_numbering() {
let docx = docx_from_djot("- first\n- second\n- third");
let items: Vec<&Paragraph> = paragraphs(&docx)
.into_iter()
.filter(|p| numbering_id(p).is_some())
.collect();
assert_eq!(items.len(), 3, "all three bullets numbered");
let ids: std::collections::HashSet<usize> =
items.iter().filter_map(|p| numbering_id(p)).collect();
assert_eq!(ids.len(), 1, "single bullet list => single numbering id");
let id = *ids.iter().next().unwrap();
assert_numbering_format(&docx, id, "bullet");
}
#[test]
fn ordered_list_uses_decimal_format() {
let docx = docx_from_djot("1. alpha\n2. beta");
let id = numbering_id(para_containing(&docx, "alpha")).expect("numbered");
assert_numbering_format(&docx, id, "decimal");
}
#[test]
fn two_separate_lists_get_independent_numbering() {
let docx = docx_from_djot("1. one\n2. two\n\nbreak\n\n1. uno\n2. dos");
let first = numbering_id(para_containing(&docx, "one")).expect("first numbered");
let second = numbering_id(para_containing(&docx, "uno")).expect("second numbered");
assert_ne!(
first, second,
"distinct lists must use distinct numbering ids so counters restart"
);
}
#[test]
fn task_items_render_checkbox_glyphs_without_numbering() {
let docx = docx_from_djot("- [x] done\n- [ ] todo");
let done = para_containing(&docx, "done");
let todo = para_containing(&docx, "todo");
assert!(para_text(done).contains('\u{2612}'), "checked glyph ☒");
assert!(para_text(todo).contains('\u{2610}'), "unchecked glyph ☐");
assert_eq!(numbering_id(done), None);
assert!(left_indent(done).unwrap_or(0) > 0);
}
fn assert_numbering_format(docx: &Docx, numbering_id: usize, expected_format: &str) {
let num = docx
.numberings
.numberings
.iter()
.find(|n| n.id == numbering_id)
.unwrap_or_else(|| panic!("numbering {numbering_id} registered"));
let abstract_num = docx
.numberings
.abstract_nums
.iter()
.find(|a| a.id == num.abstract_num_id)
.expect("abstract numbering registered");
let level0 = &abstract_num.levels[0];
assert_eq!(
level0.format.val, expected_format,
"numbering {numbering_id} level-0 format"
);
}
#[test]
fn blockquote_paragraph_is_indented() {
let docx = docx_from_djot("> a quoted line");
let p = para_containing(&docx, "a quoted line");
assert_eq!(left_indent(p), Some(720), "one quote level => 720 twips");
}
#[test]
fn nested_blockquote_indents_deeper() {
let docx = docx_from_djot("> outer quote\n>\n> > inner quote");
let outer = para_containing(&docx, "outer quote");
let inner = para_containing(&docx, "inner quote");
assert_eq!(left_indent(outer), Some(720));
assert_eq!(
left_indent(inner),
Some(1440),
"two quote levels => 1440 twips"
);
}
#[test]
fn heading_levels_use_heading_styles() {
for level in 1..=6 {
let hashes = "#".repeat(level);
let marker = format!("Title{level}");
let docx = docx_from_djot(&format!("{hashes} {marker}"));
let p = para_containing(&docx, &marker);
let style = p.property.style.as_ref().map(|s| s.val.as_str());
assert_eq!(style, Some(format!("Heading{level}").as_str()));
}
}
#[test]
fn plain_paragraph_has_no_numbering_indent_or_style() {
let docx = docx_from_djot("An ordinary paragraph");
let p = para_containing(&docx, "An ordinary paragraph");
assert_eq!(numbering_id(p), None);
assert_eq!(left_indent(p), None);
assert_eq!(p.property.style, None);
}
#[test]
fn rich_document_packs_to_a_valid_docx_file() {
use document_io::docx_rs::read_docx;
let (db, ev, _) = setup().expect("setup");
import_djot(&db, &ev, RICH_DJOT);
let dir = std::env::temp_dir();
let path = dir.join(format!("docx_export_rich_{}.docx", std::process::id()));
let path_str = path.to_string_lossy().to_string();
let mut mgr = LongOperationManager::new();
let op = document_io_controller::export_docx(
&db,
&ev,
&mut mgr,
&ExportDocxDto {
output_path: path_str.clone(),
options: Default::default(),
},
)
.expect("export_docx");
wait(&mgr, &op);
assert_eq!(
mgr.get_operation_status(&op),
Some(OperationStatus::Completed),
"export should complete"
);
let bytes = std::fs::read(&path).expect("output file exists");
let parsed = read_docx(&bytes).expect("packed docx must be readable");
assert!(
!parsed.document.children.is_empty(),
"round-tripped document has content"
);
assert!(
!parsed.numberings.numberings.is_empty(),
"list numbering definitions survive the pack/unpack"
);
let _ = std::fs::remove_file(&path);
}
const FOOTNOTE_DJOT: &str = "\
Prose with a note[^n1] in it.
[^n1]: The note body for Word.
";
fn footnote_reference_ids(document_xml: &str) -> Vec<&str> {
document_xml
.match_indices("<w:footnoteReference ")
.filter_map(|(i, _)| {
let tail = &document_xml[i..];
let id_start = tail.find("w:id=\"")? + "w:id=\"".len();
let id_end = id_start + tail[id_start..].find('"')?;
Some(&tail[id_start..id_end])
})
.collect()
}
fn footnote_body_ids(footnotes_xml: &str) -> Vec<&str> {
footnotes_xml
.match_indices("<w:footnote ")
.filter_map(|(i, _)| {
let tail = &footnotes_xml[i..];
let id_start = tail.find("w:id=\"")? + "w:id=\"".len();
let id_end = id_start + tail[id_start..].find('"')?;
Some(&tail[id_start..id_end])
})
.collect()
}
#[test]
fn docx_footnote_reference_resolves_to_a_real_body_in_footnotes_xml() {
let bytes = docx_bytes_from_djot(FOOTNOTE_DJOT);
let document_xml = read_zip_entry(&bytes, "word/document.xml");
let footnotes_xml = read_zip_entry(&bytes, "word/footnotes.xml");
let ref_ids = footnote_reference_ids(&document_xml);
assert_eq!(
ref_ids.len(),
1,
"expected exactly one footnote reference in document.xml, got {ref_ids:?}: {document_xml}"
);
let body_ids = footnote_body_ids(&footnotes_xml);
assert!(
body_ids.contains(&ref_ids[0]),
"document.xml references footnote id {:?}, but footnotes.xml only defines {body_ids:?}: {footnotes_xml}",
ref_ids[0]
);
assert!(
footnotes_xml.contains("The note body for Word."),
"the note's body never reached footnotes.xml: {footnotes_xml}"
);
}
#[test]
fn docx_note_body_is_not_also_rendered_as_prose() {
let bytes = docx_bytes_from_djot(FOOTNOTE_DJOT);
let document_xml = read_zip_entry(&bytes, "word/document.xml");
assert!(
!document_xml.contains("The note body for Word."),
"the note body must not appear inline in document.xml, only inside the footnote: {document_xml}"
);
}
#[test]
fn docx_dangling_footnote_reference_still_produces_a_note() {
let bytes = docx_bytes_from_djot("Text with a note[^solo] in it.\n");
let document_xml = read_zip_entry(&bytes, "word/document.xml");
let ref_ids = footnote_reference_ids(&document_xml);
assert_eq!(
ref_ids.len(),
1,
"the dangling reference must still produce a real footnote reference: {document_xml}"
);
let footnotes_xml = read_zip_entry(&bytes, "word/footnotes.xml");
let body_ids = footnote_body_ids(&footnotes_xml);
assert!(
body_ids.contains(&ref_ids[0]),
"the reference's id must resolve to a real (even if empty) footnote: {footnotes_xml}"
);
}
const REPEAT_FOOTNOTE_DJOT: &str =
"First[^n1] and second[^n1] citation.\n\n[^n1]: The note body for Word.\n";
#[test]
fn docx_repeat_citation_reuses_one_footnote_not_two() {
let bytes = docx_bytes_from_djot(REPEAT_FOOTNOTE_DJOT);
let document_xml = read_zip_entry(&bytes, "word/document.xml");
let footnotes_xml = read_zip_entry(&bytes, "word/footnotes.xml");
let ref_ids = footnote_reference_ids(&document_xml);
assert_eq!(
ref_ids.len(),
1,
"only the FIRST citation may become a real <w:footnoteReference>: {document_xml}"
);
let body_ids = footnote_body_ids(&footnotes_xml);
assert_eq!(
body_ids.len(),
1,
"citing one label twice must define exactly one <w:footnote>: {footnotes_xml}"
);
assert!(
body_ids.contains(&ref_ids[0]),
"the one reference must resolve to the one body: {footnotes_xml}"
);
assert_eq!(
footnotes_xml.matches("The note body for Word.").count(),
1,
"the note body must not be duplicated: {footnotes_xml}"
);
assert_eq!(
document_xml
.matches("w:rStyle w:val=\"FootnoteReference\"")
.count(),
2,
"both the real reference and the repeat's plain marker must carry \
the FootnoteReference character style: {document_xml}"
);
}
#[test]
fn bold_run_is_marked_bold() {
let docx = docx_from_djot("normal *bolded* normal");
let p = para_containing(&docx, "bolded");
let has_bold = p.children.iter().any(|c| match c {
ParagraphChild::Run(r) => {
r.run_property.bold.is_some()
&& r.children
.iter()
.any(|rc| matches!(rc, RunChild::Text(t) if t.text.contains("bolded")))
}
_ => false,
});
assert!(has_bold, "the 'bolded' run should be bold");
}
use common::parser_tools::DocxExportOptions;
fn docx_from_djot_with_options(djot: &str, options: DocxExportOptions) -> Docx {
let (db, ev, _) = setup().expect("setup");
import_djot(&db, &ev, djot);
document_io_controller::build_docx_document(
&db,
&ExportDocxDto {
output_path: String::new(),
options,
},
)
.expect("build_docx_document")
}
#[test]
fn rtl_block_exports_paragraph_bidi() {
let docx = docx_from_djot("{direction=rtl}\nمرحبا بالعالم\n");
let ps = paragraphs(&docx);
assert_eq!(ps.len(), 1, "one paragraph");
assert_eq!(
ps[0].property.bidi,
Some(true),
"an RTL block gets paragraph bidi"
);
}
#[test]
fn ltr_block_has_no_bidi() {
let docx = docx_from_djot("Hello world\n");
let ps = paragraphs(&docx);
assert_eq!(
ps[0].property.bidi, None,
"an LTR block is never marked bidi"
);
}
#[test]
fn options_apply_page_size_and_font_defaults() {
let opts = DocxExportOptions {
page_width_twips: Some(11906), page_height_twips: Some(16838),
font_family: Some("Courier New".to_string()),
font_half_points: Some(24), justify: true,
first_line_indent_twips: Some(720),
line_spacing_twips: Some(480),
..Default::default()
};
let docx = docx_from_djot_with_options("The wind rose over the hills.\n", opts);
let json = docx.json();
assert!(
json.contains("11906") && json.contains("16838"),
"A4 page size in section props"
);
let ps = paragraphs(&docx);
assert_eq!(
alignment(ps[0]),
Some("justified"),
"justify → jc=justified"
);
assert!(ps[0].property.line_spacing.is_some(), "line spacing set");
let ind = ps[0].property.indent.as_ref().expect("indent set");
assert!(
matches!(
ind.special_indent,
Some(document_io::docx_rs::SpecialIndentType::FirstLine(720))
),
"first-line indent of 720 twips"
);
}
#[test]
fn page_numbers_attach_a_header() {
let opts = DocxExportOptions {
page_numbers: true,
running_header: Some("Vane / THE LIGHTHOUSE".to_string()),
..Default::default()
};
let docx = docx_from_djot_with_options("Prose.\n", opts);
assert!(
docx.document_rels.header_count > 0,
"a header relationship was registered"
);
}
#[test]
fn a_blocks_own_text_indent_overrides_the_document_wide_one() {
let options = DocxExportOptions {
first_line_indent_twips: Some(720),
..Default::default()
};
let docx = docx_from_djot_with_options(
"Indented paragraph.\n\n{text_indent=0}\nFlush paragraph.",
options,
);
assert_eq!(
first_line_indent(para_containing(&docx, "Indented paragraph.")),
Some(720),
"an ordinary paragraph keeps the document-wide first-line indent"
);
assert_eq!(
first_line_indent(para_containing(&docx, "Flush paragraph.")),
None,
"text_indent=0 must suppress the indent, not inherit it"
);
}
#[test]
fn a_blocks_own_top_margin_becomes_space_before() {
let docx = docx_from_djot_with_options(
"Before.\n\n{top_margin=24}\nAfter.",
DocxExportOptions::default(),
);
assert_eq!(space_before(para_containing(&docx, "After.")), Some(360));
assert_eq!(
space_before(para_containing(&docx, "Before.")),
None,
"a paragraph without the attribute gets no space-above"
);
}