use std::io::Write;
use dxpdf::render::layout::draw_command::{DrawCommand, LayoutedPage};
fn make_docx(document_xml: &str) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut 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();
}
buf
}
fn decimal_tab_document(entries: &[&str]) -> String {
let paragraphs: String = entries
.iter()
.map(|entry| {
format!(
r#"<w:p>
<w:pPr>
<w:tabs><w:tab w:val="decimal" w:pos="4320"/></w:tabs>
</w:pPr>
<w:r><w:tab/><w:t>{entry}</w:t></w:r>
</w:p>"#
)
})
.collect();
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>{paragraphs}</w:body>
</w:document>"#
)
}
fn xs_of_texts(pages: &[LayoutedPage]) -> Vec<(String, f32)> {
pages
.iter()
.flat_map(|p| p.commands.iter())
.filter_map(|c| match c {
DrawCommand::Text { text, position, .. } => Some((text.to_string(), position.x.raw())),
_ => None,
})
.collect()
}
#[test]
fn decimal_stop_aligns_separators_across_paragraphs() {
let bytes = make_docx(&decimal_tab_document(&["1.5", "22.75", "333.125"]));
let doc = dxpdf::docx::parse(&bytes).expect("fixture parses");
let (_, pages) = dxpdf::render::resolve_and_layout(doc);
let texts = xs_of_texts(&pages);
assert_eq!(texts.len(), 3, "one text command per entry: {texts:?}");
let starts: Vec<f32> = texts.iter().map(|(_, x)| *x).collect();
assert!(
starts[0] > starts[1] && starts[1] > starts[2],
"wider integer parts start further left: {starts:?}"
);
assert!(
(starts[0] - starts[2]).abs() > 1.0,
"1.5 and 333.125 must not start at the same x: {starts:?}"
);
}
#[test]
fn decimal_stop_right_aligns_an_entry_with_no_separator() {
let bytes = make_docx(&decimal_tab_document(&["1234", "1.5"]));
let doc = dxpdf::docx::parse(&bytes).expect("fixture parses");
let (_, pages) = dxpdf::render::resolve_and_layout(doc);
let texts = xs_of_texts(&pages);
assert_eq!(texts.len(), 2, "{texts:?}");
let (whole, whole_x) = &texts[0];
let (fraction, fraction_x) = &texts[1];
assert_eq!((whole.as_str(), fraction.as_str()), ("1234", "1.5"));
assert!(
whole_x < fraction_x,
"a separator-less zone must right-align, so {whole:?} at {whole_x} \
starts left of {fraction:?} at {fraction_x}; equal x means it was \
left-aligned instead"
);
}
fn two_ptab_document(lead: &str, mid: &str, trail: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p>
<w:r><w:t xml:space="preserve">{lead}</w:t></w:r>
<w:r><w:ptab w:relativeTo="margin" w:alignment="center" w:leader="none"/></w:r>
<w:r><w:t xml:space="preserve">{mid}</w:t></w:r>
<w:r><w:ptab w:relativeTo="margin" w:alignment="right" w:leader="none"/></w:r>
<w:r><w:t xml:space="preserve">{trail}</w:t></w:r>
</w:p>
</w:body>
</w:document>"#
)
}
#[test]
fn a_ptab_anchor_behind_the_pen_advances_to_the_next_line() {
let bytes = make_docx(&two_ptab_document("L", &"m".repeat(34), &"m".repeat(19)));
let doc = dxpdf::docx::parse(&bytes).expect("fixture parses");
let (_, pages) = dxpdf::render::resolve_and_layout(doc);
let placed: Vec<(String, f32, f32)> = pages
.iter()
.flat_map(|p| p.commands.iter())
.filter_map(|c| match c {
DrawCommand::Text { text, position, .. } => {
Some((text.to_string(), position.x.raw(), position.y.raw()))
}
_ => None,
})
.collect();
let mid = placed
.iter()
.find(|(t, _, _)| t.starts_with('m'))
.expect("middle run emitted");
let trail = placed
.iter()
.rev()
.find(|(t, _, _)| t.starts_with('m'))
.expect("trailing run emitted — not dropped");
assert!(
trail.2 > mid.2,
"the trailing run advances to the next line: {placed:?}"
);
assert!(
trail.1 < 540.0,
"the trailing run starts inside the text area, at {}",
trail.1
);
}
fn tabbed_paragraph(tabs: &str, body: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p>
<w:pPr><w:tabs>{tabs}</w:tabs></w:pPr>
{body}
</w:p>
</w:body>
</w:document>"#
)
}
fn layout(document_xml: &str) -> Vec<LayoutedPage> {
let doc = dxpdf::docx::parse(&make_docx(document_xml)).expect("fixture parses");
dxpdf::render::resolve_and_layout(doc).1
}
fn vertical_rules(
pages: &[LayoutedPage],
) -> Vec<(f32, f32, f32, dxpdf::render::resolve::color::RgbColor)> {
pages
.iter()
.flat_map(|p| p.commands.iter())
.filter_map(|c| match c {
DrawCommand::Line { line, color, .. }
if (line.start.x.raw() - line.end.x.raw()).abs() < 0.001 =>
{
Some((
line.start.x.raw(),
line.start.y.raw().min(line.end.y.raw()),
line.start.y.raw().max(line.end.y.raw()),
*color,
))
}
_ => None,
})
.collect()
}
const THREE_INCHES: &str = "4320";
#[test]
fn a_bar_stop_draws_a_vertical_rule_at_its_position() {
let pages = layout(&tabbed_paragraph(
&format!(r#"<w:tab w:val="bar" w:pos="{THREE_INCHES}"/>"#),
r#"<w:r><w:t>text</w:t></w:r>"#,
));
let rules = vertical_rules(&pages);
assert_eq!(rules.len(), 1, "one line, one rule: {rules:?}");
assert!(rules[0].2 > rules[0].1, "the rule has height: {rules:?}");
}
#[test]
fn the_rule_sits_where_a_stop_at_the_same_position_would_put_content() {
let rule_x = vertical_rules(&layout(&tabbed_paragraph(
&format!(r#"<w:tab w:val="bar" w:pos="{THREE_INCHES}"/>"#),
r#"<w:r><w:t>text</w:t></w:r>"#,
)))[0]
.0;
let left_stop_x = xs_of_texts(&layout(&tabbed_paragraph(
&format!(r#"<w:tab w:val="left" w:pos="{THREE_INCHES}"/>"#),
r#"<w:r><w:tab/><w:t>text</w:t></w:r>"#,
)))[0]
.1;
assert!(
(rule_x - left_stop_x).abs() < 0.01,
"rule at {rule_x}, a left stop at the same w:pos puts text at {left_stop_x}",
);
}
#[test]
fn a_bar_stop_draws_its_rule_with_no_tab_character_in_the_paragraph() {
let pages = layout(&tabbed_paragraph(
&format!(r#"<w:tab w:val="bar" w:pos="{THREE_INCHES}"/>"#),
r#"<w:r><w:t>no tab here</w:t></w:r>"#,
));
assert_eq!(vertical_rules(&pages).len(), 1);
}
#[test]
fn an_empty_paragraph_with_a_bar_stop_still_draws_its_rule() {
let pages = layout(&tabbed_paragraph(
&format!(r#"<w:tab w:val="bar" w:pos="{THREE_INCHES}"/>"#),
"",
));
assert_eq!(vertical_rules(&pages).len(), 1);
}
#[test]
fn a_bar_stop_draws_on_every_line_and_the_rules_abut() {
let pages = layout(&tabbed_paragraph(
&format!(r#"<w:tab w:val="bar" w:pos="{THREE_INCHES}"/>"#),
&format!(r#"<w:r><w:t>{}</w:t></w:r>"#, "wrapping ".repeat(60)),
));
let rules = vertical_rules(&pages);
assert!(
rules.len() >= 3,
"the fixture must wrap to several lines: {rules:?}",
);
for w in rules.windows(2) {
assert!(
(w[0].0 - w[1].0).abs() < 0.001,
"every line's rule is at the same x: {rules:?}",
);
assert!(
(w[0].2 - w[1].1).abs() < 0.01,
"each rule ends where the next begins: {rules:?}",
);
}
}
#[test]
fn a_tab_character_passes_over_a_bar_stop_to_the_next_real_one() {
let with_bar = xs_of_texts(&layout(&tabbed_paragraph(
r#"<w:tab w:val="bar" w:pos="4320"/><w:tab w:val="left" w:pos="7200"/>"#,
r#"<w:r><w:tab/><w:t>text</w:t></w:r>"#,
)))[0]
.1;
let without_bar = xs_of_texts(&layout(&tabbed_paragraph(
r#"<w:tab w:val="left" w:pos="7200"/>"#,
r#"<w:r><w:tab/><w:t>text</w:t></w:r>"#,
)))[0]
.1;
assert!(
(with_bar - without_bar).abs() < 0.01,
"the bar must be invisible to the tab: with it {with_bar}, without it {without_bar}",
);
}
#[test]
fn a_lone_bar_stop_leaves_a_tab_to_the_default_interval() {
let bar_only = xs_of_texts(&layout(&tabbed_paragraph(
r#"<w:tab w:val="bar" w:pos="3744"/>"#,
r#"<w:r><w:tab/><w:t>text</w:t></w:r>"#,
)))[0]
.1;
let no_tabs_at_all = xs_of_texts(&layout(&tabbed_paragraph(
"",
r#"<w:r><w:tab/><w:t>text</w:t></w:r>"#,
)))[0]
.1;
assert!(
(bar_only - no_tabs_at_all).abs() < 0.01,
"a lone bar leaves tabbing exactly as it was: {bar_only} vs {no_tabs_at_all}",
);
}
#[test]
fn adding_a_bar_stop_moves_no_content() {
let body = format!(
r#"<w:r><w:t>ab</w:t></w:r><w:r><w:tab/><w:t>{}</w:t></w:r>"#,
"wrapping ".repeat(40)
);
let without = xs_of_texts(&layout(&tabbed_paragraph(
r#"<w:tab w:val="left" w:pos="7200"/>"#,
&body,
)));
let with = xs_of_texts(&layout(&tabbed_paragraph(
r#"<w:tab w:val="bar" w:pos="4320"/><w:tab w:val="left" w:pos="7200"/>"#,
&body,
)));
assert!(without.len() > 2, "the fixture must wrap: {without:?}");
assert_eq!(without, with, "the bar rule must not disturb line fitting");
}
#[test]
fn two_bar_stops_draw_two_rules_per_line() {
let pages = layout(&tabbed_paragraph(
r#"<w:tab w:val="bar" w:pos="2880"/><w:tab w:val="bar" w:pos="4320"/>"#,
r#"<w:r><w:t>text</w:t></w:r>"#,
));
let rules = vertical_rules(&pages);
assert_eq!(rules.len(), 2, "{rules:?}");
assert!(rules[0].0 < rules[1].0, "distinct positions: {rules:?}");
}
#[test]
fn a_bar_stop_beyond_the_lines_content_still_draws() {
let pages = layout(&tabbed_paragraph(
r#"<w:tab w:val="bar" w:pos="6480"/>"#,
r#"<w:r><w:t>hi</w:t></w:r>"#,
));
let rules = vertical_rules(&pages);
assert_eq!(rules.len(), 1, "{rules:?}");
let text_end = xs_of_texts(&pages)[0].1;
assert!(
rules[0].0 > text_end + 100.0,
"the rule is far right of the content: rule {:?}, text at {text_end}",
rules[0],
);
}
#[test]
fn the_rule_takes_the_paragraphs_text_colour() {
let pages = layout(&tabbed_paragraph(
&format!(r#"<w:tab w:val="bar" w:pos="{THREE_INCHES}"/>"#),
r#"<w:r><w:rPr><w:color w:val="FF0000"/></w:rPr><w:t>red</w:t></w:r>"#,
));
let rules = vertical_rules(&pages);
assert_eq!(rules.len(), 1, "{rules:?}");
assert_eq!(
(rules[0].3.r, rules[0].3.g, rules[0].3.b),
(0xFF, 0x00, 0x00),
"the rule follows the run's colour: {rules:?}",
);
}
#[test]
fn only_the_bar_entry_draws_when_a_paragraph_mixes_stop_kinds() {
let mixed = vertical_rules(&layout(&tabbed_paragraph(
r#"<w:tab w:val="left" w:pos="2880"/><w:tab w:val="bar" w:pos="4320"/><w:tab w:val="right" w:pos="7200"/>"#,
r#"<w:r><w:tab/><w:t>text</w:t></w:r>"#,
)));
assert_eq!(mixed.len(), 1, "one bar entry, one rule: {mixed:?}");
let bar_only = vertical_rules(&layout(&tabbed_paragraph(
&format!(r#"<w:tab w:val="bar" w:pos="{THREE_INCHES}"/>"#),
r#"<w:r><w:t>text</w:t></w:r>"#,
)));
assert!(
(mixed[0].0 - bar_only[0].0).abs() < 0.01,
"the rule is at the bar's position: {mixed:?} vs {bar_only:?}",
);
}
#[test]
fn stops_that_are_not_bars_draw_no_rule() {
for val in ["left", "center", "right", "decimal"] {
let pages = layout(&tabbed_paragraph(
&format!(r#"<w:tab w:val="{val}" w:pos="{THREE_INCHES}"/>"#),
r#"<w:r><w:tab/><w:t>text</w:t></w:r>"#,
));
assert!(
vertical_rules(&pages).is_empty(),
"{val} draws no vertical rule: {:?}",
vertical_rules(&pages),
);
}
}