use std::collections::HashMap;
use dxpdf::model::*;
use dxpdf::render::layout::draw_command::{DrawCommand, LayoutedPage};
use dxpdf::render::resolve_and_layout;
fn empty_document() -> Document {
Document {
settings: DocumentSettings::default(),
theme: None,
styles: StyleSheet::default(),
numbering: NumberingDefinitions::default(),
body: vec![],
final_section: SectionProperties::default(),
headers: HashMap::new(),
footers: HashMap::new(),
footnotes: HashMap::new(),
endnotes: HashMap::new(),
media: HashMap::new(),
embedded_fonts: vec![],
}
}
fn run_with(elements: Vec<RunElement>) -> Inline {
Inline::TextRun(Box::new(TextRun {
style_id: None,
properties: RunProperties::default(),
content: elements,
rsids: RevisionIds::default(),
}))
}
fn para(text: &str) -> Block {
Block::Paragraph(Box::new(Paragraph {
style_id: None,
properties: ParagraphProperties::default(),
mark_run_properties: None,
content: vec![run_with(vec![RunElement::Text(text.to_string())])],
rsids: ParagraphRevisionIds::default(),
}))
}
fn para_after_page_break(text: &str) -> Block {
Block::Paragraph(Box::new(Paragraph {
style_id: None,
properties: ParagraphProperties::default(),
mark_run_properties: None,
content: vec![run_with(vec![
RunElement::PageBreak,
RunElement::Text(text.to_string()),
])],
rsids: ParagraphRevisionIds::default(),
}))
}
fn page_text(page: &LayoutedPage) -> String {
let mut out = String::new();
for cmd in &page.commands {
if let DrawCommand::Text { text, .. } = cmd {
out.push_str(text);
out.push(' ');
}
}
out
}
#[test]
fn title_page_uses_first_header_on_page_one_default_after() {
let mut doc = empty_document();
let r_default = RelId::new("rD");
let r_first = RelId::new("rF");
doc.headers
.insert(r_default.clone(), vec![para("DEFAULT_HEADER_TEXT")]);
doc.headers
.insert(r_first.clone(), vec![para("FIRST_HEADER_TEXT")]);
doc.final_section = SectionProperties {
header_refs: SectionHeaderFooterRefs {
default: Some(r_default),
first: Some(r_first),
even: None,
},
title_page: Some(true),
..Default::default()
};
doc.body = vec![para("body p1"), para_after_page_break("body p2")];
let (_, pages) = resolve_and_layout(&doc);
assert_eq!(pages.len(), 2, "expected 2 pages, got {}", pages.len());
let p1 = page_text(&pages[0]);
assert!(
p1.contains("FIRST_HEADER_TEXT"),
"page 1 must show first header; got: {p1:?}"
);
assert!(
!p1.contains("DEFAULT_HEADER_TEXT"),
"page 1 must NOT show default header; got: {p1:?}"
);
let p2 = page_text(&pages[1]);
assert!(
p2.contains("DEFAULT_HEADER_TEXT"),
"page 2 must show default header; got: {p2:?}"
);
assert!(
!p2.contains("FIRST_HEADER_TEXT"),
"page 2 must NOT show first header; got: {p2:?}"
);
}
#[test]
fn title_page_without_first_slot_blanks_page_one() {
let mut doc = empty_document();
let r_default = RelId::new("rD");
doc.headers
.insert(r_default.clone(), vec![para("DEFAULT_HEADER_TEXT")]);
doc.final_section = SectionProperties {
header_refs: SectionHeaderFooterRefs {
default: Some(r_default),
first: None,
even: None,
},
title_page: Some(true),
..Default::default()
};
doc.body = vec![para("body p1"), para_after_page_break("body p2")];
let (_, pages) = resolve_and_layout(&doc);
let p1 = page_text(&pages[0]);
assert!(
!p1.contains("DEFAULT_HEADER_TEXT"),
"title page must be blank, not fall back to default; got: {p1:?}"
);
let p2 = page_text(&pages[1]);
assert!(
p2.contains("DEFAULT_HEADER_TEXT"),
"page 2 still shows default; got: {p2:?}"
);
}
#[test]
fn title_page_flag_off_keeps_default_on_page_one() {
let mut doc = empty_document();
let r_default = RelId::new("rD");
let r_first = RelId::new("rF");
doc.headers
.insert(r_default.clone(), vec![para("DEFAULT_HEADER_TEXT")]);
doc.headers
.insert(r_first.clone(), vec![para("FIRST_HEADER_TEXT")]);
doc.final_section = SectionProperties {
header_refs: SectionHeaderFooterRefs {
default: Some(r_default),
first: Some(r_first),
even: None,
},
title_page: None,
..Default::default()
};
doc.body = vec![para("body p1"), para_after_page_break("body p2")];
let (_, pages) = resolve_and_layout(&doc);
for (i, page) in pages.iter().enumerate() {
let t = page_text(page);
assert!(
t.contains("DEFAULT_HEADER_TEXT"),
"page {i} must show default header without titlePg; got: {t:?}"
);
assert!(
!t.contains("FIRST_HEADER_TEXT"),
"page {i} must NOT show first header without titlePg; got: {t:?}"
);
}
}
#[test]
fn even_and_odd_alternates_headers_across_three_pages() {
let mut doc = empty_document();
let r_default = RelId::new("rD");
let r_even = RelId::new("rE");
doc.headers
.insert(r_default.clone(), vec![para("ODD_HEADER")]);
doc.headers
.insert(r_even.clone(), vec![para("EVEN_HEADER")]);
doc.settings.even_and_odd_headers = true;
doc.final_section = SectionProperties {
header_refs: SectionHeaderFooterRefs {
default: Some(r_default),
first: None,
even: Some(r_even),
},
title_page: None,
..Default::default()
};
doc.body = vec![
para("p1"),
para_after_page_break("p2"),
para_after_page_break("p3"),
];
let (_, pages) = resolve_and_layout(&doc);
assert_eq!(pages.len(), 3);
let p1 = page_text(&pages[0]);
assert!(
p1.contains("ODD_HEADER"),
"page 1 (odd) → odd header; got {p1:?}"
);
assert!(!p1.contains("EVEN_HEADER"));
let p2 = page_text(&pages[1]);
assert!(
p2.contains("EVEN_HEADER"),
"page 2 (even) → even header; got {p2:?}"
);
assert!(!p2.contains("ODD_HEADER"));
let p3 = page_text(&pages[2]);
assert!(
p3.contains("ODD_HEADER"),
"page 3 (odd) → odd header; got {p3:?}"
);
}
#[test]
fn even_and_odd_disabled_keeps_default_on_every_page() {
let mut doc = empty_document();
let r_default = RelId::new("rD");
let r_even = RelId::new("rE");
doc.headers
.insert(r_default.clone(), vec![para("ODD_HEADER")]);
doc.headers
.insert(r_even.clone(), vec![para("EVEN_HEADER")]);
doc.settings.even_and_odd_headers = false;
doc.final_section = SectionProperties {
header_refs: SectionHeaderFooterRefs {
default: Some(r_default),
first: None,
even: Some(r_even),
},
..Default::default()
};
doc.body = vec![para("p1"), para_after_page_break("p2")];
let (_, pages) = resolve_and_layout(&doc);
for (i, page) in pages.iter().enumerate() {
let t = page_text(page);
assert!(
t.contains("ODD_HEADER"),
"page {i} must use default; got {t:?}"
);
assert!(
!t.contains("EVEN_HEADER"),
"page {i} must not use even slot when flag is off; got {t:?}"
);
}
}
#[test]
fn even_and_odd_with_no_even_slot_blanks_even_pages() {
let mut doc = empty_document();
let r_default = RelId::new("rD");
doc.headers
.insert(r_default.clone(), vec![para("ODD_HEADER")]);
doc.settings.even_and_odd_headers = true;
doc.final_section = SectionProperties {
header_refs: SectionHeaderFooterRefs {
default: Some(r_default),
first: None,
even: None,
},
..Default::default()
};
doc.body = vec![para("p1"), para_after_page_break("p2")];
let (_, pages) = resolve_and_layout(&doc);
let p1 = page_text(&pages[0]);
assert!(p1.contains("ODD_HEADER"));
let p2 = page_text(&pages[1]);
assert!(
!p2.contains("ODD_HEADER"),
"page 2 must be blank when `even` slot is empty; got {p2:?}"
);
}
#[test]
fn title_page_takes_precedence_over_even_and_odd_on_page_one() {
let mut doc = empty_document();
let r_default = RelId::new("rD");
let r_first = RelId::new("rF");
let r_even = RelId::new("rE");
doc.headers
.insert(r_default.clone(), vec![para("ODD_HEADER")]);
doc.headers
.insert(r_first.clone(), vec![para("FIRST_HEADER")]);
doc.headers
.insert(r_even.clone(), vec![para("EVEN_HEADER")]);
doc.settings.even_and_odd_headers = true;
doc.final_section = SectionProperties {
header_refs: SectionHeaderFooterRefs {
default: Some(r_default),
first: Some(r_first),
even: Some(r_even),
},
title_page: Some(true),
..Default::default()
};
doc.body = vec![
para("p1"),
para_after_page_break("p2"),
para_after_page_break("p3"),
];
let (_, pages) = resolve_and_layout(&doc);
assert_eq!(pages.len(), 3);
let p1 = page_text(&pages[0]);
assert!(
p1.contains("FIRST_HEADER"),
"page 1 (titlePg + even/odd both on) → first wins; got {p1:?}"
);
assert!(!p1.contains("ODD_HEADER"));
assert!(!p1.contains("EVEN_HEADER"));
let p2 = page_text(&pages[1]);
assert!(
p2.contains("EVEN_HEADER"),
"page 2 → even (titlePg only fires on page 1); got {p2:?}"
);
let p3 = page_text(&pages[2]);
assert!(
p3.contains("ODD_HEADER"),
"page 3 → odd default; got {p3:?}"
);
}
#[test]
fn pg_num_type_start_two_makes_first_page_even_for_selection() {
use dxpdf::model::PageNumberType;
let mut doc = empty_document();
let r_default = RelId::new("rD");
let r_even = RelId::new("rE");
doc.headers
.insert(r_default.clone(), vec![para("ODD_HEADER")]);
doc.headers
.insert(r_even.clone(), vec![para("EVEN_HEADER")]);
doc.settings.even_and_odd_headers = true;
doc.final_section = SectionProperties {
header_refs: SectionHeaderFooterRefs {
default: Some(r_default),
first: None,
even: Some(r_even),
},
page_number_type: Some(PageNumberType {
format: None,
start: Some(2),
chap_style: None,
chap_sep: None,
}),
..Default::default()
};
doc.body = vec![para("p1"), para_after_page_break("p2")];
let (_, pages) = resolve_and_layout(&doc);
assert_eq!(pages.len(), 2);
let p1 = page_text(&pages[0]);
assert!(
p1.contains("EVEN_HEADER"),
"physical page 1 = logical page 2 (even) → even header; got {p1:?}"
);
assert!(!p1.contains("ODD_HEADER"));
let p2 = page_text(&pages[1]);
assert!(
p2.contains("ODD_HEADER"),
"physical page 2 = logical page 3 (odd) → default; got {p2:?}"
);
}
#[test]
fn pg_num_type_start_renders_in_page_field_in_header() {
use dxpdf::field::{CommonSwitches, FieldInstruction};
use dxpdf::model::{Field, PageNumberType};
let mut doc = empty_document();
let r_default = RelId::new("rD");
let page_field_para = Block::Paragraph(Box::new(Paragraph {
style_id: None,
properties: ParagraphProperties::default(),
mark_run_properties: None,
content: vec![Inline::Field(Field {
instruction: FieldInstruction::Page {
switches: CommonSwitches::default(),
},
content: vec![run_with(vec![RunElement::Text("999".into())])],
})],
rsids: ParagraphRevisionIds::default(),
}));
doc.headers.insert(r_default.clone(), vec![page_field_para]);
doc.final_section = SectionProperties {
header_refs: SectionHeaderFooterRefs {
default: Some(r_default),
first: None,
even: None,
},
page_number_type: Some(PageNumberType {
format: None,
start: Some(5),
chap_style: None,
chap_sep: None,
}),
..Default::default()
};
doc.body = vec![para("p1"), para_after_page_break("p2")];
let (_, pages) = resolve_and_layout(&doc);
let p1 = page_text(&pages[0]);
let p2 = page_text(&pages[1]);
assert!(
p1.contains("5"),
"page 1 PAGE field renders logical 5 (start); got {p1:?}",
);
assert!(
p2.contains("6"),
"page 2 PAGE field renders logical 6; got {p2:?}",
);
assert!(
!p1.contains("999"),
"stale cached value must not appear; got {p1:?}",
);
}
#[test]
fn pg_num_type_continues_across_sections_without_start() {
let mut doc = empty_document();
let r_default = RelId::new("rD");
let r_even = RelId::new("rE");
doc.headers.insert(r_default.clone(), vec![para("ODD")]);
doc.headers.insert(r_even.clone(), vec![para("EVEN")]);
doc.settings.even_and_odd_headers = true;
let s1_break = SectionProperties {
section_type: Some(SectionType::NextPage),
header_refs: SectionHeaderFooterRefs {
default: Some(r_default.clone()),
first: None,
even: Some(r_even.clone()),
},
..Default::default()
};
doc.final_section = SectionProperties {
header_refs: SectionHeaderFooterRefs {
default: Some(r_default),
first: None,
even: Some(r_even),
},
..Default::default()
};
doc.body = vec![
para("S1"),
Block::SectionBreak(Box::new(s1_break)),
para("S2 p1"),
para_after_page_break("S2 p2"),
];
let (_, pages) = resolve_and_layout(&doc);
assert_eq!(pages.len(), 3, "expected 3 pages, got {}", pages.len());
assert!(page_text(&pages[0]).contains("ODD"), "logical 1 → odd");
assert!(
page_text(&pages[1]).contains("EVEN"),
"logical 2 → even (continued across section)",
);
assert!(page_text(&pages[2]).contains("ODD"), "logical 3 → odd");
}
#[test]
fn pg_num_type_start_resets_on_second_section() {
use dxpdf::model::PageNumberType;
let mut doc = empty_document();
let r_default = RelId::new("rD");
let r_even = RelId::new("rE");
doc.headers
.insert(r_default.clone(), vec![para("ODD_HEADER")]);
doc.headers
.insert(r_even.clone(), vec![para("EVEN_HEADER")]);
doc.settings.even_and_odd_headers = true;
let s1_break = SectionProperties {
section_type: Some(SectionType::NextPage),
header_refs: SectionHeaderFooterRefs {
default: Some(r_default.clone()),
first: None,
even: Some(r_even.clone()),
},
..Default::default()
};
doc.final_section = SectionProperties {
header_refs: SectionHeaderFooterRefs {
default: Some(r_default),
first: None,
even: Some(r_even),
},
page_number_type: Some(PageNumberType {
format: None,
start: Some(10),
chap_style: None,
chap_sep: None,
}),
..Default::default()
};
doc.body = vec![
para("S1"),
Block::SectionBreak(Box::new(s1_break)),
para("S2 p1"),
];
let (_, pages) = resolve_and_layout(&doc);
assert_eq!(pages.len(), 2);
let p1 = page_text(&pages[0]);
assert!(
p1.contains("ODD_HEADER"),
"S1 page 1 = logical 1; got {p1:?}"
);
let p2 = page_text(&pages[1]);
assert!(
p2.contains("EVEN_HEADER"),
"S2 page 1 = logical 10 (even due to start); got {p2:?}",
);
}
#[test]
fn vorlage_baustellenkoordinator_v12_page_one_header_is_blank() {
let path = std::path::Path::new("test-cases/vorlage_baustellenkoordinator_v12.docx");
if !path.exists() {
eprintln!("SKIPPED: {} not present", path.display());
return;
}
let bytes = std::fs::read(path).expect("read fixture");
let doc = dxpdf::docx::parse(&bytes).expect("parse fixture");
let (_, pages) = resolve_and_layout(&doc);
assert!(
pages.len() >= 2,
"expected at least 2 pages, got {}",
pages.len()
);
let header_text_on = |page_idx: usize| -> String {
let mut out = String::new();
for cmd in &pages[page_idx].commands {
if let DrawCommand::Text { text, position, .. } = cmd {
if position.y.raw() < 70.0 {
out.push_str(text);
out.push(' ');
}
}
}
out
};
let header_p1 = header_text_on(0);
let header_p2 = header_text_on(1);
assert!(
!header_p1.contains("Begehungs"),
"page 1 strict header zone must be blank (`first` slot is empty), got: {header_p1:?}"
);
assert!(
header_p2.contains("Begehungs"),
"page 2 must show the default header, got: {header_p2:?}"
);
}
#[test]
fn vorlage_baustellenkoordinator_v12_per_part_image_rels_do_not_collide() {
let path = std::path::Path::new("test-cases/vorlage_baustellenkoordinator_v12.docx");
if !path.exists() {
eprintln!("SKIPPED: {} not present", path.display());
return;
}
let bytes = std::fs::read(path).expect("read fixture");
let doc = dxpdf::docx::parse(&bytes).expect("parse fixture");
assert!(
!doc.media.contains_key(&dxpdf::model::RelId::new("rId1")),
"no media entry should be keyed by bare 'rId1' — that namespace \
is shared across parts"
);
let header3_logo = doc
.media
.get(&dxpdf::model::RelId::new("word/header3.xml::rId1"))
.expect("header3's image must have its own synthesized media entry");
let footer3_image = doc
.media
.get(&dxpdf::model::RelId::new("word/footer3.xml::rId1"))
.expect("footer3's image must have its own synthesized media entry");
assert_ne!(
header3_logo.0.len(),
footer3_image.0.len(),
"header and footer images must point at different bytes — if \
they're equal we may have re-introduced the rId collision"
);
}
#[test]
fn footer_selection_follows_same_rules_as_header() {
let mut doc = empty_document();
let r_default = RelId::new("fD");
let r_first = RelId::new("fF");
doc.footers
.insert(r_default.clone(), vec![para("DEFAULT_FOOTER")]);
doc.footers
.insert(r_first.clone(), vec![para("FIRST_FOOTER")]);
doc.final_section = SectionProperties {
footer_refs: SectionHeaderFooterRefs {
default: Some(r_default),
first: Some(r_first),
even: None,
},
title_page: Some(true),
..Default::default()
};
doc.body = vec![para("p1"), para_after_page_break("p2")];
let (_, pages) = resolve_and_layout(&doc);
let p1 = page_text(&pages[0]);
assert!(p1.contains("FIRST_FOOTER"), "page 1 footer; got {p1:?}");
assert!(!p1.contains("DEFAULT_FOOTER"));
let p2 = page_text(&pages[1]);
assert!(p2.contains("DEFAULT_FOOTER"), "page 2 footer; got {p2:?}");
assert!(!p2.contains("FIRST_FOOTER"));
}