use super::common::*;
use lopdf::Document;
fn page_streams(bytes: &[u8]) -> Vec<Vec<u8>> {
let doc = Document::load_mem(bytes).expect("rendered PDF must parse");
doc.page_iter()
.map(|pid| doc.get_page_content(pid).expect("page content stream"))
.collect()
}
fn page_contains(stream: &[u8], needle: &str) -> bool {
String::from_utf8_lossy(stream).contains(needle)
}
fn many_section_fixture(n_sections: usize) -> String {
let mut out = String::new();
for i in 1..=n_sections {
out.push_str(&format!("## Section {i}\n\n"));
out.push_str(&format!(
"BODYMARK{i} body sentence for section {i}. Lorem ipsum dolor sit \
amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut \
labore et dolore magna aliqua.\n\n"
));
out.push_str(
"Filler paragraph. Lorem ipsum dolor sit amet, consectetur \
adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore \
magna aliqua. Ut enim ad minim veniam.\n\n",
);
out.push_str(
"Second filler paragraph for variability. Quis nostrud \
exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\n",
);
if i % 3 == 0 {
out.push_str("- bullet one\n- bullet two\n- bullet three\n\n");
}
if i % 4 == 0 {
out.push_str("```\nlet x = 42;\n```\n\n");
}
}
out
}
#[test]
fn heading_lands_on_same_page_as_its_body() {
let md = many_section_fixture(20);
let bytes = render(&md, "");
let streams = page_streams(&bytes);
assert!(streams.len() >= 2, "fixture must span multiple pages");
for i in 1..=20 {
let heading_needle = format!("Section {i}");
let body_needle = format!("BODYMARK{i}");
let heading_page = streams
.iter()
.position(|s| page_contains(s, &heading_needle))
.unwrap_or_else(|| panic!("heading '{heading_needle}' not found in any page"));
let body_page = streams
.iter()
.position(|s| page_contains(s, &body_needle))
.unwrap_or_else(|| panic!("body '{body_needle}' not found in any page"));
assert_eq!(
heading_page, body_page,
"Section {i} orphaned: heading on page {} but body on page {}",
heading_page + 1,
body_page + 1
);
}
}
fn padded_until_bottom(filler_paragraphs: usize, probe: &str) -> String {
let mut out = String::new();
out.push_str("# Probe\n\n");
for i in 0..filler_paragraphs {
out.push_str(&format!("Filler {i}. "));
out.push_str(&"Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(4));
out.push_str("\n\n");
}
out.push_str(probe);
out
}
#[test]
fn multi_line_heading_stays_with_body() {
let probe = "## A long heading whose text overflows the column width and \
wraps onto a second visual line because the words just keep going\n\n\
MULTILINEBODY This is the first body sentence; it must land on the same \
page as the wrapped heading above.\n\n";
let mut checked = 0usize;
for n in 14..=30 {
let md = padded_until_bottom(n, probe);
let bytes = render(&md, "");
let streams = page_streams(&bytes);
if streams.len() < 2 {
continue;
}
let h_page = streams
.iter()
.position(|s| page_contains(s, "A long heading"));
let b_page = streams.iter().position(|s| page_contains(s, "MULTILINEBODY"));
if let (Some(h), Some(b)) = (h_page, b_page) {
checked += 1;
assert_eq!(
h, b,
"multi-line heading orphaned at filler={n}: heading on \
page {} but body on page {}",
h + 1,
b + 1
);
}
}
assert!(
checked > 0,
"no fixture in the sweep produced both heading and body — \
test would silently pass even on broken renderer"
);
}
#[test]
fn heading_followed_by_admonition_not_orphaned() {
let probe = "## Followed by an admonition\n\n\
> [!WARNING]\n\
> ADMOMARK warning body that should land on the same page as its heading.\n\n";
let mut checked = 0usize;
for n in 14..=30 {
let md = padded_until_bottom(n, probe);
let bytes = render(&md, "");
let streams = page_streams(&bytes);
if streams.len() < 2 {
continue;
}
let h_page = streams
.iter()
.position(|s| page_contains(s, "Followed by an admonition"));
let b_page = streams.iter().position(|s| page_contains(s, "ADMOMARK"));
if let (Some(h), Some(b)) = (h_page, b_page) {
checked += 1;
assert_eq!(
h, b,
"heading→admonition orphaned at filler={n}: heading on \
page {} but admonition on page {}",
h + 1,
b + 1
);
}
}
assert!(checked > 0, "no fixture in the sweep landed both heading and admonition body");
}
#[test]
fn heading_followed_by_code_block_not_orphaned() {
let probe = "## Followed by a code block\n\n\
```\n\
CODEMARK = \"first code line that must follow its heading\"\n\
let x = 1;\n\
```\n\n";
let mut checked = 0usize;
for n in 14..=30 {
let md = padded_until_bottom(n, probe);
let bytes = render(&md, "");
let streams = page_streams(&bytes);
if streams.len() < 2 {
continue;
}
let h_page = streams
.iter()
.position(|s| page_contains(s, "Followed by a code block"));
let b_page = streams.iter().position(|s| page_contains(s, "CODEMARK"));
if let (Some(h), Some(b)) = (h_page, b_page) {
checked += 1;
assert_eq!(
h, b,
"heading→code orphaned at filler={n}: heading on page {} \
but code on page {}",
h + 1,
b + 1
);
}
}
assert!(checked > 0, "no fixture landed both heading and code marker");
}
#[test]
fn heading_followed_by_list_not_orphaned() {
let probe = "## Followed by a list\n\n\
- BULLETMARK first bullet text that must land with its heading\n\
- second bullet\n\
- third bullet\n\n";
let mut checked = 0usize;
for n in 14..=30 {
let md = padded_until_bottom(n, probe);
let bytes = render(&md, "");
let streams = page_streams(&bytes);
if streams.len() < 2 {
continue;
}
let h_page = streams
.iter()
.position(|s| page_contains(s, "Followed by a list"));
let b_page = streams.iter().position(|s| page_contains(s, "BULLETMARK"));
if let (Some(h), Some(b)) = (h_page, b_page) {
checked += 1;
assert_eq!(
h, b,
"heading→list orphaned at filler={n}: heading on page {} \
but first bullet on page {}",
h + 1,
b + 1
);
}
}
assert!(checked > 0, "no fixture landed both heading and first bullet");
}
#[test]
fn long_list_renders_without_dropping_items() {
let mut md = String::from("# Bullet orphan probe\n\n");
for i in 0..120 {
md.push_str(&format!(
"- BULLET{i} item {i} with enough text to occupy a meaningful \
chunk of width on the page.\n"
));
}
md.push_str("\n");
let bytes = render(&md, "");
let streams = page_streams(&bytes);
assert!(
streams.len() >= 2,
"fixture must span multiple pages to exercise the orphan path"
);
let joined = streams
.iter()
.flat_map(|s| s.iter().copied())
.collect::<Vec<u8>>();
let joined_text = String::from_utf8_lossy(&joined);
for i in 0..120 {
let needle = format!("BULLET{i} ");
assert!(
joined_text.contains(&needle),
"item {i} missing from rendered output"
);
}
}
#[test]
fn footnotes_section_heading_stays_with_first_entry() {
let mut md = String::from("# Footnote orphan probe\n\n");
for i in 0..18 {
md.push_str(&format!("Filler {i}. "));
md.push_str(&"Lorem ipsum dolor sit amet. ".repeat(6));
md.push_str(" Reference[^1] tucked in.\n\n");
}
md.push_str(
"[^1]: FOOTNOTEDEFMARK the one footnote definition that must \
stay glued to its auto-emitted heading.\n",
);
let bytes = render(&md, "");
let streams = page_streams(&bytes);
let h_page = streams
.iter()
.position(|s| page_contains(s, "Footnotes"))
.expect("auto-emitted Footnotes heading not found");
let b_page = streams
.iter()
.position(|s| page_contains(s, "FOOTNOTEDEFMARK"))
.expect("footnote definition marker not found");
assert_eq!(
h_page, b_page,
"Footnotes heading on page {} orphaned from its definition on \
page {}",
h_page + 1,
b_page + 1
);
}
#[test]
fn admonition_label_stays_with_body_at_page_boundary() {
let mut checked = 0usize;
for n in 52..=62 {
let mut md = String::from("# t\n\n");
for i in 0..n {
md.push_str(&format!("P{i}. line\n\n"));
}
md.push_str(
"> [!CAUTION]\n> ADMOORPHAN long body content. Lorem ipsum dolor \
sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut \
labore et dolore magna aliqua.\n",
);
let bytes = render(&md, "");
let streams = page_streams(&bytes);
if streams.len() < 2 {
continue;
}
let label_page = streams.iter().position(|s| page_contains(s, "CAUTION"));
let body_page = streams.iter().position(|s| page_contains(s, "ADMOORPHAN"));
if let (Some(l), Some(b)) = (label_page, body_page) {
checked += 1;
assert_eq!(
l, b,
"admonition orphaned at filler={n}: label on page {} but \
body on page {}",
l + 1,
b + 1
);
}
}
assert!(
checked > 0,
"no fixture in the sweep produced both label and body — test would \
silently pass on a broken renderer"
);
}