use lightweight_pdf::*;
use lightweight_pdf_test_support as support;
#[test]
fn page_format_and_orientation_landscape_and_custom_sizes() {
let mut doc = Document::new(PageFormat::A4).landscape().margin(Margin::all(20.0));
doc.add(Text::new("A4 Landscape Document").size(18.0).bold());
let (bytes, warnings) = doc.render_with_diagnostics().expect("render should succeed");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/MediaBox [0 0 841.89 595.276]"));
}
#[test]
fn custom_page_format_and_orientation_works() {
let mut doc = Document::new(PageFormat::Custom(400.0, 600.0)).margin(Margin::all(10.0));
doc.add(Text::new("Custom Size").size(12.0));
let bytes = doc.render().expect("render should succeed");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/MediaBox [0 0 400 600]"));
}
#[test]
fn document_metadata_is_written_to_pdf_info_dictionary() {
let mut doc = Document::new(PageFormat::A4)
.title("Invoice #2026-104")
.author("Acme Software GmbH")
.subject("Quarterly Report")
.keywords("invoice, 2026, tax")
.creator("lightweight-pdf v0.2.0")
.creation_date(PdfDate::new(2026, 1, 15, 9, 30, 0))
.mod_date(PdfDate::new(2026, 1, 16, 10, 0, 0));
doc.add(Text::new("Metadata Test"));
let bytes = doc.render().expect("render should succeed");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/Title (Invoice #2026-104)"));
assert!(text.contains("/Author (Acme Software GmbH)"));
assert!(text.contains("/Subject (Quarterly Report)"));
assert!(text.contains("/Keywords (invoice, 2026, tax)"));
assert!(text.contains("/Creator (lightweight-pdf v0.2.0)"));
assert!(text.contains("/CreationDate (D:20260115093000Z)"));
assert!(text.contains("/ModDate (D:20260116100000Z)"));
assert!(text.contains(concat!("/Producer (lightweight-pdf ", env!("CARGO_PKG_VERSION"), ")")));
assert!(text.contains("/ID [<"));
assert!(text.contains("/Info"));
let info = support::pdfinfo(&bytes).unwrap();
assert!(info.contains("Invoice #2026-104"), "pdfinfo output was:\n{info}");
assert!(info.contains("Acme Software GmbH"), "pdfinfo output was:\n{info}");
assert!(info.contains("CreationDate:"), "pdfinfo output was:\n{info}");
assert!(info.contains("ModDate:"), "pdfinfo output was:\n{info}");
}
#[test]
fn document_id_is_deterministic_across_identical_renders() {
let build = || {
let mut doc = Document::new(PageFormat::A4).title("Determinism Check");
doc.add(Text::new("Same content, same /ID"));
doc.render().expect("render should succeed")
};
let first = build();
let second = build();
assert_eq!(
first, second,
"two renders of the same Document must be byte-identical, including /ID"
);
}
#[test]
fn rendering_is_deterministic_with_multiple_font_weights() {
let build = || {
let mut doc = Document::new(PageFormat::A4).title("Determinism Check");
doc.add(Text::new("Regular text"));
doc.add(Text::new("Bold text").bold());
doc.render().expect("render should succeed")
};
let first = build();
let second = build();
assert_eq!(
first, second,
"two renders of the same multi-font-weight Document must be byte-identical"
);
}
#[test]
fn table_cell_with_colspan_and_alignment() {
let mut doc = Document::new(PageFormat::A4).margin(Margin::all(30.0));
doc.add(
Table::new()
.columns([
TableColumn::flex(2.0),
TableColumn::fixed(80.0).align(Align::End),
TableColumn::fixed(80.0).align(Align::End),
])
.header(["Beschreibung", "Menge", "Gesamt"])
.rows(vec![
vec![
TableCell::from("Beratung Softwarearchitektur"),
TableCell::from("10 Std"),
TableCell::from("1.200,00 €"),
],
vec![
TableCell::new("Gesamtsumme (Netto)").colspan(2).align(Align::End),
TableCell::from("1.200,00 €"),
],
]),
);
let (bytes, warnings) = doc.render_with_diagnostics().expect("render should succeed");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
let extracted = support::pdftotext(&bytes).unwrap();
assert!(extracted.contains("Gesamtsumme (Netto)"));
assert!(extracted.contains("1.200,00 €"));
}
#[test]
fn rect_and_container_rounded_corners_and_dashed_borders() {
let mut doc = Document::new(PageFormat::A4).margin(Margin::all(40.0));
doc.add(
Column::new()
.padding(15.0)
.corner_radius(8.0)
.border(Border::dashed(2.0, Color::rgb(0, 102, 204), 4.0, 2.0))
.background(Color::rgb(240, 248, 255))
.child(Text::new("Karten-Box mit abgerundeten Ecken").bold()),
);
let (bytes, warnings) = doc.render_with_diagnostics().expect("render should succeed");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
let decompressed = support::decompressed(&bytes).unwrap();
let text = String::from_utf8_lossy(&decompressed);
assert!(text.contains(" c\n"), "rounded rect must emit Bezier curve 'c' operators");
assert!(text.contains("[4 2] 0 d"), "dashed border must emit PDF dash operator");
}
#[test]
fn text_with_hyperlink_url_emits_link_annotation() {
let mut doc = Document::new(PageFormat::A4).margin(Margin::all(40.0));
doc.add(
Text::new("Besuchen Sie unsere Website")
.color(Color::rgb(0, 102, 204))
.url("https://example.com"),
);
let bytes = doc.render().expect("render should succeed");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/Subtype /Link"));
assert!(text.contains("/URI (https://example.com)"));
assert!(text.contains("/Annots"));
}
fn dest_object_number(text: &str) -> u32 {
let after = text.split("/Dest [").nth(1).expect("expected a /Dest array in the output");
after
.split(' ')
.next()
.and_then(|n| n.parse().ok())
.expect("expected an object number right after '/Dest ['")
}
#[test]
fn link_to_jumps_to_a_different_page_than_a_same_page_anchor() {
let mut doc = Document::new(PageFormat::A4).margin(Margin::symmetric(56.0, 56.0));
doc.add(Text::new("Sprung zum Anhang").link_to("appendix"));
doc.add(Text::new("Toter Link").link_to("nonexistent-anchor"));
for i in 0..120 {
doc.add(Text::new(format!("Fuelltext Zeile {i:03}")));
}
doc.add(Text::new("Anhang").anchor("appendix"));
let (bytes, warnings) = doc.render_with_diagnostics().expect("render should succeed");
assert!(warnings.is_empty(), "unexpected layout warnings: {warnings:?}");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
let page_count = support::page_count(&bytes).unwrap();
assert!(page_count > 1, "expected the filler content to force a page break");
let text = String::from_utf8_lossy(&bytes);
assert_eq!(
text.matches("/Dest [").count(),
1,
"expected exactly one resolved internal link, got:\n{text}"
);
let dest_obj = dest_object_number(&text);
let dest_obj_decl = format!("\n{dest_obj} 0 obj");
assert!(text.contains(&dest_obj_decl), "destination object {dest_obj} not found in output");
let dest_obj_body = text.split(&dest_obj_decl).nth(1).unwrap().split("endobj").next().unwrap();
assert!(
dest_obj_body.contains("/Type /Page"),
"destination object {dest_obj} is not a /Type /Page object: {dest_obj_body}"
);
let kids = text.split("/Kids [").nth(1).unwrap().split(']').next().unwrap();
let first_page_obj: u32 = kids.split(' ').next().unwrap().parse().unwrap();
assert_ne!(
dest_obj, first_page_obj,
"internal link must not point back at its own (first) page"
);
}
fn find_object_containing<'a>(text: &'a str, needle: &str) -> &'a str {
let idx = text
.find(needle)
.unwrap_or_else(|| panic!("{needle:?} not found in output:\n{text}"));
let start = text[..idx].rfind("\nendobj\n").map(|i| i + "\nendobj\n".len()).unwrap_or(0);
let end = text[idx..].find("\nendobj").map(|i| idx + i).unwrap_or(text.len());
&text[start..end]
}
#[test]
fn document_without_headings_has_no_outlines_object() {
let mut doc = Document::new(PageFormat::A4);
doc.add(Text::new("Ganz gewoehnlicher Text, keine Ueberschrift"));
let bytes = doc.render().expect("render should succeed");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
let text = String::from_utf8_lossy(&bytes);
assert!(
!text.contains("/Outlines"),
"a document with no headings must not emit an /Outlines object"
);
}
#[test]
fn heading_hierarchy_produces_a_nested_outline_tree() {
let mut doc = Document::new(PageFormat::A4).margin(Margin::all(40.0));
doc.add(Text::new("Kapitel 1").heading1());
doc.add(Text::new("Abschnitt 1.1").heading2());
doc.add(Text::new("Abschnitt 1.2").heading2());
doc.add(Text::new("Kapitel 2").heading1());
let (bytes, warnings) = doc.render_with_diagnostics().expect("render should succeed");
assert!(warnings.is_empty(), "unexpected layout warnings: {warnings:?}");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
let text = String::from_utf8_lossy(&bytes);
assert_eq!(
text.matches("/Title (").count(),
4,
"expected one outline entry per heading, got:\n{text}"
);
assert!(
text.contains("/Type /Outlines /Count 4"),
"expected the root outline count to be 4, got:\n{text}"
);
let kapitel1 = find_object_containing(&text, "/Title (Kapitel 1)");
assert!(kapitel1.contains("/Count 2"), "Kapitel 1 should have 2 children:\n{kapitel1}");
assert!(kapitel1.contains("/Next"), "Kapitel 1 should have a /Next sibling:\n{kapitel1}");
assert!(
!kapitel1.contains("/Prev"),
"Kapitel 1 is the first root, must not have /Prev:\n{kapitel1}"
);
let abschnitt_1_2 = find_object_containing(&text, "/Title (Abschnitt 1.2)");
assert!(
abschnitt_1_2.contains("/Prev"),
"Abschnitt 1.2 should have a /Prev sibling:\n{abschnitt_1_2}"
);
assert!(
!abschnitt_1_2.contains("/Next"),
"Abschnitt 1.2 is the last child, must not have /Next:\n{abschnitt_1_2}"
);
assert!(
!abschnitt_1_2.contains("/Count"),
"a leaf outline entry must not have /Count:\n{abschnitt_1_2}"
);
}
#[test]
fn heading_on_a_later_page_jumps_to_the_correct_page() {
let mut doc = Document::new(PageFormat::A4).margin(Margin::symmetric(56.0, 56.0));
doc.add(Text::new("Start").heading1());
for i in 0..120 {
doc.add(Text::new(format!("Fuelltext Zeile {i:03}")));
}
doc.add(Text::new("Ende").heading1());
let (bytes, warnings) = doc.render_with_diagnostics().expect("render should succeed");
assert!(warnings.is_empty(), "unexpected layout warnings: {warnings:?}");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
let page_count = support::page_count(&bytes).unwrap();
assert!(page_count > 1, "expected the filler content to force a page break");
let text = String::from_utf8_lossy(&bytes);
let ende_obj = find_object_containing(&text, "/Title (Ende)");
let dest_obj = dest_object_number(ende_obj);
let dest_obj_decl = format!("\n{dest_obj} 0 obj");
let dest_obj_body = text.split(&dest_obj_decl).nth(1).unwrap().split("endobj").next().unwrap();
assert!(
dest_obj_body.contains("/Type /Page"),
"'Ende' heading's /Dest {dest_obj} is not a /Type /Page object: {dest_obj_body}"
);
let kids = text.split("/Kids [").nth(1).unwrap().split(']').next().unwrap();
let first_page_obj: u32 = kids.split(' ').next().unwrap().parse().unwrap();
assert_ne!(
dest_obj, first_page_obj,
"'Ende' heading landed on a later page, its bookmark must not point at page 1"
);
}
const CUSTOM_FONT: &[u8] = include_bytes!("../../lightweight-pdf-fonts/tests/fixtures/custom-test-font.ttf");
#[test]
fn italic_with_nothing_registered_under_the_key_is_a_typed_error_not_a_silent_fallback() {
let mut doc = Document::new(PageFormat::A4);
doc.add(Text::new("Kursiver Hinweis").italic());
let err = doc
.render()
.expect_err("no SANS_ITALIC registered — render must not silently substitute regular");
assert!(
matches!(err, RenderError::MissingFont(key) if key == FontKey::SANS_ITALIC),
"expected RenderError::MissingFont(SANS_ITALIC), got: {err:?}"
);
}
#[test]
fn rendering_with_an_empty_font_registry_is_a_typed_error_not_a_panic() {
let fonts = FontRegistry::empty();
let mut doc = Document::new(PageFormat::A4);
doc.add(Text::new("Hello"));
let err = doc
.render_with_fonts(&fonts)
.expect_err("nothing registered — render must not panic inside layout");
assert!(
matches!(err, RenderError::NoFontsRegistered),
"expected RenderError::NoFontsRegistered, got: {err:?}"
);
}
#[test]
fn italic_and_bold_italic_methods_work_once_a_font_is_registered_under_the_key() {
let mut fonts = FontRegistry::with_fonts(CUSTOM_FONT, CUSTOM_FONT).unwrap();
fonts.register(FontKey::SANS_ITALIC, CUSTOM_FONT).unwrap();
fonts.register(FontKey::SANS_BOLD_ITALIC, CUSTOM_FONT).unwrap();
let mut doc = Document::new(PageFormat::A4);
doc.add(Text::new("Kursiver Hinweis").italic());
doc.add(Text::new("Fetter kursiver Text").bold_italic());
let bytes = doc
.render_with_fonts(&fonts)
.expect("render should succeed once SANS_ITALIC/SANS_BOLD_ITALIC are registered");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
let extracted = support::pdftotext(&bytes).unwrap();
assert!(extracted.contains("Kursiver Hinweis"));
assert!(extracted.contains("Fetter kursiver Text"));
}
fn count_tj_ops(bytes: &[u8]) -> usize {
let decompressed = support::decompressed(bytes).expect("qpdf --stream-data=uncompress should succeed");
decompressed.windows(5).filter(|w| *w == b"Tj ET").count()
}
#[test]
fn justified_paragraph_stretches_every_line_but_the_last() {
let mut doc = Document::new(PageFormat::A4).margin(Margin::all(40.0));
doc.add(
Text::new("Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor")
.align(Align::Justify)
.width(220.0),
);
let (bytes, warnings) = doc.render_with_diagnostics().expect("render should succeed");
assert!(warnings.is_empty(), "unexpected layout warnings: {warnings:?}");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
let extracted = support::pdftotext(&bytes).unwrap();
let line_count = extracted.lines().filter(|l| !l.trim().is_empty()).count();
assert!(
line_count >= 2,
"expected the paragraph to wrap onto multiple lines, got:\n{extracted}"
);
let tj_count = count_tj_ops(&bytes);
assert!(
tj_count > line_count,
"expected more than one text run per justified line (one per word), got {tj_count} Tj ops across {line_count} lines"
);
for word in ["Lorem", "ipsum", "dolor", "tempor"] {
assert!(extracted.contains(word), "missing {word:?} in extracted text:\n{extracted}");
}
}
#[test]
fn justify_never_stretches_a_single_line_paragraph() {
let mut doc = Document::new(PageFormat::A4).margin(Margin::all(40.0));
doc.add(Text::new("Kurzer Satz").align(Align::Justify).width(300.0));
let bytes = doc.render().expect("render should succeed");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
assert_eq!(count_tj_ops(&bytes), 1);
}
#[test]
fn document_theme_resolves_untouched_text_but_not_explicitly_styled_text() {
let mut theme = Theme::default();
theme.body.size = 30.0;
theme.heading1.size = 60.0;
let mut doc = Document::new(PageFormat::A4).margin(Margin::all(40.0)).theme(theme);
doc.add(Text::new("Themed body text")); doc.add(Text::new("Explicit size").size(11.0)); doc.add(Text::new("Themed heading").heading1());
let (bytes, warnings) = doc.render_with_diagnostics().expect("render should succeed");
assert!(warnings.is_empty(), "unexpected layout warnings: {warnings:?}");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
let decompressed = support::decompressed(&bytes).unwrap();
let content = String::from_utf8_lossy(&decompressed);
assert!(
content.contains(" 30 Tf"),
"expected theme.body's size (30) in a Tf operator, got:\n{content}"
);
assert!(
content.contains(" 11 Tf"),
"expected the explicit .size(11.0) to survive theming, got:\n{content}"
);
assert!(
content.contains(" 60 Tf"),
"expected theme.heading1's size (60) for .heading1(), got:\n{content}"
);
}
#[test]
fn rich_text_renders_mixed_styles_on_one_paragraph() {
let mut doc = Document::new(PageFormat::A4).margin(Margin::all(40.0));
doc.add(Text::rich([
Span::new("Normal ", TextStyle::default()),
Span::new(
"big red",
TextStyle {
size: 30.0,
color: Color::rgb(200, 0, 0),
..Default::default()
},
),
Span::new(" tail", TextStyle::default()),
]));
let (bytes, warnings) = doc.render_with_diagnostics().expect("render should succeed");
assert!(warnings.is_empty(), "unexpected layout warnings: {warnings:?}");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
let extracted = support::pdftotext(&bytes).unwrap();
for word in ["Normal", "big", "red", "tail"] {
assert!(extracted.contains(word), "missing {word:?} in extracted text:\n{extracted}");
}
let decompressed = support::decompressed(&bytes).unwrap();
let content = String::from_utf8_lossy(&decompressed);
assert!(
content.contains(" 30 Tf"),
"expected the larger span's size (30) in a Tf operator, got:\n{content}"
);
assert!(
content.contains(" 12 Tf"),
"expected the default span size (12) in a Tf operator, got:\n{content}"
);
let tj_count = count_tj_ops(&bytes);
assert!(
tj_count >= 3,
"expected one text-showing run per span (3 spans), got {tj_count} Tj ops"
);
}
#[test]
fn soft_hyphen_breaks_at_a_narrow_width_and_never_leaks_into_extracted_text() {
let mut doc = Document::new(PageFormat::A4).margin(Margin::all(40.0));
doc.add(Text::new("Silben\u{AD}trennung").width(50.0));
let (bytes, warnings) = doc.render_with_diagnostics().expect("render should succeed");
assert!(warnings.is_empty(), "unexpected layout warnings: {warnings:?}");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
let extracted = support::pdftotext(&bytes).unwrap();
assert!(
!extracted.contains('\u{AD}'),
"soft hyphen must never appear in extracted text:\n{extracted}"
);
assert!(
extracted.contains('-'),
"expected a visible hyphen where the line broke:\n{extracted}"
);
}
#[test]
fn unused_soft_hyphen_is_invisible_when_the_word_fits() {
let mut doc = Document::new(PageFormat::A4).margin(Margin::all(40.0));
doc.add(Text::new("Silben\u{AD}trennung").width(300.0));
let bytes = doc.render().expect("render should succeed");
let extracted = support::pdftotext(&bytes).unwrap();
assert!(
!extracted.contains('\u{AD}'),
"soft hyphen must never appear in extracted text:\n{extracted}"
);
assert!(
!extracted.contains('-'),
"no break occurred, so no hyphen should have been drawn:\n{extracted}"
);
assert!(extracted.replace(['\n', ' '], "").contains("Silbentrennung"));
}
#[cfg(feature = "hyphenation")]
#[test]
fn automatic_hyphenation_breaks_a_long_compound_word_with_no_authored_soft_hyphen() {
let mut doc = Document::new(PageFormat::A4).margin(Margin::all(40.0));
doc.add(Text::new("Silbentrennung").width(50.0).hyphenate(HyphenationLanguage::German));
let (bytes, warnings) = doc.render_with_diagnostics().expect("render should succeed");
assert!(warnings.is_empty(), "unexpected layout warnings: {warnings:?}");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
let extracted = support::pdftotext(&bytes).unwrap();
assert!(
!extracted.contains('\u{AD}'),
"soft hyphen must never appear in extracted text:\n{extracted}"
);
assert!(
extracted.contains('-'),
"expected automatic hyphenation to break the word with a visible hyphen:\n{extracted}"
);
}
#[test]
fn table_of_contents_page_numbers_match_actual_heading_pages() {
let mut doc = Document::new(PageFormat::A4).margin(Margin::all(40.0));
doc.add(TableOfContents::new());
doc.add(Element::PageBreak);
doc.add(Text::new("Chapter One").heading1());
doc.add(Element::PageBreak);
doc.add(Text::new("Chapter Two").heading1());
doc.add(Element::PageBreak);
doc.add(Text::new("Chapter Three").heading1());
let (bytes, warnings) = doc.render_with_diagnostics().expect("render should succeed");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
assert_eq!(support::page_count(&bytes).unwrap(), 4);
let toc_page = support::pdftotext_page(&bytes, 1).unwrap();
for (title, expected_page) in [("Chapter One", "2"), ("Chapter Two", "3"), ("Chapter Three", "4")] {
let line = toc_page
.lines()
.find(|l| l.contains(title))
.unwrap_or_else(|| panic!("expected a TOC entry for {title:?}, got:\n{toc_page}"));
assert!(
line.trim_end().ends_with(expected_page),
"expected {title:?}'s TOC entry to end with page {expected_page}, got: {line:?}"
);
}
assert!(support::pdftotext_page(&bytes, 2).unwrap().contains("Chapter One"));
assert!(support::pdftotext_page(&bytes, 3).unwrap().contains("Chapter Two"));
assert!(support::pdftotext_page(&bytes, 4).unwrap().contains("Chapter Three"));
}
#[test]
fn table_of_contents_itself_splits_across_pages_when_it_has_many_entries() {
let mut doc = Document::new(PageFormat::A4).margin(Margin::all(40.0));
doc.add(TableOfContents::new());
for i in 0..80 {
doc.add(Text::new(format!("Section {i}")).outline_level(1));
}
let (bytes, warnings) = doc.render_with_diagnostics().expect("render should succeed");
assert!(warnings.is_empty(), "unexpected layout warnings: {warnings:?}");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
let page_count = support::page_count(&bytes).unwrap();
assert!(
page_count >= 2,
"expected 80 entries to overflow a single page's table of contents, got {page_count} pages total"
);
let page1 = support::pdftotext_page(&bytes, 1).unwrap();
assert!(page1.contains("Section 0"), "expected the first entries on page 1:\n{page1}");
assert!(
!page1.contains("Section 79"),
"expected the table of contents to have overflowed onto a later page by entry 79:\n{page1}"
);
}
#[test]
fn table_of_contents_with_no_matching_headings_renders_without_looping_or_warnings() {
let mut doc = Document::new(PageFormat::A4).margin(Margin::all(40.0));
doc.add(TableOfContents::new());
doc.add(Text::new("No headings anywhere in this document, just body text."));
let (bytes, warnings) = doc.render_with_diagnostics().expect("render should succeed");
assert!(warnings.is_empty(), "unexpected layout warnings: {warnings:?}");
let (ok, log) = support::qpdf_check(&bytes).unwrap();
assert!(ok, "qpdf check failed: {log}");
assert_eq!(support::page_count(&bytes).unwrap(), 1);
}