use std::io::Write;
use dxpdf::render::layout::draw_command::DrawCommand;
fn make_docx(document_xml: &str) -> Vec<u8> {
let buf = std::io::Cursor::new(Vec::new());
let mut zip = zip::ZipWriter::new(buf);
let o = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
zip.start_file("[Content_Types].xml", o).unwrap();
zip.write_all(
br#"<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml"
ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>"#,
)
.unwrap();
zip.start_file("_rels/.rels", o).unwrap();
zip.write_all(
br#"<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
Target="word/document.xml"/>
</Relationships>"#,
)
.unwrap();
zip.start_file("word/document.xml", o).unwrap();
zip.write_all(document_xml.as_bytes()).unwrap();
zip.finish().unwrap().into_inner()
}
fn doc(body: &str) -> Vec<u8> {
make_docx(&format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>{body}</w:body>
</w:document>"#
))
}
fn layout(bytes: &[u8]) -> Vec<dxpdf::render::layout::draw_command::LayoutedPage> {
let parsed = dxpdf::docx::parse(bytes).expect("parse");
dxpdf::render::resolve_and_layout(parsed).1
}
fn y_of(pages: &[dxpdf::render::layout::draw_command::LayoutedPage], needle: &str) -> f32 {
pages
.iter()
.flat_map(|p| &p.commands)
.find_map(|c| match c {
DrawCommand::Text { text, position, .. } if &**text == needle => Some(position.y.raw()),
_ => None,
})
.unwrap_or_else(|| panic!("no text command {needle:?}"))
}
fn lone_cell_table(vmerge: &str) -> Vec<u8> {
doc(&format!(
r#"<w:p><w:r><w:t>before</w:t></w:r></w:p>
<w:tbl>
<w:tblPr><w:tblW w:w="8000" w:type="dxa"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="8000"/></w:tblGrid>
<w:tr><w:tc><w:tcPr><w:tcW w:w="8000" w:type="dxa"/>{vmerge}</w:tcPr>
<w:p><w:r><w:t>AAA</w:t></w:r></w:p>
<w:p><w:r><w:t>BBB</w:t></w:r></w:p>
<w:p><w:r><w:t>CCC</w:t></w:r></w:p>
</w:tc></w:tr>
</w:tbl>
<w:p><w:r><w:t>after</w:t></w:r></w:p>"#
))
}
#[test]
fn lone_vmerge_restart_does_not_let_following_content_overlap_the_table() {
let pages = layout(&lone_cell_table(r#"<w:vMerge w:val="restart"/>"#));
let last_row_text = y_of(&pages, "CCC");
let after = y_of(&pages, "after");
assert!(
after > last_row_text,
"\"after\" must follow the table's last line (y={last_row_text:.1}), \
but was drawn at y={after:.1} — on top of it"
);
}
#[test]
fn lone_vmerge_restart_matches_the_unmerged_layout() {
let restart = layout(&lone_cell_table(r#"<w:vMerge w:val="restart"/>"#));
let control = layout(&lone_cell_table(""));
assert!(
(y_of(&restart, "after") - y_of(&control, "after")).abs() < 0.01,
"lone restart moved the following paragraph: {:.1} vs control {:.1}",
y_of(&restart, "after"),
y_of(&control, "after"),
);
}
fn all_text(pages: &[dxpdf::render::layout::draw_command::LayoutedPage]) -> Vec<String> {
pages
.iter()
.flat_map(|p| &p.commands)
.filter_map(|c| match c {
DrawCommand::Text { text, .. } => Some(text.to_string()),
_ => None,
})
.collect()
}
fn orphan_continue_table(vmerge: &str) -> Vec<u8> {
doc(&format!(
r#"<w:p><w:r><w:t>before</w:t></w:r></w:p>
<w:tbl>
<w:tblPr><w:tblW w:w="8000" w:type="dxa"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="4000"/><w:gridCol w:w="4000"/></w:tblGrid>
<w:tr>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/>{vmerge}</w:tcPr>
<w:p><w:r><w:t>ORPHAN</w:t></w:r></w:p></w:tc>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/></w:tcPr>
<w:p><w:r><w:t>PEER</w:t></w:r></w:p></w:tc>
</w:tr>
<w:tr>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/></w:tcPr>
<w:p><w:r><w:t>NEXT</w:t></w:r></w:p></w:tc>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/></w:tcPr>
<w:p><w:r><w:t>NEXT2</w:t></w:r></w:p></w:tc>
</w:tr>
</w:tbl>
<w:p><w:r><w:t>after</w:t></w:r></w:p>"#
))
}
#[test]
fn orphan_vmerge_continue_keeps_its_cell_content() {
let pages = layout(&orphan_continue_table(r#"<w:vMerge w:val="continue"/>"#));
let drawn = all_text(&pages);
assert!(
drawn.iter().any(|t| t == "ORPHAN"),
"the orphaned cell's text was dropped from the document — drawn: {drawn:?}"
);
}
#[test]
fn orphan_vmerge_continue_matches_the_unmerged_layout() {
let orphan = layout(&orphan_continue_table(r#"<w:vMerge w:val="continue"/>"#));
let control = layout(&orphan_continue_table(""));
for needle in ["ORPHAN", "PEER", "NEXT", "NEXT2", "after"] {
assert!(
(y_of(&orphan, needle) - y_of(&control, needle)).abs() < 0.01,
"{needle} moved: y={:.2} vs control y={:.2}",
y_of(&orphan, needle),
y_of(&control, needle),
);
}
}
#[test]
fn a_paired_vmerge_continue_still_hides_its_own_content() {
let pages = layout(&doc(r#"<w:tbl>
<w:tblPr><w:tblW w:w="8000" w:type="dxa"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="4000"/><w:gridCol w:w="4000"/></w:tblGrid>
<w:tr>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/><w:vMerge w:val="restart"/></w:tcPr>
<w:p><w:r><w:t>MERGED</w:t></w:r></w:p></w:tc>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/></w:tcPr>
<w:p><w:r><w:t>PEER</w:t></w:r></w:p></w:tc>
</w:tr>
<w:tr>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/><w:vMerge w:val="continue"/></w:tcPr>
<w:p><w:r><w:t>HIDDEN</w:t></w:r></w:p></w:tc>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/></w:tcPr>
<w:p><w:r><w:t>PEER2</w:t></w:r></w:p></w:tc>
</w:tr>
</w:tbl>"#));
let drawn = all_text(&pages);
assert!(drawn.iter().any(|t| t == "MERGED"), "drawn: {drawn:?}");
assert!(
!drawn.iter().any(|t| t == "HIDDEN"),
"a continued merge shows the restart cell's content, not the \
continue cell's — drawn: {drawn:?}"
);
}
fn wide_restart_narrow_continue_table(vmerge: &str) -> Vec<u8> {
doc(&format!(
r#"<w:tbl>
<w:tblPr><w:tblW w:w="8000" w:type="dxa"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="4000"/><w:gridCol w:w="4000"/></w:tblGrid>
<w:tr>
<w:tc><w:tcPr><w:tcW w:w="8000" w:type="dxa"/><w:gridSpan w:val="2"/>
<w:vMerge w:val="restart"/></w:tcPr>
<w:p><w:r><w:t>WIDE</w:t></w:r></w:p></w:tc>
</w:tr>
<w:tr>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/></w:tcPr>
<w:p><w:r><w:t>PLAIN</w:t></w:r></w:p></w:tc>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/>{vmerge}</w:tcPr>
<w:p><w:r><w:t>ORPHAN</w:t></w:r></w:p></w:tc>
</w:tr>
</w:tbl>"#
))
}
#[test]
fn a_continue_outside_a_wide_restarts_first_column_keeps_its_cell_content() {
let pages = layout(&wide_restart_narrow_continue_table("<w:vMerge/>"));
let drawn = all_text(&pages);
assert!(
drawn.iter().any(|t| t == "ORPHAN"),
"a `continue` that the merge passes never join had its text dropped \
from the document — drawn: {drawn:?}"
);
}
#[test]
fn a_continue_outside_a_wide_restarts_first_column_matches_the_unmerged_layout() {
let orphan = layout(&wide_restart_narrow_continue_table("<w:vMerge/>"));
let control = layout(&wide_restart_narrow_continue_table(""));
for needle in ["WIDE", "PLAIN", "ORPHAN"] {
assert!(
(y_of(&orphan, needle) - y_of(&control, needle)).abs() < 0.01,
"{needle} moved: y={:.2} vs control y={:.2}",
y_of(&orphan, needle),
y_of(&control, needle),
);
}
}
#[test]
fn a_continue_under_a_wide_restarts_first_column_is_still_a_merge() {
let pages = layout(&doc(r#"<w:tbl>
<w:tblPr><w:tblW w:w="8000" w:type="dxa"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="4000"/><w:gridCol w:w="4000"/></w:tblGrid>
<w:tr>
<w:tc><w:tcPr><w:tcW w:w="8000" w:type="dxa"/><w:gridSpan w:val="2"/>
<w:vMerge w:val="restart"/></w:tcPr>
<w:p><w:r><w:t>WIDE</w:t></w:r></w:p></w:tc>
</w:tr>
<w:tr>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/><w:vMerge/></w:tcPr>
<w:p><w:r><w:t>HIDDEN</w:t></w:r></w:p></w:tc>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/></w:tcPr>
<w:p><w:r><w:t>PEER</w:t></w:r></w:p></w:tc>
</w:tr>
</w:tbl>"#));
let drawn = all_text(&pages);
assert!(drawn.iter().any(|t| t == "WIDE"), "drawn: {drawn:?}");
assert!(
!drawn.iter().any(|t| t == "HIDDEN"),
"the `continue` sits under the restart's own grid column, so it \
continues a real merge and shows the restart's content — drawn: \
{drawn:?}"
);
}
fn wide_continue_over_narrow_restart_table(vmerge: &str) -> Vec<u8> {
doc(&format!(
r#"<w:tbl>
<w:tblPr><w:tblW w:w="8000" w:type="dxa"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="4000"/><w:gridCol w:w="4000"/></w:tblGrid>
<w:tr>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/><w:vMerge w:val="restart"/></w:tcPr>
<w:p><w:r><w:t>RESTART</w:t></w:r></w:p></w:tc>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/></w:tcPr>
<w:p><w:r><w:t>PEER</w:t></w:r></w:p></w:tc>
</w:tr>
<w:tr>
<w:tc><w:tcPr><w:tcW w:w="8000" w:type="dxa"/><w:gridSpan w:val="2"/>
<w:vMerge/></w:tcPr>
<w:p><w:r><w:t>WIDECONT</w:t></w:r></w:p></w:tc>
</w:tr>
<w:tr>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/></w:tcPr>
<w:p><w:r><w:t>PLAIN</w:t></w:r></w:p></w:tc>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/>{vmerge}</w:tcPr>
<w:p><w:r><w:t>ORPHAN</w:t></w:r></w:p></w:tc>
</w:tr>
</w:tbl>"#
))
}
#[test]
fn a_continue_below_a_wider_continue_keeps_its_cell_content() {
let pages = layout(&wide_continue_over_narrow_restart_table("<w:vMerge/>"));
let drawn = all_text(&pages);
assert!(
drawn.iter().any(|t| t == "ORPHAN"),
"a `continue` in a column no `restart` ever opened had its text \
dropped from the document — drawn: {drawn:?}"
);
assert!(
!drawn.iter().any(|t| t == "WIDECONT"),
"the row above it *is* paired (its span covers the restart's column), \
so it stays a merge continuation and hides its own content — drawn: \
{drawn:?}"
);
}
#[test]
fn a_continue_below_a_wider_continue_matches_the_unmerged_layout() {
let orphan = layout(&wide_continue_over_narrow_restart_table("<w:vMerge/>"));
let control = layout(&wide_continue_over_narrow_restart_table(""));
for needle in ["RESTART", "PEER", "PLAIN", "ORPHAN"] {
assert!(
(y_of(&orphan, needle) - y_of(&control, needle)).abs() < 0.01,
"{needle} moved: y={:.2} vs control y={:.2}",
y_of(&orphan, needle),
y_of(&control, needle),
);
}
}
#[test]
fn a_bare_vmerge_orphan_keeps_its_cell_content() {
let bare = layout(&orphan_continue_table("<w:vMerge/>"));
let explicit = layout(&orphan_continue_table(r#"<w:vMerge w:val="continue"/>"#));
assert!(
all_text(&bare).iter().any(|t| t == "ORPHAN"),
"drawn: {:?}",
all_text(&bare)
);
assert_eq!(
all_text(&bare),
all_text(&explicit),
"the two spellings of `continue` must render identically"
);
}
#[test]
fn a_bare_vmerge_that_is_paired_still_hides_its_own_content() {
let pages = layout(&doc(r#"<w:tbl>
<w:tblPr><w:tblW w:w="8000" w:type="dxa"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="4000"/><w:gridCol w:w="4000"/></w:tblGrid>
<w:tr>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/><w:vMerge w:val="restart"/></w:tcPr>
<w:p><w:r><w:t>MERGED</w:t></w:r></w:p></w:tc>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/></w:tcPr>
<w:p><w:r><w:t>PEER</w:t></w:r></w:p></w:tc>
</w:tr>
<w:tr>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/><w:vMerge/></w:tcPr>
<w:p><w:r><w:t>HIDDEN</w:t></w:r></w:p></w:tc>
<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/></w:tcPr>
<w:p><w:r><w:t>PEER2</w:t></w:r></w:p></w:tc>
</w:tr>
</w:tbl>"#));
let drawn = all_text(&pages);
assert!(drawn.iter().any(|t| t == "MERGED"), "drawn: {drawn:?}");
assert!(
!drawn.iter().any(|t| t == "HIDDEN"),
"a bare `<w:vMerge/>` under a `restart` is a merge continuation — \
drawn: {drawn:?}"
);
}
#[test]
fn oversized_cant_split_row_does_not_emit_a_blank_leading_page() {
let paras: String = (0..70)
.map(|i| format!("<w:p><w:r><w:t>line {i}</w:t></w:r></w:p>"))
.collect();
let pages = layout(&doc(&format!(
r#"<w:tbl>
<w:tblPr><w:tblW w:w="8000" w:type="dxa"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="8000"/></w:tblGrid>
<w:tr><w:trPr><w:cantSplit/></w:trPr><w:tc>
<w:tcPr><w:tcW w:w="8000" w:type="dxa"/></w:tcPr>
{paras}
</w:tc></w:tr>
</w:tbl>
<w:p><w:r><w:t>after</w:t></w:r></w:p>"#
)));
assert!(
!pages[0].commands.is_empty(),
"page 0 is blank — the table advanced to a page that gave it no more room"
);
}
#[test]
fn table_after_body_text_still_moves_to_the_next_page_when_it_does_not_fit() {
let filler: String = (0..60)
.map(|i| format!("<w:p><w:r><w:t>filler {i}</w:t></w:r></w:p>"))
.collect();
let rows: String = (0..12)
.map(|i| {
format!(
r#"<w:tr><w:trPr><w:cantSplit/></w:trPr><w:tc>
<w:tcPr><w:tcW w:w="8000" w:type="dxa"/></w:tcPr>
<w:p><w:r><w:t>row {i}</w:t></w:r></w:p></w:tc></w:tr>"#
)
})
.collect();
let pages = layout(&doc(&format!(
r#"{filler}
<w:tbl>
<w:tblPr><w:tblW w:w="8000" w:type="dxa"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="8000"/></w:tblGrid>
{rows}
</w:tbl>"#
)));
assert!(
pages.len() > 1,
"filler plus a 12-row table should not fit on one page"
);
for (i, page) in pages.iter().enumerate() {
assert!(!page.commands.is_empty(), "page {i} is blank");
}
}
fn banded_table(tr_pr: &str) -> Vec<u8> {
let row = |extra: &str, label: &str| {
format!(
"<w:tr>{extra}<w:tc><w:tcPr><w:tcW w:w=\"4000\" w:type=\"dxa\"/></w:tcPr>\
<w:p><w:r><w:t>{label}</w:t></w:r></w:p></w:tc></w:tr>"
)
};
doc(&format!(
r#"<w:tbl>
<w:tblPr>
<w:tblW w:w="4000" w:type="dxa"/><w:tblLayout w:type="fixed"/>
<w:tblBorders>
<w:top w:val="single" w:sz="24" w:space="0" w:color="000000"/>
<w:bottom w:val="single" w:sz="24" w:space="0" w:color="000000"/>
<w:left w:val="nil"/><w:right w:val="nil"/>
<w:insideH w:val="single" w:sz="24" w:space="0" w:color="000000"/>
<w:insideV w:val="nil"/>
</w:tblBorders>
</w:tblPr>
<w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid>
{upper}{middle}{lower}
</w:tbl>"#,
upper = row("", "upper"),
middle = row(tr_pr, "middle"),
lower = row("", "lower"),
))
}
fn middle_row_box(bytes: &[u8]) -> f32 {
let pages = layout(bytes);
let mut rules: Vec<(f32, f32)> = pages
.iter()
.flat_map(|p| &p.commands)
.filter_map(|c| match c {
DrawCommand::Rect { rect, .. } if rect.size.height.raw() < rect.size.width.raw() => {
Some((
rect.origin.y.raw(),
(rect.origin.y + rect.size.height).raw(),
))
}
_ => None,
})
.collect();
rules.sort_by(|a, b| a.0.total_cmp(&b.0));
assert_eq!(
rules.len(),
4,
"table top, two interior rules, table bottom"
);
rules[2].0 - rules[1].1
}
#[test]
fn an_exact_row_height_is_charged_half_of_each_interior_border() {
let box_h = middle_row_box(&banded_table(
r#"<w:trPr><w:trHeight w:val="800" w:hRule="exact"/></w:trPr>"#,
));
assert!(
(box_h - 37.0).abs() < 0.01,
"800 twips is 40pt minus half of each of its two 3pt interior rules, \
37pt of cell, not {box_h}pt"
);
}
#[test]
fn a_row_shorter_than_its_rules_floors_at_zero_rather_than_going_negative() {
let pages = layout(&banded_table(
r#"<w:trPr><w:trHeight w:val="40" w:hRule="exact"/></w:trPr>"#,
));
let mut rules: Vec<(f32, f32)> = pages
.iter()
.flat_map(|p| &p.commands)
.filter_map(|c| match c {
DrawCommand::Rect { rect, .. } if rect.size.height.raw() < rect.size.width.raw() => {
Some((
rect.origin.y.raw(),
(rect.origin.y + rect.size.height).raw(),
))
}
_ => None,
})
.collect();
rules.sort_by(|a, b| a.0.total_cmp(&b.0));
assert_eq!(
rules.len(),
3,
"table top, one coalesced interior band, table bottom — not 4 \
separate rules, since the floored row leaves none of its own \
between them"
);
let band_h = rules[1].1 - rules[1].0;
assert!(
(band_h - 6.0).abs() < 0.01,
"40 twips (2pt) is less than the 3pt charged from each side, so the \
content box floors at zero and its two 3pt interior rules meet with \
nothing between them: one 6pt band, not {band_h}pt"
);
}
#[test]
fn an_at_least_row_height_is_measured_the_same_way() {
let box_h = middle_row_box(&banded_table(
r#"<w:trPr><w:trHeight w:val="800" w:hRule="atLeast"/></w:trPr>"#,
));
assert!(
(box_h - 40.0).abs() < 0.01,
"800 twips is 40pt of cell under atLeast too, not {box_h}pt"
);
}
#[test]
fn an_exact_height_of_zero_is_no_constraint_at_all() {
let zero = middle_row_box(&banded_table(
r#"<w:trPr><w:trHeight w:val="0" w:hRule="exact"/></w:trPr>"#,
));
let unconstrained = middle_row_box(&banded_table(""));
assert!(
(zero - unconstrained).abs() < 0.01,
"a zero exact height must lay out like no height at all: {zero} vs {unconstrained}"
);
assert!(
zero > 1.0,
"and that is a real row, not a flat one: {zero}pt"
);
}