mod floating_table;
mod helpers;
mod layout;
mod stacker;
mod types;
pub use layout::layout_section;
pub(crate) use layout::layout_section_with_clearance;
pub use stacker::{stack_blocks, CellLine, StackResult};
pub use types::*;
const FOOTNOTE_SEPARATOR_RATIO: f32 = 0.33;
const FOOTNOTE_SEPARATOR_LINE_WIDTH: crate::render::dimension::Pt =
crate::render::dimension::Pt::new(0.5);
const FOOTNOTE_SEPARATOR_GAP: crate::render::dimension::Pt = crate::render::dimension::Pt::new(4.0);
const FLOAT_DEDUP_EPSILON_PT: f32 = 0.1;
#[cfg(test)]
mod tests {
use super::*;
use crate::render::dimension::Pt;
use crate::render::geometry::{PtEdgeInsets, PtSize};
use crate::render::layout::draw_command::DrawCommand;
use crate::render::layout::fragment::Fragment;
use crate::render::layout::fragment::{FontProps, TextMetrics};
use crate::render::layout::header_footer::HeaderFooterClearance;
use crate::render::layout::page::PageConfig;
use crate::render::layout::paragraph::ParagraphStyle;
use crate::render::layout::table::{
TableBorderConfig, TableBorderLine, TableBorderStyle, TableCellInput, TableRowInput,
};
use crate::render::resolve::color::RgbColor;
use crate::render::resolve::header_footer::HeaderFooterSet;
use crate::render::resolve::images::MediaEntry;
use std::cell::Cell;
use std::rc::Rc;
use std::sync::{Mutex, Once};
struct TableWarningLogger;
thread_local! {
static CAPTURE_TABLE_WARNINGS: Cell<bool> = const { Cell::new(false) };
}
static TABLE_WARNING_LOGGER: TableWarningLogger = TableWarningLogger;
static TABLE_WARNING_LOGGER_INIT: Once = Once::new();
static TABLE_WARNINGS: Mutex<Vec<String>> = Mutex::new(Vec::new());
impl log::Log for TableWarningLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= log::Level::Warn
}
fn log(&self, record: &log::Record<'_>) {
if self.enabled(record.metadata())
&& record.target().starts_with("dxpdf::render::layout::table")
&& CAPTURE_TABLE_WARNINGS.with(Cell::get)
{
TABLE_WARNINGS
.lock()
.unwrap()
.push(record.args().to_string());
}
}
fn flush(&self) {}
}
fn capture_table_warnings<T>(run: impl FnOnce() -> T) -> (T, Vec<String>) {
TABLE_WARNING_LOGGER_INIT.call_once(|| {
log::set_logger(&TABLE_WARNING_LOGGER).unwrap();
log::set_max_level(log::LevelFilter::Warn);
});
TABLE_WARNINGS.lock().unwrap().clear();
CAPTURE_TABLE_WARNINGS.with(|capture| capture.set(true));
let result = run();
CAPTURE_TABLE_WARNINGS.with(|capture| capture.set(false));
let warnings = std::mem::take(&mut *TABLE_WARNINGS.lock().unwrap());
(result, warnings)
}
fn text_frag(text: &str, width: f32, height: f32) -> Fragment {
Fragment::Text {
text: text.into(),
font: Rc::new(FontProps {
family: Rc::from("Test"),
size: Pt::new(12.0),
bold: false,
italic: false,
underline: false,
char_spacing: Pt::ZERO,
text_scale: 1.0,
underline_position: Pt::ZERO,
underline_thickness: Pt::ZERO,
}),
color: RgbColor::BLACK,
width: Pt::new(width),
trimmed_width: Pt::new(width),
metrics: TextMetrics {
ascent: Pt::new(height * 0.7),
descent: Pt::new(height * 0.3),
leading: Pt::ZERO,
},
hyperlink_url: None,
shading: None,
border: None,
baseline_offset: Pt::ZERO,
text_offset: Pt::ZERO,
is_footnote_ref: false,
}
}
fn para_block(text: &str, width: f32) -> LayoutBlock {
LayoutBlock::Paragraph {
fragments: vec![text_frag(text, width, 14.0)],
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
}
}
fn floating_image(wrap_mode: WrapMode, height: f32) -> FloatingImage {
FloatingImage {
image_data: MediaEntry {
data: Rc::from([]),
format: crate::model::ImageFormat::Png,
},
size: PtSize::new(Pt::new(80.0), Pt::new(height)),
src_rect: None,
x: Pt::new(10.0),
y: FloatingImageY::RelativeToParagraph(Pt::ZERO),
wrap_mode,
dist_left: Pt::ZERO,
dist_right: Pt::ZERO,
behind_doc: false,
}
}
fn absolute_floating_image(wrap_mode: WrapMode, y: f32, height: f32) -> FloatingImage {
FloatingImage {
y: FloatingImageY::Absolute(Pt::new(y)),
..floating_image(wrap_mode, height)
}
}
fn styled_para_block(text: &str, keep_next: bool, page_break_before: bool) -> LayoutBlock {
LayoutBlock::Paragraph {
fragments: vec![text_frag(text, 30.0, 14.0)],
style: ParagraphStyle {
keep_next,
..Default::default()
},
page_break_before,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
}
}
fn one_column_table(rows: &[(&str, bool)]) -> LayoutBlock {
LayoutBlock::Table {
rows: rows
.iter()
.map(|(text, header)| TableRowInput {
cells: vec![TableCellInput {
blocks: vec![para_block(text, 30.0)],
margins: PtEdgeInsets::ZERO,
grid_span: 1,
shading: None,
cell_borders: None,
vertical_merge: None,
vertical_align: crate::render::layout::table::CellVAlign::Top,
}],
height_rule: None,
is_header: Some(*header),
cant_split: Some(true),
grid_before: 0,
grid_after: 0,
border_overrides: None,
})
.collect(),
col_widths: vec![Pt::new(100.0)],
border_config: None,
indent: Pt::ZERO,
alignment: None,
float_info: None,
style_id: None,
}
}
fn small_config() -> PageConfig {
use crate::render::layout::page::ColumnGeometry;
PageConfig {
page_size: PtSize::new(Pt::new(200.0), Pt::new(100.0)),
margins: PtEdgeInsets::new(Pt::new(10.0), Pt::new(10.0), Pt::new(10.0), Pt::new(10.0)),
header_margin: Pt::new(5.0),
footer_margin: Pt::new(5.0),
columns: vec![ColumnGeometry {
x_offset: Pt::ZERO,
width: Pt::new(180.0),
}],
}
}
#[test]
fn empty_blocks_produces_one_empty_page() {
let pages = layout_section(&[], &small_config(), None, Pt::ZERO, Pt::new(14.0), None);
assert_eq!(pages.len(), 1);
assert!(pages[0].commands.is_empty());
}
#[test]
fn single_paragraph_on_one_page() {
let blocks = vec![para_block("hello", 30.0)];
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 1);
let text_count = pages[0]
.commands
.iter()
.filter(|c| matches!(c, DrawCommand::Text { .. }))
.count();
assert_eq!(text_count, 1);
}
fn multiline_para(n: usize, keep_lines: bool, widow_control: bool) -> LayoutBlock {
let fragments = (0..n)
.map(|i| text_frag(&format!("word{i} "), 100.0, 14.0))
.collect();
LayoutBlock::Paragraph {
fragments,
style: ParagraphStyle {
keep_lines,
widow_control,
..Default::default()
},
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
}
}
fn text_count(commands: &[DrawCommand]) -> usize {
commands
.iter()
.filter(|c| matches!(c, DrawCommand::Text { .. }))
.count()
}
fn two_column_config() -> PageConfig {
use crate::render::layout::page::ColumnGeometry;
PageConfig {
page_size: PtSize::new(Pt::new(200.0), Pt::new(100.0)),
margins: PtEdgeInsets::new(Pt::new(10.0), Pt::new(10.0), Pt::new(10.0), Pt::new(10.0)),
header_margin: Pt::new(5.0),
footer_margin: Pt::new(5.0),
columns: vec![
ColumnGeometry {
x_offset: Pt::ZERO,
width: Pt::new(85.0),
},
ColumnGeometry {
x_offset: Pt::new(95.0),
width: Pt::new(85.0),
},
],
}
}
#[test]
fn paragraph_splits_across_columns() {
let fragments: Vec<Fragment> = (0..12)
.map(|i| text_frag(&format!("word{i} "), 60.0, 14.0))
.collect();
let block = LayoutBlock::Paragraph {
fragments,
style: ParagraphStyle {
widow_control: false,
..Default::default()
},
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
};
let pages = layout_section(
&[block],
&two_column_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert!(pages.len() >= 2, "12 lines exceed two 5-line columns");
let xs: std::collections::BTreeSet<i32> = pages[0]
.commands
.iter()
.filter_map(|c| match c {
DrawCommand::Text { position, .. } => Some(position.x.raw() as i32),
_ => None,
})
.collect();
assert!(xs.contains(&10), "column 0 text at x=10, got {xs:?}");
assert!(xs.contains(&105), "column 1 text at x=105, got {xs:?}");
}
fn border_line() -> crate::render::layout::paragraph::BorderLine {
crate::render::layout::paragraph::BorderLine {
width: Pt::new(1.0),
color: RgbColor::BLACK,
space: Pt::new(2.0),
}
}
#[test]
fn tall_paragraph_splits_across_pages() {
let blocks = vec![multiline_para(8, false, true)];
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 2, "eight lines should not fit on one page");
assert_eq!(
text_count(&pages[0].commands) + text_count(&pages[1].commands),
8
);
assert!(
text_count(&pages[0].commands) >= 2 && text_count(&pages[1].commands) >= 2,
"§17.3.1.44: each side of the split keeps >= 2 lines, got {} + {}",
text_count(&pages[0].commands),
text_count(&pages[1].commands)
);
}
#[test]
fn keep_lines_prevents_split() {
let blocks = vec![multiline_para(8, true, true)];
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 1, "§17.3.1.14: keepLines forbids the split");
assert_eq!(text_count(&pages[0].commands), 8);
}
#[test]
fn widow_control_moves_unsplittable_paragraph_whole() {
let blocks = vec![
multiline_para(3, false, true),
multiline_para(3, false, true),
];
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 2);
assert_eq!(
text_count(&pages[0].commands),
3,
"only para 1 fits on page 1"
);
assert_eq!(
text_count(&pages[1].commands),
3,
"para 2 moved whole to page 2"
);
}
#[test]
fn keep_next_paragraph_taller_than_page_still_splits() {
let fragments: Vec<Fragment> = (0..8)
.map(|i| text_frag(&format!("word{i} "), 100.0, 14.0))
.collect();
let keep = LayoutBlock::Paragraph {
fragments,
style: ParagraphStyle {
keep_next: true,
widow_control: false,
..Default::default()
},
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
};
let next = para_block("next", 40.0);
let pages = layout_section(
&[keep, next],
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert!(pages.len() >= 2, "the tall keepNext paragraph splits");
let total: usize = pages.iter().map(|p| text_count(&p.commands)).sum();
assert_eq!(total, 9, "all eight keepNext lines plus the next paragraph");
}
#[test]
fn bordered_paragraph_splits_across_pages() {
let mut block = multiline_para(8, false, true);
if let LayoutBlock::Paragraph { style, .. } = &mut block {
style.borders = Some(crate::render::layout::paragraph::ParagraphBorderStyle {
top: Some(border_line()),
bottom: Some(border_line()),
left: Some(border_line()),
right: Some(border_line()),
});
}
let pages = layout_section(
&[block],
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(
pages.len(),
2,
"bordered paragraph should split, not overflow"
);
for page in &pages {
assert!(
page.commands
.iter()
.any(|c| matches!(c, DrawCommand::Line { .. })),
"each segment draws its part of the border box"
);
}
}
#[test]
fn without_widow_control_paragraph_splits_leaving_single_line() {
let blocks = vec![
multiline_para(3, false, true),
multiline_para(3, false, false),
];
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 2);
assert_eq!(
text_count(&pages[0].commands),
5,
"para 1 (3) + first 2 lines of para 2"
);
assert_eq!(text_count(&pages[1].commands), 1, "the widow line");
}
fn dropcap_para(n: usize, drop_cap_lines: u32) -> LayoutBlock {
use crate::render::layout::paragraph::DropCapInfo;
let fragments = (0..n)
.map(|i| text_frag(&format!("b{i} "), 100.0, 14.0))
.collect();
let style = ParagraphStyle {
widow_control: false,
drop_cap: Some(DropCapInfo {
fragments: vec![text_frag("§", 20.0, 30.0)],
lines: drop_cap_lines,
width: Pt::new(20.0),
height: Pt::new(30.0),
ascent: Pt::new(28.0),
h_space: Pt::ZERO,
margin_mode: false,
indent: Pt::ZERO,
frame_height: None,
position_offset: Pt::ZERO,
}),
..Default::default()
};
LayoutBlock::Paragraph {
fragments,
style,
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
}
}
fn page_has_dropcap_glyph(page: &crate::render::LayoutedPage) -> bool {
page.commands
.iter()
.any(|c| matches!(c, DrawCommand::Text { text, .. } if text.as_ref() == "§"))
}
fn footnote_ref_frag() -> Fragment {
let mut f = text_frag("1", 5.0, 8.0);
if let Fragment::Text {
is_footnote_ref, ..
} = &mut f
{
*is_footnote_ref = true;
}
f
}
fn page_has_text(page: &crate::render::LayoutedPage, needle: &str) -> bool {
page.commands
.iter()
.any(|c| matches!(c, DrawCommand::Text { text, .. } if text.as_ref() == needle))
}
#[test]
fn footnotes_reserve_on_the_page_of_their_reference() {
let mut fragments = vec![text_frag("word0 ", 100.0, 14.0), footnote_ref_frag()];
for i in 1..8 {
fragments.push(text_frag(&format!("word{i} "), 100.0, 14.0));
}
fragments.push(footnote_ref_frag()); let footnotes = vec![
(
vec![text_frag("FNA", 30.0, 12.0)],
ParagraphStyle::default(),
),
(
vec![text_frag("FNB", 30.0, 12.0)],
ParagraphStyle::default(),
),
];
let block = LayoutBlock::Paragraph {
fragments,
style: ParagraphStyle {
widow_control: false,
..Default::default()
},
page_break_before: false,
footnotes,
floating_images: vec![],
floating_shapes: vec![],
};
let pages = layout_section(
&[block],
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert!(pages.len() >= 2, "the paragraph should split");
let last = pages.len() - 1;
assert!(
page_has_text(&pages[0], "FNA"),
"footnote A on its reference's page"
);
assert!(
!page_has_text(&pages[0], "FNB"),
"footnote B is not on page 1"
);
assert!(
page_has_text(&pages[last], "FNB"),
"footnote B on the last page with its reference"
);
}
#[test]
fn drop_cap_prefix_is_not_split_across_pages() {
let blocks = vec![multiline_para(4, false, false), dropcap_para(6, 3)];
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert!(pages.len() >= 2);
assert_eq!(
text_count(&pages[0].commands),
4,
"only para 1 on page 1 — the drop-cap paragraph moved whole"
);
assert!(
!page_has_dropcap_glyph(&pages[0]),
"the drop cap must not appear on page 1"
);
let cap_page = pages
.iter()
.find(|p| page_has_dropcap_glyph(p))
.expect("drop cap is emitted on some page");
assert!(
text_count(&cap_page.commands) > 3,
"the drop-cap prefix (>= 3 lines) stays with the glyph"
);
}
#[test]
fn text_positioned_at_margins() {
let blocks = vec![para_block("hello", 30.0)];
let config = small_config();
let pages = layout_section(&blocks, &config, None, Pt::ZERO, Pt::new(14.0), None);
if let Some(DrawCommand::Text { position, .. }) = pages[0].commands.first() {
assert!(
position.x.raw() >= config.margins.left.raw(),
"x should be at least left margin"
);
assert!(
position.y.raw() >= config.margins.top.raw(),
"y should be at least top margin"
);
}
}
#[test]
fn page_break_when_content_overflows() {
let blocks: Vec<_> = (0..6).map(|i| para_block(&format!("p{i}"), 30.0)).collect();
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 2, "should overflow to 2 pages");
let page1_texts: Vec<_> = pages[0]
.commands
.iter()
.filter_map(|c| match c {
DrawCommand::Text { text, .. } => Some(text.clone()),
_ => None,
})
.collect();
let page2_texts: Vec<_> = pages[1]
.commands
.iter()
.filter_map(|c| match c {
DrawCommand::Text { text, .. } => Some(text.clone()),
_ => None,
})
.collect();
assert_eq!(page1_texts.len(), 5, "5 paras fit on page 1 (5*14=70 < 80)");
assert_eq!(page2_texts.len(), 1, "1 para on page 2");
}
#[test]
fn each_page_uses_its_selected_header_and_footer_clearance() {
let config = small_config();
let clearance = HeaderFooterClearance::new(
&config,
HeaderFooterSet {
default: None,
first: Some(Pt::new(30.0)),
even: None,
},
HeaderFooterSet {
default: None,
first: Some(Pt::new(30.0)),
even: None,
},
true,
false,
1,
);
let blocks: Vec<_> = (0..5).map(|i| para_block(&format!("p{i}"), 30.0)).collect();
let pages = layout_section_with_clearance(
&blocks,
&config,
None,
Pt::ZERO,
Pt::new(14.0),
None,
&clearance,
);
assert_eq!(pages.len(), 2, "page 2 must use the shorter default slots");
let page_texts = pages
.iter()
.map(|page| {
page.commands
.iter()
.filter_map(|command| match command {
DrawCommand::Text { text, position, .. } => {
Some((text.to_string(), position.y))
}
_ => None,
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
assert_eq!(
page_texts[0].len(),
2,
"40pt first-page body fits two lines"
);
assert_eq!(
page_texts[1].len(),
3,
"80pt default-page body fits the remainder"
);
assert!(
page_texts[1][0].1 < page_texts[0][0].1,
"page 2 body must start at the shorter default-header boundary",
);
}
#[test]
fn paragraph_moved_to_taller_page_is_relaid_before_following_block() {
let config = small_config();
let clearance = HeaderFooterClearance::new(
&config,
HeaderFooterSet {
default: None,
first: Some(Pt::new(30.0)),
even: None,
},
HeaderFooterSet {
default: None,
first: Some(Pt::new(30.0)),
even: None,
},
true,
false,
1,
);
let moved = LayoutBlock::Paragraph {
fragments: (0..4)
.map(|i| text_frag(&format!("moved-{i} "), 170.0, 14.0))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
};
let pages = layout_section_with_clearance(
&[para_block("before", 30.0), moved, para_block("after", 30.0)],
&config,
None,
Pt::ZERO,
Pt::new(14.0),
None,
&clearance,
);
assert_eq!(pages.len(), 2);
let moved_last_y = pages[1]
.commands
.iter()
.filter_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.starts_with("moved-") => {
Some(position.y.raw())
}
_ => None,
})
.max_by(f32::total_cmp)
.expect("moved paragraph text");
let after_y = pages[1]
.commands
.iter()
.find_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.as_ref() == "after" => {
Some(position.y.raw())
}
_ => None,
})
.expect("following paragraph text");
assert!(
after_y > moved_last_y,
"following paragraph at {after_y:?} overlaps moved paragraph ending at {moved_last_y:?}",
);
}
#[test]
fn moved_paragraph_keeps_its_wrap_float_during_destination_relayout() {
let config = small_config();
let clearance = HeaderFooterClearance::new(
&config,
HeaderFooterSet {
default: None,
first: Some(Pt::new(30.0)),
even: None,
},
HeaderFooterSet {
default: None,
first: Some(Pt::new(30.0)),
even: None,
},
true,
false,
1,
);
let moved = LayoutBlock::Paragraph {
fragments: (0..4)
.map(|i| text_frag(&format!("moved-{i}"), 80.0, 14.0))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![floating_image(
WrapMode::Square(crate::model::WrapText::BothSides),
42.0,
)],
floating_shapes: vec![],
};
let pages = layout_section_with_clearance(
&[para_block("before", 170.0), moved],
&config,
None,
Pt::ZERO,
Pt::new(14.0),
None,
&clearance,
);
assert_eq!(pages.len(), 2);
let first_moved_x = pages[1]
.commands
.iter()
.find_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.starts_with("moved-") => {
Some(position.x)
}
_ => None,
})
.expect("moved paragraph text on page 2");
assert!(
first_moved_x >= Pt::new(90.0),
"moved text at {first_moved_x:?} overlaps its 10-90pt float",
);
}
#[test]
fn moved_absolute_float_relayouts_earlier_source_page_text() {
let config = small_config();
let source = LayoutBlock::Paragraph {
fragments: (0..4)
.map(|i| text_frag(&format!("source-{i}"), 170.0, 14.0))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
};
let owner = LayoutBlock::Paragraph {
fragments: (0..2)
.map(|i| text_frag(&format!("owner-{i}"), 170.0, 14.0))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![absolute_floating_image(
WrapMode::Square(crate::model::WrapText::BothSides),
10.0,
42.0,
)],
floating_shapes: vec![],
};
let pages = layout_section(
&[source, owner],
&config,
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 2);
assert!(
!pages[0]
.commands
.iter()
.any(|command| matches!(command, DrawCommand::Image { .. })),
"the relocated float must not remain on the source page"
);
assert!(
pages[1]
.commands
.iter()
.any(|command| matches!(command, DrawCommand::Image { .. })),
"the relocated float must be emitted on the destination page"
);
let source_positions: Vec<_> = pages[0]
.commands
.iter()
.filter_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.starts_with("source-") => {
Some(position.x)
}
_ => None,
})
.collect();
assert_eq!(source_positions, vec![Pt::new(10.0); 4]);
let owner_x = pages[1]
.commands
.iter()
.find_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.starts_with("owner-") => {
Some(position.x)
}
_ => None,
})
.expect("owner text on destination page");
assert!(
owner_x >= Pt::new(90.0),
"moved owner text at {owner_x:?} overlaps its 10-90pt float"
);
}
#[test]
fn leading_inline_page_break_relayouts_source_page_after_float_relocation() {
let config = small_config();
let source = LayoutBlock::Paragraph {
fragments: (0..4)
.map(|i| text_frag(&format!("source-{i}"), 170.0, 14.0))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
};
let owner = LayoutBlock::Paragraph {
fragments: std::iter::once(Fragment::PageBreak {
line_height: Pt::new(14.0),
})
.chain((0..2).map(|i| text_frag(&format!("owner-{i}"), 170.0, 14.0)))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![absolute_floating_image(
WrapMode::Square(crate::model::WrapText::BothSides),
10.0,
42.0,
)],
floating_shapes: vec![],
};
let pages = layout_section(
&[source, owner],
&config,
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 2);
let source_positions: Vec<_> = pages[0]
.commands
.iter()
.filter_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.starts_with("source-") => {
Some(position.x)
}
_ => None,
})
.collect();
assert_eq!(source_positions, vec![Pt::new(10.0); 4]);
let owner_x = pages[1]
.commands
.iter()
.find_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.starts_with("owner-") => {
Some(position.x)
}
_ => None,
})
.expect("owner text on destination page");
assert!(
owner_x >= Pt::new(90.0),
"owner text at {owner_x:?} overlaps its 10-90pt float"
);
}
#[test]
fn column_break_page_transition_drops_forward_floats_from_previous_page() {
let config = small_config();
let source = LayoutBlock::Paragraph {
fragments: std::iter::once(Fragment::ColumnBreak)
.chain((0..4).map(|i| text_frag(&format!("source-{i}"), 170.0, 14.0)))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
};
let owner = LayoutBlock::Paragraph {
fragments: (0..2)
.map(|i| text_frag(&format!("owner-{i}"), 170.0, 14.0))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![absolute_floating_image(
WrapMode::Square(crate::model::WrapText::BothSides),
10.0,
42.0,
)],
floating_shapes: vec![],
};
let pages = layout_section(
&[source, owner],
&config,
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 3);
let source_positions: Vec<_> = pages[1]
.commands
.iter()
.filter_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.starts_with("source-") => {
Some(position.x)
}
_ => None,
})
.collect();
assert_eq!(source_positions, vec![Pt::new(10.0); 4]);
assert!(
pages[2]
.commands
.iter()
.any(|command| matches!(command, DrawCommand::Image { .. })),
"the relocated float must be emitted on page 3"
);
}
#[test]
fn nonempty_column_break_does_not_leak_future_float_to_source_page() {
let config = small_config();
let source = LayoutBlock::Paragraph {
fragments: std::iter::once(text_frag("before", 170.0, 14.0))
.chain(std::iter::once(Fragment::ColumnBreak))
.chain((0..4).map(|i| text_frag(&format!("source-{i}"), 170.0, 14.0)))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
};
let owner = LayoutBlock::Paragraph {
fragments: (0..2)
.map(|i| text_frag(&format!("owner-{i}"), 170.0, 14.0))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![absolute_floating_image(
WrapMode::Square(crate::model::WrapText::BothSides),
10.0,
42.0,
)],
floating_shapes: vec![],
};
let pages = layout_section(
&[source, owner],
&config,
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 3);
let before_x = pages[0]
.commands
.iter()
.find_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.as_ref() == "before" => {
Some(position.x)
}
_ => None,
})
.expect("text before the column break on page 1");
assert_eq!(before_x, Pt::new(10.0));
let source_positions: Vec<_> = pages[1]
.commands
.iter()
.filter_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.starts_with("source-") => {
Some(position.x)
}
_ => None,
})
.collect();
assert_eq!(source_positions, vec![Pt::new(10.0); 4]);
assert!(pages[..2].iter().all(|page| {
!page
.commands
.iter()
.any(|command| matches!(command, DrawCommand::Image { .. }))
}));
assert_eq!(
pages[2]
.commands
.iter()
.filter(|command| matches!(command, DrawCommand::Image { .. }))
.count(),
1
);
let owner_x = pages[2]
.commands
.iter()
.find_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.starts_with("owner-") => {
Some(position.x)
}
_ => None,
})
.expect("owner text on page 3");
assert!(owner_x >= Pt::new(90.0));
}
#[test]
fn leading_inline_column_break_relayouts_source_page_after_float_relocation() {
let config = small_config();
let source = LayoutBlock::Paragraph {
fragments: (0..4)
.map(|i| text_frag(&format!("source-{i}"), 170.0, 14.0))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
};
let owner = LayoutBlock::Paragraph {
fragments: std::iter::once(Fragment::ColumnBreak)
.chain((0..2).map(|i| text_frag(&format!("owner-{i}"), 170.0, 14.0)))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![absolute_floating_image(
WrapMode::Square(crate::model::WrapText::BothSides),
10.0,
42.0,
)],
floating_shapes: vec![],
};
let pages = layout_section(
&[source, owner],
&config,
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 2);
let source_positions: Vec<_> = pages[0]
.commands
.iter()
.filter_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.starts_with("source-") => {
Some(position.x)
}
_ => None,
})
.collect();
assert_eq!(source_positions, vec![Pt::new(10.0); 4]);
assert!(!pages[0]
.commands
.iter()
.any(|command| matches!(command, DrawCommand::Image { .. })));
assert_eq!(
pages[1]
.commands
.iter()
.filter(|command| matches!(command, DrawCommand::Image { .. }))
.count(),
1
);
let owner_x = pages[1]
.commands
.iter()
.find_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.starts_with("owner-") => {
Some(position.x)
}
_ => None,
})
.expect("owner text on page 2");
assert!(owner_x >= Pt::new(90.0));
}
#[test]
fn nonempty_page_break_does_not_leak_future_float_to_source_page() {
let config = small_config();
let source = LayoutBlock::Paragraph {
fragments: std::iter::once(text_frag("before", 170.0, 14.0))
.chain(std::iter::once(Fragment::PageBreak {
line_height: Pt::new(14.0),
}))
.chain((0..4).map(|i| text_frag(&format!("source-{i}"), 170.0, 14.0)))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
};
let owner = LayoutBlock::Paragraph {
fragments: (0..2)
.map(|i| text_frag(&format!("owner-{i}"), 170.0, 14.0))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![absolute_floating_image(
WrapMode::Square(crate::model::WrapText::BothSides),
10.0,
42.0,
)],
floating_shapes: vec![],
};
let pages = layout_section(
&[source, owner],
&config,
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 3);
let before_x = pages[0]
.commands
.iter()
.find_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.as_ref() == "before" => {
Some(position.x)
}
_ => None,
})
.expect("text before the page break on page 1");
assert_eq!(before_x, Pt::new(10.0));
let source_positions: Vec<_> = pages[1]
.commands
.iter()
.filter_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.starts_with("source-") => {
Some(position.x)
}
_ => None,
})
.collect();
assert_eq!(source_positions, vec![Pt::new(10.0); 4]);
assert!(pages[..2].iter().all(|page| {
!page
.commands
.iter()
.any(|command| matches!(command, DrawCommand::Image { .. }))
}));
assert_eq!(
pages[2]
.commands
.iter()
.filter(|command| matches!(command, DrawCommand::Image { .. }))
.count(),
1
);
let owner_x = pages[2]
.commands
.iter()
.find_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.starts_with("owner-") => {
Some(position.x)
}
_ => None,
})
.expect("owner text on page 3");
assert!(owner_x >= Pt::new(90.0));
}
#[test]
fn same_page_column_break_preserves_page_float_state() {
use crate::render::layout::page::ColumnGeometry;
let mut config = small_config();
config.columns = vec![
ColumnGeometry {
x_offset: Pt::ZERO,
width: Pt::new(85.0),
},
ColumnGeometry {
x_offset: Pt::new(95.0),
width: Pt::new(85.0),
},
];
let source = LayoutBlock::Paragraph {
fragments: vec![
text_frag("before", 70.0, 14.0),
Fragment::ColumnBreak,
text_frag("after", 70.0, 14.0),
],
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
};
let owner = LayoutBlock::Paragraph {
fragments: vec![text_frag("owner", 70.0, 14.0)],
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![absolute_floating_image(
WrapMode::Square(crate::model::WrapText::BothSides),
10.0,
42.0,
)],
floating_shapes: vec![],
};
let pages = layout_section(
&[source, owner],
&config,
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 1);
let before_x = pages[0]
.commands
.iter()
.find_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.as_ref() == "before" => {
Some(position.x)
}
_ => None,
})
.expect("text in the first column");
assert!(before_x >= Pt::new(90.0));
for expected in ["before", "after", "owner"] {
assert_eq!(
pages[0]
.commands
.iter()
.filter(|command| {
matches!(command, DrawCommand::Text { text, .. } if text.as_ref() == expected)
})
.count(),
1
);
}
assert_eq!(
pages[0]
.commands
.iter()
.filter(|command| matches!(command, DrawCommand::Image { .. }))
.count(),
1
);
}
#[test]
fn moved_absolute_float_replays_from_first_affected_page_two_paragraph() {
let config = small_config();
let source = LayoutBlock::Paragraph {
fragments: (0..4)
.map(|i| text_frag(&format!("source-{i}"), 170.0, 14.0))
.collect(),
style: ParagraphStyle::default(),
page_break_before: true,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
};
let owner = LayoutBlock::Paragraph {
fragments: (0..2)
.map(|i| text_frag(&format!("owner-{i}"), 170.0, 14.0))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![absolute_floating_image(
WrapMode::Square(crate::model::WrapText::BothSides),
10.0,
42.0,
)],
floating_shapes: vec![],
};
let pages = layout_section(
&[para_block("before", 170.0), source, owner],
&config,
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 3);
let source_positions: Vec<_> = pages[1]
.commands
.iter()
.filter_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.starts_with("source-") => {
Some(position.x)
}
_ => None,
})
.collect();
assert_eq!(source_positions, vec![Pt::new(10.0); 4]);
assert!(
pages[2]
.commands
.iter()
.any(|command| matches!(command, DrawCommand::Image { .. })),
"the relocated float must be emitted on page 3"
);
}
#[test]
fn moved_absolute_float_does_not_replay_page_start_paragraph() {
let config = small_config();
let filler = LayoutBlock::Paragraph {
fragments: (0..4)
.map(|i| text_frag(&format!("filler-{i}"), 170.0, 14.0))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
};
let page_start = LayoutBlock::Paragraph {
fragments: (0..2)
.map(|i| text_frag(&format!("page-start-{i}"), 170.0, 14.0))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
};
let source = LayoutBlock::Paragraph {
fragments: (0..2)
.map(|i| text_frag(&format!("source-{i}"), 170.0, 14.0))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
};
let owner = LayoutBlock::Paragraph {
fragments: (0..2)
.map(|i| text_frag(&format!("owner-{i}"), 170.0, 14.0))
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![absolute_floating_image(
WrapMode::Square(crate::model::WrapText::BothSides),
10.0,
42.0,
)],
floating_shapes: vec![],
};
let pages = layout_section(
&[filler, page_start, source, owner],
&config,
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 3);
let page_start_commands = pages
.iter()
.flat_map(|page| &page.commands)
.filter(|command| {
matches!(
command,
DrawCommand::Text { text, .. } if text.starts_with("page-start-")
)
})
.count();
assert_eq!(page_start_commands, 2);
}
#[test]
fn moved_paragraph_relocates_top_and_bottom_float_to_destination_page() {
let config = small_config();
let clearance = HeaderFooterClearance::new(
&config,
HeaderFooterSet {
default: None,
first: Some(Pt::new(30.0)),
even: None,
},
HeaderFooterSet {
default: None,
first: Some(Pt::new(30.0)),
even: None,
},
true,
false,
1,
);
let moved = LayoutBlock::Paragraph {
fragments: vec![text_frag("moved", 170.0, 14.0)],
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![floating_image(WrapMode::TopAndBottom, 30.0)],
floating_shapes: vec![],
};
let pages = layout_section_with_clearance(
&[para_block("before", 170.0), moved],
&config,
None,
Pt::ZERO,
Pt::new(14.0),
None,
&clearance,
);
assert_eq!(pages.len(), 2);
assert!(
!pages[0]
.commands
.iter()
.any(|command| matches!(command, DrawCommand::Image { .. })),
"the moved paragraph must not leave its image on page 1",
);
let image_bottom = pages[1]
.commands
.iter()
.find_map(|command| match command {
DrawCommand::Image { rect, .. } => Some(rect.origin.y + rect.size.height),
_ => None,
})
.expect("top-and-bottom image on page 2");
let text_y = pages[1]
.commands
.iter()
.find_map(|command| match command {
DrawCommand::Text { text, position, .. } if text.as_ref() == "moved" => {
Some(position.y)
}
_ => None,
})
.expect("moved paragraph text on page 2");
assert!(
text_y >= image_bottom,
"moved text at {text_y:?} must follow image ending at {image_bottom:?}",
);
}
#[test]
fn footnotes_render_from_the_selected_footer_boundary_once() {
let config = small_config();
let clearance = HeaderFooterClearance::new(
&config,
HeaderFooterSet::default(),
HeaderFooterSet {
default: Some(Pt::new(30.0)),
first: None,
even: None,
},
false,
false,
1,
);
let block = LayoutBlock::Paragraph {
fragments: vec![text_frag("body", 30.0, 14.0)],
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![(
vec![text_frag("footnote", 30.0, 14.0)],
ParagraphStyle::default(),
)],
floating_images: vec![],
floating_shapes: vec![],
};
let pages = layout_section_with_clearance(
&[block],
&config,
None,
Pt::ZERO,
Pt::new(14.0),
None,
&clearance,
);
let separator_y = pages[0]
.commands
.iter()
.find_map(|command| match command {
DrawCommand::Line { line, .. } => Some(line.start.y),
_ => None,
})
.expect("footnote separator");
assert_eq!(separator_y, Pt::new(52.0));
}
#[test]
fn page_size_set_on_layouted_page() {
let config = small_config();
let pages = layout_section(&[], &config, None, Pt::ZERO, Pt::new(14.0), None);
assert_eq!(pages[0].page_size, config.page_size);
}
#[test]
fn many_paragraphs_produce_multiple_pages() {
let blocks: Vec<_> = (0..20)
.map(|i| para_block(&format!("p{i}"), 30.0))
.collect();
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 4);
}
#[test]
fn table_on_page() {
let blocks = vec![LayoutBlock::Table {
rows: vec![TableRowInput {
cells: vec![TableCellInput {
blocks: vec![LayoutBlock::Paragraph {
fragments: vec![text_frag("cell", 30.0, 14.0)],
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
}],
margins: PtEdgeInsets::ZERO,
grid_span: 1,
shading: None,
cell_borders: None,
vertical_merge: None,
vertical_align: crate::render::layout::table::CellVAlign::Top,
}],
height_rule: None,
is_header: None,
cant_split: None,
grid_before: 0,
grid_after: 0,
border_overrides: None,
}],
col_widths: vec![Pt::new(100.0)],
border_config: None,
indent: Pt::ZERO,
alignment: None,
float_info: None,
style_id: None,
}];
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 1);
let text_count = pages[0]
.commands
.iter()
.filter(|c| matches!(c, DrawCommand::Text { .. }))
.count();
assert_eq!(text_count, 1);
}
#[test]
fn space_before_suppressed_for_first_paragraph_of_section() {
let style = ParagraphStyle {
space_before: Pt::new(24.0),
..Default::default()
};
let blocks = vec![LayoutBlock::Paragraph {
fragments: vec![text_frag("heading", 50.0, 14.0)],
style,
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
}];
let config = small_config();
let pages = layout_section(&blocks, &config, None, Pt::ZERO, Pt::new(14.0), None);
if let Some(DrawCommand::Text { position, .. }) = pages[0].commands.first() {
assert!(
position.y.raw() < config.margins.top.raw() + 24.0,
"space_before should be suppressed: y={}",
position.y.raw()
);
}
}
#[test]
fn space_before_preserved_for_page_break_before() {
let heading_style = ParagraphStyle {
space_before: Pt::new(24.0),
..Default::default()
};
let blocks = vec![
para_block("first page", 30.0),
LayoutBlock::Paragraph {
fragments: vec![text_frag("heading", 50.0, 14.0)],
style: heading_style,
page_break_before: true,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
},
];
let config = small_config();
let pages = layout_section(&blocks, &config, None, Pt::ZERO, Pt::new(14.0), None);
assert!(pages.len() >= 2, "should have at least 2 pages");
let heading_y = pages[1]
.commands
.iter()
.find_map(|c| match c {
DrawCommand::Text { position, text, .. } if &**text == "heading" => {
Some(position.y)
}
_ => None,
})
.expect("heading should be on page 2");
assert!(
heading_y.raw() > config.margins.top.raw() + 20.0,
"space_before should be preserved for pageBreakBefore: y={}",
heading_y.raw(),
);
}
fn cell_with_text(text: &str) -> TableCellInput {
TableCellInput {
blocks: vec![LayoutBlock::Paragraph {
fragments: vec![text_frag(text, 30.0, 14.0)],
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
}],
margins: PtEdgeInsets::ZERO,
grid_span: 1,
shading: None,
cell_borders: None,
vertical_merge: None,
vertical_align: crate::render::layout::table::CellVAlign::Top,
}
}
fn row_with_label(label: &str) -> TableRowInput {
TableRowInput {
cells: vec![cell_with_text(label)],
height_rule: None,
is_header: None,
cant_split: None,
grid_before: 0,
grid_after: 0,
border_overrides: None,
}
}
fn floating_table_with_rows(n: usize, y_offset: f32) -> LayoutBlock {
LayoutBlock::Table {
rows: (0..n).map(|i| row_with_label(&format!("r{i}"))).collect(),
col_widths: vec![Pt::new(100.0)],
border_config: None,
indent: Pt::ZERO,
alignment: None,
float_info: Some(super::TableFloatInfo {
right_gap: Pt::ZERO,
bottom_gap: Pt::ZERO,
x_align: None,
y_offset: Pt::new(y_offset),
vert_anchor: crate::model::TableAnchor::Page,
overlap: None,
}),
style_id: None,
}
}
fn texts_per_page(
pages: &[crate::render::layout::draw_command::LayoutedPage],
) -> Vec<Vec<String>> {
pages
.iter()
.map(|p| {
p.commands
.iter()
.filter_map(|c| match c {
DrawCommand::Text { text, .. } => Some(text.to_string()),
_ => None,
})
.collect()
})
.collect()
}
#[test]
fn keep_next_chain_moves_with_terminal_paragraph() {
let mut blocks: Vec<_> = (0..3)
.map(|i| para_block(&format!("fill{i}"), 30.0))
.collect();
blocks.extend([
styled_para_block("heading", true, false),
styled_para_block("subheading", true, false),
styled_para_block("body", false, false),
]);
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
let per_page = texts_per_page(&pages);
let last = per_page.last().unwrap();
assert!(last.iter().any(|text| text == "heading"));
assert!(last.iter().any(|text| text == "subheading"));
assert!(last.iter().any(|text| text == "body"));
}
#[test]
fn keep_next_fit_includes_group_footnotes() {
let mut blocks: Vec<_> = (0..3)
.map(|i| para_block(&format!("fill{i}"), 30.0))
.collect();
blocks.extend([
LayoutBlock::Paragraph {
fragments: vec![text_frag("heading", 30.0, 14.0)],
style: ParagraphStyle {
keep_next: true,
..Default::default()
},
page_break_before: false,
footnotes: vec![(
vec![text_frag("footnote", 30.0, 14.0)],
ParagraphStyle::default(),
)],
floating_images: vec![],
floating_shapes: vec![],
},
styled_para_block("body", false, false),
]);
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
let per_page = texts_per_page(&pages);
let heading_page = per_page
.iter()
.position(|page| page.iter().any(|text| text == "heading"))
.expect("heading text");
let body_page = per_page
.iter()
.position(|page| page.iter().any(|text| text == "body"))
.expect("body text");
assert_eq!(
heading_page, body_page,
"footnote height must participate in keep-next fit checks",
);
}
#[test]
fn contextual_keep_next_chain_stays_when_collapsed_spacing_fits() {
let mut blocks: Vec<_> = (0..2)
.map(|i| para_block(&format!("fill{i}"), 30.0))
.collect();
let style = ParagraphStyle {
keep_next: true,
contextual_spacing: true,
style_id: Some(crate::model::StyleId::new("contextual")),
space_before: Pt::new(10.0),
space_after: Pt::new(10.0),
..Default::default()
};
blocks.extend([
LayoutBlock::Paragraph {
fragments: vec![text_frag("heading", 30.0, 14.0)],
style: style.clone(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
},
LayoutBlock::Paragraph {
fragments: vec![text_frag("body", 30.0, 14.0)],
style: ParagraphStyle {
keep_next: false,
..style
},
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
},
]);
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert_eq!(pages.len(), 1);
let texts = &texts_per_page(&pages)[0];
assert!(texts.iter().any(|text| text == "heading"));
assert!(texts.iter().any(|text| text == "body"));
}
#[test]
fn contextual_predecessor_allows_keep_next_table_chain_on_fresh_page() {
let style = ParagraphStyle {
keep_next: true,
contextual_spacing: true,
style_id: Some(crate::model::StyleId::new("contextual")),
space_before: Pt::new(20.0),
space_after: Pt::ZERO,
..Default::default()
};
let mut blocks = vec![
para_block("fill", 30.0),
LayoutBlock::Paragraph {
fragments: vec![text_frag("predecessor", 30.0, 14.0)],
style: ParagraphStyle {
keep_next: false,
..style.clone()
},
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
},
];
blocks.extend((0..4).map(|index| LayoutBlock::Paragraph {
fragments: vec![text_frag(&format!("chain{index}"), 30.0, 14.0)],
style: style.clone(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
}));
blocks.push(one_column_table(&[("row0", false), ("row1", false)]));
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
let per_page = texts_per_page(&pages);
let heading_page = per_page
.iter()
.position(|page| page.iter().any(|text| text == "chain0"))
.unwrap();
let row0_page = per_page
.iter()
.position(|page| page.iter().any(|text| text == "row0"))
.unwrap();
assert_eq!(heading_page, row0_page);
}
#[test]
fn keep_next_heading_moves_with_first_table_row() {
for header in [false, true] {
let mut blocks: Vec<_> = (0..4)
.map(|i| para_block(&format!("fill{i}"), 30.0))
.collect();
blocks.push(styled_para_block("heading", true, false));
blocks.push(one_column_table(&[
("row0", header),
("row1", false),
("row2", false),
]));
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
let per_page = texts_per_page(&pages);
let heading_page = per_page
.iter()
.position(|p| p.iter().any(|t| t == "heading"))
.unwrap();
let row_page = per_page
.iter()
.position(|p| p.iter().any(|t| t == "row0"))
.unwrap();
assert_eq!(heading_page, row_page);
}
}
#[test]
fn keep_next_does_not_move_heading_before_floating_table() {
let mut blocks = (0..4)
.map(|i| para_block(&format!("filler-{i}"), 30.0))
.collect::<Vec<_>>();
blocks.push(styled_para_block("heading", true, false));
blocks.push(floating_table_with_rows(1, 15.0));
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
let heading_page = pages
.iter()
.position(|page| {
page.commands.iter().any(
|command| matches!(command, DrawCommand::Text { text, .. } if text.as_ref() == "heading"),
)
})
.expect("heading text");
assert_eq!(
heading_page, 0,
"a floating table must not act as the terminal block of a keep-next chain",
);
}
#[test]
fn keep_next_heading_moves_with_bordered_first_table_row() {
let mut blocks: Vec<_> = (0..3)
.map(|i| para_block(&format!("fill{i}"), 30.0))
.collect();
blocks.push(styled_para_block("heading", true, false));
let mut table = one_column_table(&[
("row0", false),
("row1", false),
("row2", false),
("row3", false),
]);
if let LayoutBlock::Table { border_config, .. } = &mut table {
*border_config = Some(TableBorderConfig {
top: None,
bottom: None,
left: None,
right: None,
inside_h: Some(TableBorderLine {
width: Pt::new(12.0),
color: RgbColor::BLACK,
style: TableBorderStyle::Single,
}),
inside_v: None,
});
}
blocks.push(table);
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
let per_page = texts_per_page(&pages);
let heading_page = per_page
.iter()
.position(|p| p.iter().any(|text| text == "heading"))
.unwrap();
let row0_page = per_page
.iter()
.position(|p| p.iter().any(|text| text == "row0"))
.unwrap();
assert_eq!(heading_page, row0_page);
assert!(per_page.iter().skip(row0_page + 1).any(|page| page
.iter()
.any(|text| text == "row1" || text == "row2" || text == "row3")));
}
#[test]
fn oversized_splittable_first_table_row_does_not_move_heading() {
let mut blocks: Vec<_> = (0..4)
.map(|i| para_block(&format!("fill{i}"), 30.0))
.collect();
blocks.push(styled_para_block("heading", true, false));
blocks.push(LayoutBlock::Table {
rows: vec![TableRowInput {
cells: vec![TableCellInput {
blocks: vec![LayoutBlock::Paragraph {
fragments: (0..12)
.flat_map(|i| {
[
text_frag(&format!("row0-{i}"), 30.0, 14.0),
Fragment::LineBreak {
line_height: Pt::new(14.0),
},
]
})
.collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
}],
margins: PtEdgeInsets::ZERO,
grid_span: 1,
shading: None,
cell_borders: None,
vertical_merge: None,
vertical_align: crate::render::layout::table::CellVAlign::Top,
}],
height_rule: None,
is_header: None,
cant_split: None,
grid_before: 0,
grid_after: 0,
border_overrides: None,
}],
col_widths: vec![Pt::new(100.0)],
border_config: None,
indent: Pt::ZERO,
alignment: None,
float_info: None,
style_id: None,
});
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
let per_page = texts_per_page(&pages);
let heading_page = per_page
.iter()
.position(|page| page.iter().any(|text| text == "heading"))
.unwrap();
let first_row_page = per_page
.iter()
.position(|page| page.iter().any(|text| text == "row0-0"))
.unwrap();
assert!(heading_page < first_row_page);
let flattened: Vec<_> = per_page.into_iter().flatten().collect();
for index in 0..12 {
let needle = format!("row0-{index}");
assert_eq!(flattened.iter().filter(|text| *text == &needle).count(), 1);
}
}
#[test]
fn keep_next_heading_stays_before_oversized_leading_vmerge_group() {
let mut blocks: Vec<_> = (0..4)
.map(|i| para_block(&format!("fill{i}"), 30.0))
.collect();
blocks.push(styled_para_block("heading", true, false));
blocks.push(LayoutBlock::Table {
rows: vec![
TableRowInput {
cells: vec![
TableCellInput {
blocks: (0..6)
.map(|i| para_block(&format!("restart-{i}"), 30.0))
.collect(),
margins: PtEdgeInsets::ZERO,
grid_span: 1,
shading: None,
cell_borders: None,
vertical_merge: Some(
crate::render::layout::table::VerticalMergeState::Restart,
),
vertical_align: crate::render::layout::table::CellVAlign::Top,
},
TableCellInput {
blocks: vec![para_block("row0-peer", 30.0)],
margins: PtEdgeInsets::ZERO,
grid_span: 1,
shading: None,
cell_borders: None,
vertical_merge: None,
vertical_align: crate::render::layout::table::CellVAlign::Top,
},
],
height_rule: None,
is_header: None,
cant_split: None,
grid_before: 0,
grid_after: 0,
border_overrides: None,
},
TableRowInput {
cells: vec![
TableCellInput {
blocks: vec![],
margins: PtEdgeInsets::ZERO,
grid_span: 1,
shading: None,
cell_borders: None,
vertical_merge: Some(
crate::render::layout::table::VerticalMergeState::Continue,
),
vertical_align: crate::render::layout::table::CellVAlign::Top,
},
TableCellInput {
blocks: (0..2)
.map(|i| para_block(&format!("continuation-{i}"), 30.0))
.collect(),
margins: PtEdgeInsets::ZERO,
grid_span: 1,
shading: None,
cell_borders: None,
vertical_merge: None,
vertical_align: crate::render::layout::table::CellVAlign::Top,
},
],
height_rule: None,
is_header: None,
cant_split: None,
grid_before: 0,
grid_after: 0,
border_overrides: None,
},
TableRowInput {
cells: vec![
TableCellInput {
blocks: vec![para_block("after-merged", 30.0)],
margins: PtEdgeInsets::ZERO,
grid_span: 1,
shading: None,
cell_borders: None,
vertical_merge: None,
vertical_align: crate::render::layout::table::CellVAlign::Top,
},
TableCellInput {
blocks: vec![para_block("after-peer", 30.0)],
margins: PtEdgeInsets::ZERO,
grid_span: 1,
shading: None,
cell_borders: None,
vertical_merge: None,
vertical_align: crate::render::layout::table::CellVAlign::Top,
},
],
height_rule: None,
is_header: None,
cant_split: None,
grid_before: 0,
grid_after: 0,
border_overrides: None,
},
],
col_widths: vec![Pt::new(50.0), Pt::new(50.0)],
border_config: None,
indent: Pt::ZERO,
alignment: None,
float_info: None,
style_id: None,
});
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
let per_page = texts_per_page(&pages);
let heading_page = per_page
.iter()
.position(|page| page.iter().any(|text| text == "heading"))
.unwrap();
let restart_page = per_page
.iter()
.position(|page| page.iter().any(|text| text == "restart-0"))
.unwrap();
assert_eq!(heading_page, 0);
assert!(heading_page < restart_page);
let flattened: Vec<_> = per_page.into_iter().flatten().collect();
for text in [
"restart-0",
"restart-1",
"restart-2",
"restart-3",
"restart-4",
"restart-5",
"row0-peer",
"continuation-0",
"continuation-1",
"after-merged",
"after-peer",
] {
assert_eq!(
flattened
.iter()
.filter(|candidate| *candidate == text)
.count(),
1
);
}
}
#[test]
fn large_table_keep_next_preflight_does_not_probe_later_groups() {
let mut blocks: Vec<_> = (0..4)
.map(|i| para_block(&format!("fill{i}"), 30.0))
.collect();
blocks.push(styled_para_block("heading", true, false));
blocks.push(LayoutBlock::Table {
rows: (0..24)
.map(|i| row_with_label(&format!("row{i}")))
.collect(),
col_widths: vec![Pt::new(100.0)],
border_config: None,
indent: Pt::ZERO,
alignment: None,
float_info: None,
style_id: None,
});
let (pages, warnings) = capture_table_warnings(|| {
layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
)
});
assert!(
warnings.is_empty(),
"unexpected table warnings: {warnings:?}"
);
let flattened: Vec<_> = texts_per_page(&pages).into_iter().flatten().collect();
assert_eq!(
flattened.iter().filter(|text| *text == "heading").count(),
1
);
for index in 0..24 {
let needle = format!("row{index}");
assert_eq!(flattened.iter().filter(|text| *text == &needle).count(), 1);
}
}
#[test]
fn keep_next_does_not_force_an_entire_multi_page_table() {
let blocks = vec![
styled_para_block("heading", true, false),
one_column_table(&[
("row0", true),
("row1", false),
("row2", false),
("row3", false),
("row4", false),
("row5", false),
]),
];
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert!(pages.len() > 1);
assert!(texts_per_page(&pages)[0]
.iter()
.any(|text| text == "heading"));
}
#[test]
fn explicit_page_break_terminates_keep_next_group() {
let blocks = vec![
styled_para_block("heading", true, false),
styled_para_block("forced", false, true),
];
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
let per_page = texts_per_page(&pages);
assert!(per_page[0].iter().any(|text| text == "heading"));
assert!(per_page[1].iter().any(|text| text == "forced"));
}
#[test]
fn oversized_keep_next_chain_makes_progress_without_duplicates() {
let mut blocks = Vec::new();
for index in 0..6 {
blocks.push(styled_para_block(
&format!("chain{index}"),
index < 5,
false,
));
}
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
let flattened: Vec<_> = texts_per_page(&pages).into_iter().flatten().collect();
for index in 0..6 {
let needle = format!("chain{index}");
assert_eq!(flattened.iter().filter(|text| *text == &needle).count(), 1);
}
}
fn multiline_keep_next(prefix: &str, n: usize) -> LayoutBlock {
LayoutBlock::Paragraph {
fragments: (0..n)
.map(|i| text_frag(&format!("{prefix}{i}"), 100.0, 14.0))
.collect(),
style: ParagraphStyle {
keep_next: true,
..Default::default()
},
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
}
}
fn tall_config() -> PageConfig {
use crate::render::layout::page::ColumnGeometry;
PageConfig {
page_size: PtSize::new(Pt::new(200.0), Pt::new(160.0)),
margins: PtEdgeInsets::new(Pt::new(10.0), Pt::new(10.0), Pt::new(10.0), Pt::new(10.0)),
header_margin: Pt::new(5.0),
footer_margin: Pt::new(5.0),
columns: vec![ColumnGeometry {
x_offset: Pt::ZERO,
width: Pt::new(180.0),
}],
}
}
#[test]
fn splittable_leading_keep_next_paragraph_fills_current_page() {
let mut blocks = vec![para_block("fill0", 100.0), para_block("fill1", 100.0)];
blocks.push(multiline_keep_next("kn", 4));
blocks.push(para_block("body", 100.0));
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
let per_page = texts_per_page(&pages);
assert!(pages.len() >= 2);
assert!(
per_page[0].iter().any(|t| t == "kn0"),
"leading keepNext paragraph should fill the current page: {per_page:?}"
);
let kn_last = per_page
.iter()
.position(|p| p.iter().any(|t| t == "kn3"))
.expect("kn3 present");
let body = per_page
.iter()
.position(|p| p.iter().any(|t| t == "body"))
.expect("body present");
assert_eq!(
kn_last, body,
"keepNext binds the last line to the next block"
);
}
#[test]
fn keep_next_chain_leading_split_keeps_all_boundaries() {
let mut blocks: Vec<LayoutBlock> = (0..5)
.map(|i| para_block(&format!("fill{i}"), 100.0))
.collect();
blocks.push(multiline_keep_next("lead", 6));
blocks.push(multiline_keep_next("mid", 2));
blocks.push(para_block("term", 100.0));
let pages = layout_section(&blocks, &tall_config(), None, Pt::ZERO, Pt::new(14.0), None);
let per_page = texts_per_page(&pages);
assert!(pages.len() >= 2);
assert!(
per_page[0].iter().any(|t| t == "lead0"),
"leading paragraph head fills the current page: {per_page:?}"
);
let find = |needle: &str| {
per_page
.iter()
.position(|p| p.iter().any(|t| t == needle))
.unwrap_or_else(|| panic!("{needle} present: {per_page:?}"))
};
assert_eq!(find("lead5"), find("mid0"), "lead→mid boundary intact");
assert_eq!(find("mid1"), find("term"), "mid→term boundary intact");
}
#[test]
fn three_line_keep_next_paragraph_moves_whole_under_widow_control() {
let mut blocks = vec![para_block("fill0", 100.0), para_block("fill1", 100.0)];
blocks.push(multiline_keep_next("kn", 3));
blocks.push(para_block("body", 100.0));
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
let per_page = texts_per_page(&pages);
assert!(pages.len() >= 2);
assert!(
!per_page[0].iter().any(|t| t == "kn0"),
"an unsplittable 3-line keepNext paragraph must move whole: {per_page:?}"
);
let kn_last = per_page
.iter()
.position(|p| p.iter().any(|t| t == "kn2"))
.unwrap();
let body = per_page
.iter()
.position(|p| p.iter().any(|t| t == "body"))
.unwrap();
assert_eq!(kn_last, body);
}
#[test]
fn floating_table_splits_when_taller_than_anchor_page() {
let blocks = vec![floating_table_with_rows(8, 15.0)];
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert!(
pages.len() >= 2,
"overflowing floating table must paginate: got {} pages",
pages.len()
);
let per_page = texts_per_page(&pages);
let all: Vec<&String> = per_page.iter().flatten().collect();
for i in 0..8 {
let needle = format!("r{i}");
assert!(
all.iter().any(|t| t.as_str() == needle),
"row {needle} missing from output entirely",
);
}
}
#[test]
fn floating_table_split_rows_do_not_overlap_across_pages() {
let blocks = vec![floating_table_with_rows(8, 15.0)];
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
let per_page = texts_per_page(&pages);
for i in 0..8 {
let needle = format!("r{i}");
let on_pages: Vec<usize> = per_page
.iter()
.enumerate()
.filter_map(|(idx, page)| {
if page.iter().any(|t| t == &needle) {
Some(idx)
} else {
None
}
})
.collect();
assert_eq!(
on_pages.len(),
1,
"row {needle} appeared on pages {on_pages:?}; floating-table rows must not be duplicated",
);
}
}
fn floating_table_with_rows_and_overlap(
n: usize,
y_offset: f32,
overlap: Option<crate::model::TableOverlap>,
) -> LayoutBlock {
LayoutBlock::Table {
rows: (0..n).map(|i| row_with_label(&format!("r{i}"))).collect(),
col_widths: vec![Pt::new(100.0)],
border_config: None,
indent: Pt::ZERO,
alignment: None,
float_info: Some(super::TableFloatInfo {
right_gap: Pt::ZERO,
bottom_gap: Pt::ZERO,
x_align: None,
y_offset: Pt::new(y_offset),
vert_anchor: crate::model::TableAnchor::Page,
overlap,
}),
style_id: None,
}
}
#[test]
fn floating_tables_never_overlap_when_both_set_never() {
use crate::model::TableOverlap;
let blocks = vec![
floating_table_with_rows_and_overlap(3, 15.0, Some(TableOverlap::Never)),
floating_table_with_rows_and_overlap(3, 15.0, Some(TableOverlap::Never)),
];
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
let mut row_positions: Vec<(usize, f32, f32, String)> = Vec::new();
for (pi, page) in pages.iter().enumerate() {
for cmd in &page.commands {
if let DrawCommand::Text { text, position, .. } = cmd {
if text.starts_with('r') {
row_positions.push((
pi,
position.x.raw(),
position.y.raw(),
text.to_string(),
));
}
}
}
}
for i in 0..row_positions.len() {
for j in (i + 1)..row_positions.len() {
let (pi, xi, yi, ti) = &row_positions[i];
let (pj, xj, yj, tj) = &row_positions[j];
if pi == pj && (xi - xj).abs() < 1.0 && (yi - yj).abs() < 0.5 {
panic!(
"two row labels at same position on page {pi}: {ti:?} and {tj:?} both at y={yi}",
);
}
}
}
}
#[test]
fn floating_tables_overlap_by_default() {
let blocks = vec![
floating_table_with_rows_and_overlap(3, 15.0, None),
floating_table_with_rows_and_overlap(3, 15.0, None),
];
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert!(!pages.is_empty());
let page1_rows: Vec<f32> = pages[0]
.commands
.iter()
.filter_map(|c| match c {
DrawCommand::Text { text, position, .. } if text.starts_with('r') => {
Some(position.y.raw())
}
_ => None,
})
.collect();
assert_eq!(
page1_rows.len(),
6,
"with overlap permitted, both tables draw on page 1: got {} row entries",
page1_rows.len()
);
}
#[test]
fn floating_table_continuation_anchors_at_top_margin() {
let blocks = vec![floating_table_with_rows(8, 15.0)];
let config = small_config();
let pages = layout_section(&blocks, &config, None, Pt::ZERO, Pt::new(14.0), None);
assert!(pages.len() >= 2, "test precondition: expected overflow");
let first_text_y =
|page: &crate::render::layout::draw_command::LayoutedPage| -> Option<f32> {
page.commands.iter().find_map(|c| match c {
DrawCommand::Text { position, .. } => Some(position.y.raw()),
_ => None,
})
};
let y_p1 = first_text_y(&pages[0]).expect("page 1 must contain table content");
let y_p2 = first_text_y(&pages[1]).expect("page 2 must contain table content");
let anchor_y = 15.0;
let margin_top = config.margins.top.raw();
assert!(
y_p2 < y_p1,
"continuation (page 2 first y={y_p2}) must start above the anchor (page 1 first y={y_p1}); anchor={anchor_y}, margin.top={margin_top}",
);
assert!(y_p2 >= margin_top - 1.0);
}
#[test]
fn identical_borders_suppress_second_top() {
use crate::render::layout::paragraph::{BorderLine, ParagraphBorderStyle};
let border = Some(ParagraphBorderStyle {
top: Some(BorderLine {
width: Pt::new(0.5),
color: RgbColor::BLACK,
space: Pt::new(1.0),
}),
bottom: None,
left: None,
right: None,
});
let style1 = ParagraphStyle {
borders: border.clone(),
..Default::default()
};
let style2 = ParagraphStyle {
borders: border,
..Default::default()
};
let blocks = vec![
LayoutBlock::Paragraph {
fragments: vec![text_frag("para1", 30.0, 14.0)],
style: style1,
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
},
LayoutBlock::Paragraph {
fragments: vec![text_frag("para2", 30.0, 14.0)],
style: style2,
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
},
];
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
let line_cmds: Vec<_> = pages[0]
.commands
.iter()
.filter(|c| matches!(c, DrawCommand::Line { .. }))
.collect();
assert_eq!(
line_cmds.len(),
1,
"only one top border line (grouped): got {}",
line_cmds.len()
);
}
fn para_with_abs_left_float(text: &str, fx: f32, fy: f32, fw: f32, fh: f32) -> LayoutBlock {
LayoutBlock::Paragraph {
fragments: vec![text_frag(text, 20.0, 14.0)],
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![FloatingImage {
image_data: crate::render::resolve::images::MediaEntry {
data: std::rc::Rc::from(&[][..]),
format: crate::model::ImageFormat::Png,
},
size: PtSize::new(Pt::new(fw), Pt::new(fh)),
src_rect: None,
x: Pt::new(fx),
y: FloatingImageY::Absolute(Pt::new(fy)),
wrap_mode: WrapMode::Square(crate::model::WrapText::BothSides),
dist_left: Pt::ZERO,
dist_right: Pt::ZERO,
behind_doc: false,
}],
floating_shapes: vec![],
}
}
#[test]
fn continuation_re_fits_around_a_float_on_its_own_page() {
let float_block = para_with_abs_left_float("Q", 10.0, 10.0, 50.0, 40.0);
let mut blocks: Vec<LayoutBlock> = (0..3)
.map(|i| para_block(&format!("fill{i}"), 100.0))
.collect();
blocks.push(multiline_para(6, false, true));
blocks.push(float_block);
let pages = layout_section(
&blocks,
&small_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
assert!(pages.len() >= 2, "paragraph must split across pages");
let word2_x = pages[1]
.commands
.iter()
.find_map(|c| match c {
DrawCommand::Text { text, position, .. } if text.trim() == "word2" => {
Some(position.x.raw())
}
_ => None,
})
.expect("word2 on page 2");
assert!(
(word2_x - 60.0).abs() < 0.5,
"continuation must wrap around the next-page float (x≈60), got {word2_x}"
);
}
fn unequal_two_column_config() -> PageConfig {
use crate::render::layout::page::ColumnGeometry;
PageConfig {
page_size: PtSize::new(Pt::new(200.0), Pt::new(100.0)),
margins: PtEdgeInsets::new(Pt::new(10.0), Pt::new(10.0), Pt::new(10.0), Pt::new(10.0)),
header_margin: Pt::new(5.0),
footer_margin: Pt::new(5.0),
columns: vec![
ColumnGeometry {
x_offset: Pt::ZERO,
width: Pt::new(100.0),
},
ColumnGeometry {
x_offset: Pt::new(110.0),
width: Pt::new(70.0),
},
],
}
}
#[test]
fn paragraph_splits_across_unequal_width_columns() {
let fragments: Vec<Fragment> = (0..8)
.map(|i| text_frag(&format!("w{i} "), 65.0, 14.0))
.collect();
let block = LayoutBlock::Paragraph {
fragments,
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
};
let pages = layout_section(
&[block],
&unequal_two_column_config(),
None,
Pt::ZERO,
Pt::new(14.0),
None,
);
let placed: Vec<(f32, String)> = pages
.iter()
.flat_map(|p| p.commands.iter())
.filter_map(|c| match c {
DrawCommand::Text { position, text, .. } => {
Some((position.x.raw(), text.trim().to_string()))
}
_ => None,
})
.collect();
assert_eq!(
placed.len(),
8,
"every word emitted exactly once: {placed:?}"
);
let in_col0 = placed
.iter()
.filter(|(x, _)| (*x - 10.0).abs() < 0.5)
.count();
let in_col1 = placed
.iter()
.filter(|(x, _)| (*x - 120.0).abs() < 0.5)
.count();
assert!(
in_col0 >= 2,
"col 0 (x≈10, 100pt wide) holds the head: {placed:?}"
);
assert!(
in_col1 >= 1,
"col 1 (x≈120, 70pt wide) holds the re-fit continuation: {placed:?}"
);
assert_eq!(
in_col0 + in_col1,
8,
"all words land in one column or the other"
);
}
}