use crate::render::dimension::Pt;
use crate::render::layout::draw_command::DrawCommand;
use crate::render::layout::section::CellLine;
use super::borders::CellBorders;
use super::types::{CellLayoutEntry, MeasuredRow, TableRowInput};
pub(super) struct RowCutInput<'a> {
pub(super) mr: &'a MeasuredRow,
pub(super) row: &'a TableRowInput,
pub(super) available: Pt,
}
struct CellCut {
content_cut_y: Pt,
line_cut_y: Pt,
shift: Pt,
}
impl CellCut {
fn keep_all() -> Self {
Self {
content_cut_y: Pt::new(f32::INFINITY),
line_cut_y: Pt::new(f32::INFINITY),
shift: Pt::ZERO,
}
}
}
pub(super) struct SplitCut {
first_half_height: Pt,
cells: Vec<CellCut>,
}
pub(super) fn find_row_cut(input: &RowCutInput<'_>) -> Option<SplitCut> {
let mut cells: Vec<CellCut> = Vec::with_capacity(input.row.cells.len());
let mut first_half_height = Pt::ZERO;
let mut any_fits = false;
let mut non_splittable_heights: Vec<Pt> = Vec::with_capacity(input.row.cells.len());
for (entry, cell) in input.mr.entries.iter().zip(&input.row.cells) {
match cut_for_cell(
entry,
cell.margins.top,
cell.margins.bottom,
input.available,
) {
Some((cut, half_h)) => {
any_fits = true;
if half_h > first_half_height {
first_half_height = half_h;
}
cells.push(cut);
non_splittable_heights.push(Pt::ZERO);
}
None => {
cells.push(CellCut::keep_all());
let required = entry.layout.content_height + cell.margins.top + cell.margins.bottom;
non_splittable_heights.push(required);
}
}
}
if !any_fits {
return None;
}
let non_splittable_max = non_splittable_heights
.iter()
.copied()
.fold(Pt::ZERO, Pt::max);
if non_splittable_max > first_half_height {
first_half_height = non_splittable_max;
}
if first_half_height > input.available {
return None;
}
Some(SplitCut {
first_half_height,
cells,
})
}
fn cut_for_cell(
entry: &CellLayoutEntry,
margin_top: Pt,
margin_bottom: Pt,
available: Pt,
) -> Option<(CellCut, Pt)> {
let budget = available - margin_top - margin_bottom;
if budget <= Pt::ZERO {
return None;
}
let cont_top = largest_legal_cut(&entry.layout.lines, budget)?;
if cont_top <= Pt::ZERO {
return None;
}
let shift = cont_top;
let half_h = cont_top + margin_top + margin_bottom;
Some((
CellCut {
content_cut_y: margin_top + cont_top,
line_cut_y: cont_top,
shift,
},
half_h,
))
}
fn largest_legal_cut(lines: &[CellLine], budget: Pt) -> Option<Pt> {
let n = lines.len();
if n < 2 {
return None;
}
let mut best: Option<Pt> = None;
for l in 0..n - 1 {
let a = &lines[l];
let b = &lines[l + 1];
let cont_top = b.top_y;
if cont_top > budget {
break;
}
let legal = if a.para == b.para {
if a.interior_atomic {
false
} else if a.widow_control {
let first = lines.iter().position(|x| x.para == a.para).unwrap_or(l);
let last = lines.iter().rposition(|x| x.para == a.para).unwrap_or(l);
let head = l + 1 - first; let tail = last - l; head >= 2 && tail >= 2
} else {
true
}
} else {
!a.keep_next
};
if legal {
best = Some(best.map_or(cont_top, |x: Pt| x.max(cont_top)));
}
}
best
}
pub(super) struct SplitRow {
pub(super) first: MeasuredRow,
pub(super) second: MeasuredRow,
}
pub(super) fn split_row_at(mr: &MeasuredRow, cut: &SplitCut) -> SplitRow {
let first_h = cut.first_half_height;
let max_shift = cut.cells.iter().map(|c| c.shift).fold(Pt::ZERO, Pt::max);
let second_h = (mr.height - max_shift).max(Pt::ZERO);
let mut first_entries: Vec<CellLayoutEntry> = Vec::with_capacity(mr.entries.len());
let mut second_entries: Vec<CellLayoutEntry> = Vec::with_capacity(mr.entries.len());
for (entry, cc) in mr.entries.iter().zip(cut.cells.iter()) {
let (first_cmds, second_cmds) =
partition_commands(&entry.layout.commands, cc.content_cut_y, cc.shift);
let (first_lines, second_lines) =
partition_lines(&entry.layout.lines, cc.line_cut_y, cc.shift);
first_entries.push(CellLayoutEntry {
layout: crate::render::layout::cell::CellLayout {
commands: first_cmds,
content_height: entry.layout.content_height.min(first_h),
lines: first_lines,
},
cell_x: entry.cell_x,
cell_w: entry.cell_w,
grid_col: entry.grid_col,
});
second_entries.push(CellLayoutEntry {
layout: crate::render::layout::cell::CellLayout {
commands: second_cmds,
content_height: (entry.layout.content_height - cc.shift).max(Pt::ZERO),
lines: second_lines,
},
cell_x: entry.cell_x,
cell_w: entry.cell_w,
grid_col: entry.grid_col,
});
}
let first_borders: Vec<CellBorders> = mr.borders.to_vec();
let second_borders: Vec<CellBorders> = mr
.borders
.iter()
.map(|b| CellBorders {
top: if b.top.line().is_some() {
b.top
} else {
b.bottom
},
bottom: b.bottom,
left: b.left,
right: b.right,
})
.collect();
SplitRow {
first: MeasuredRow {
entries: first_entries,
borders: first_borders,
height: first_h,
leading_gap: mr.leading_gap,
border_gap_below: Pt::ZERO,
},
second: MeasuredRow {
entries: second_entries,
borders: second_borders,
height: second_h,
leading_gap: mr.leading_gap,
border_gap_below: mr.border_gap_below,
},
}
}
fn partition_commands(
commands: &[DrawCommand],
cut_y: Pt,
shift: Pt,
) -> (Vec<DrawCommand>, Vec<DrawCommand>) {
let mut first = Vec::new();
let mut second = Vec::new();
for cmd in commands {
if command_primary_y(cmd) < cut_y {
first.push(cmd.clone());
} else {
let mut c = cmd.clone();
c.shift_y(-shift);
second.push(c);
}
}
(first, second)
}
fn partition_lines(lines: &[CellLine], cut_y: Pt, shift: Pt) -> (Vec<CellLine>, Vec<CellLine>) {
let mut first = Vec::new();
let mut second = Vec::new();
for line in lines {
if line.top_y < cut_y {
first.push(line.clone());
} else {
let mut l = line.clone();
l.top_y -= shift;
second.push(l);
}
}
(first, second)
}
fn command_primary_y(cmd: &DrawCommand) -> Pt {
match cmd {
DrawCommand::Text { position, .. } | DrawCommand::NamedDestination { position, .. } => {
position.y
}
DrawCommand::Underline { line, .. } | DrawCommand::Line { line, .. } => line.start.y,
DrawCommand::Image { rect, .. }
| DrawCommand::EmojiCluster { rect, .. }
| DrawCommand::Rect { rect, .. }
| DrawCommand::LinkAnnotation { rect, .. }
| DrawCommand::InternalLink { rect, .. } => rect.origin.y,
DrawCommand::Path { origin, .. } => origin.y,
DrawCommand::Outline(_) => Pt::ZERO,
}
}
#[cfg(test)]
mod tests {
use super::super::borders::CellEdge;
use super::*;
use crate::render::geometry::PtEdgeInsets;
use crate::render::layout::fragment::{FontProps, Fragment, TextMetrics};
use crate::render::layout::paragraph::ParagraphStyle;
use crate::render::layout::section::LayoutBlock;
use crate::render::layout::table::types::{CellVAlign, TableCellInput};
use crate::render::resolve::color::RgbColor;
use std::rc::Rc;
fn text_frag(text: &str) -> 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(30.0),
trimmed_width: Pt::new(30.0),
metrics: TextMetrics {
ascent: Pt::new(10.0),
descent: Pt::new(4.0),
leading: Pt::ZERO,
},
hyperlink_url: None,
shading: None,
border: None,
baseline_offset: Pt::ZERO,
text_offset: Pt::ZERO,
is_footnote_ref: false,
}
}
fn row_n_lines(n: usize, margin: f32) -> TableRowInput {
let m = PtEdgeInsets::new(
Pt::new(margin),
Pt::new(margin),
Pt::new(margin),
Pt::new(margin),
);
TableRowInput {
cells: vec![TableCellInput {
blocks: vec![LayoutBlock::Paragraph {
fragments: (0..n).map(|i| text_frag(&format!("L{i} "))).collect(),
style: ParagraphStyle::default(),
page_break_before: false,
footnotes: vec![],
floating_images: vec![],
floating_shapes: vec![],
}],
margins: m,
grid_span: 1,
shading: None,
cell_borders: None,
vertical_merge: None,
vertical_align: CellVAlign::Top,
}],
height_rule: None,
is_header: None,
cant_split: None,
grid_before: 0,
border_overrides: None,
}
}
fn measure(rows: &[TableRowInput]) -> super::super::types::MeasuredTable {
super::super::measure::measure_table_rows(
rows,
&[Pt::new(40.0)],
Pt::ZERO,
Pt::new(14.0),
None,
None,
false,
)
}
#[test]
fn every_accepted_cut_strictly_shrinks_the_row() {
for n_lines in [2usize, 3, 5, 12] {
for avail in [1.0f32, 5.0, 13.9, 14.0, 14.1, 27.9, 28.0, 100.0, 1000.0] {
for margin in [0.0f32, 3.0, 20.0] {
let rows = vec![row_n_lines(n_lines, margin)];
let measured = measure(&rows);
let input = RowCutInput {
mr: &measured.rows[0],
row: &rows[0],
available: Pt::new(avail),
};
let Some(cut) = find_row_cut(&input) else {
continue;
};
let parts = split_row_at(&measured.rows[0], &cut);
assert!(
parts.second.height < measured.rows[0].height,
"cut made no progress (lines={n_lines} avail={avail} \
margin={margin}): {:.2} -> {:.2}",
measured.rows[0].height.raw(),
parts.second.height.raw(),
);
}
}
}
}
#[test]
fn accepted_cuts_carry_a_positive_shift() {
let rows = vec![row_n_lines(6, 0.0)];
let measured = measure(&rows);
let cut = find_row_cut(&RowCutInput {
mr: &measured.rows[0],
row: &rows[0],
available: Pt::new(60.0),
})
.expect("a 6-line row must be splittable at 60pt");
let max_shift = cut.cells.iter().map(|c| c.shift).fold(Pt::ZERO, Pt::max);
assert!(
max_shift > Pt::ZERO,
"shift must be positive or the continuation loop cannot progress"
);
}
#[test]
fn zero_offset_cut_is_rejected_so_the_shift_is_never_zero() {
let flat_line = |para: usize| CellLine {
top_y: Pt::ZERO,
para,
interior_atomic: false,
widow_control: false,
keep_next: false,
};
let lines = vec![flat_line(0), flat_line(1)];
assert_eq!(
largest_legal_cut(&lines, Pt::new(100.0)),
Some(Pt::ZERO),
"two lines sharing a top expose a zero-offset cut"
);
let entry = CellLayoutEntry {
layout: crate::render::layout::cell::CellLayout {
commands: Vec::new(),
content_height: Pt::new(28.0),
lines,
},
cell_x: Pt::ZERO,
cell_w: Pt::new(40.0),
grid_col: 0,
};
assert!(
cut_for_cell(&entry, Pt::ZERO, Pt::ZERO, Pt::new(100.0)).is_none(),
"a zero-offset cut must be rejected - accepting it gives shift == 0 \
and the continuation loop never progresses"
);
}
#[test]
fn fewer_than_two_lines_yields_no_cut() {
assert_eq!(largest_legal_cut(&[], Pt::new(100.0)), None);
let one = vec![CellLine {
top_y: Pt::ZERO,
para: 0,
interior_atomic: false,
widow_control: false,
keep_next: false,
}];
assert_eq!(largest_legal_cut(&one, Pt::new(100.0)), None);
}
#[test]
fn iterative_continuation_split_halts_with_a_bounded_slice_count() {
use crate::render::layout::table::{layout_table_paginated, TablePaginationConfig};
let rows = vec![row_n_lines(40, 0.0)];
let slices = layout_table_paginated(
&rows,
&[Pt::new(40.0)],
Pt::ZERO,
Pt::new(14.0),
None,
None,
&TablePaginationConfig {
available_height: Pt::new(28.0), page_height: Pt::new(28.0),
suppress_first_row_top: false,
},
);
assert_eq!(slices.len(), 20, "40 lines at 2 per page");
}
fn texts(cmds: &[DrawCommand]) -> Vec<String> {
cmds.iter()
.filter_map(|c| match c {
DrawCommand::Text { text, .. } => Some(text.to_string()),
_ => None,
})
.collect()
}
fn baselines(cmds: &[DrawCommand]) -> Vec<f32> {
cmds.iter()
.filter_map(|c| match c {
DrawCommand::Text { position, .. } => Some(position.y.raw()),
_ => None,
})
.collect()
}
#[test]
fn cut_partitions_lines_without_duplicating_or_dropping_any() {
let rows = vec![row_n_lines(6, 0.0)];
let measured = measure(&rows);
let cut = find_row_cut(&RowCutInput {
mr: &measured.rows[0],
row: &rows[0],
available: Pt::new(4.0 * 14.0),
})
.expect("6 lines with room for 4 must be splittable");
let parts = split_row_at(&measured.rows[0], &cut);
let first = texts(&parts.first.entries[0].layout.commands);
let second = texts(&parts.second.entries[0].layout.commands);
assert_eq!(first.len() + second.len(), 6, "no line lost or duplicated");
assert_eq!(first, vec!["L0 ", "L1 ", "L2 ", "L3 "]);
assert_eq!(second, vec!["L4 ", "L5 "]);
}
#[test]
fn continuation_first_line_is_rebased_to_the_cell_top() {
let rows = vec![row_n_lines(6, 0.0)];
let measured = measure(&rows);
let cut = find_row_cut(&RowCutInput {
mr: &measured.rows[0],
row: &rows[0],
available: Pt::new(4.0 * 14.0),
})
.expect("splittable");
let parts = split_row_at(&measured.rows[0], &cut);
let before = baselines(&measured.rows[0].entries[0].layout.commands);
let first = baselines(&parts.first.entries[0].layout.commands);
let second = baselines(&parts.second.entries[0].layout.commands);
assert_eq!(
first,
before[..4],
"first half keeps its original baselines"
);
let shift = before[4] - second[0];
assert!(
(shift - 4.0 * 14.0).abs() < 0.01,
"tail shifted by the retained content height, got {shift}"
);
assert_eq!(
second[0], before[0],
"the continuation's first line lands where the original first line did"
);
}
#[test]
fn continuation_line_model_is_rebased_with_the_commands() {
let rows = vec![row_n_lines(6, 0.0)];
let measured = measure(&rows);
let cut = find_row_cut(&RowCutInput {
mr: &measured.rows[0],
row: &rows[0],
available: Pt::new(4.0 * 14.0),
})
.expect("splittable");
let parts = split_row_at(&measured.rows[0], &cut);
let second_lines = &parts.second.entries[0].layout.lines;
assert_eq!(second_lines.len(), 2, "two lines continue");
assert_eq!(
second_lines[0].top_y,
Pt::ZERO,
"the continuation's first line starts at content offset 0"
);
assert_eq!(parts.first.entries[0].layout.lines.len(), 4);
}
#[test]
fn a_line_exactly_at_the_threshold_goes_to_the_continuation() {
let line_at = |top: f32, para: usize| CellLine {
top_y: Pt::new(top),
para,
interior_atomic: false,
widow_control: false,
keep_next: false,
};
let lines = vec![line_at(0.0, 0), line_at(14.0, 0), line_at(28.0, 0)];
let (first, second) = partition_lines(&lines, Pt::new(14.0), Pt::new(14.0));
assert_eq!(first.len(), 1, "only the line strictly above the cut stays");
assert_eq!(second.len(), 2);
assert_eq!(
second[0].top_y,
Pt::ZERO,
"the threshold line becomes the continuation's first, rebased to 0"
);
assert_eq!(second[1].top_y, Pt::new(14.0));
}
#[test]
fn both_halves_account_for_the_cell_margins() {
const MARGIN: f32 = 5.0;
let rows = vec![row_n_lines(6, MARGIN)];
let measured = measure(&rows);
let available = Pt::new(2.0 * 14.0 + 2.0 * MARGIN);
let cut = find_row_cut(&RowCutInput {
mr: &measured.rows[0],
row: &rows[0],
available,
})
.expect("splittable with margins");
let parts = split_row_at(&measured.rows[0], &cut);
assert!(
parts.first.height <= available,
"first half ({:.1}) must fit the space it was given ({:.1})",
parts.first.height.raw(),
available.raw()
);
assert!(
(parts.first.height.raw() - (2.0 * 14.0 + 2.0 * MARGIN)).abs() < 0.01,
"first-half height is retained content + both margins, got {:.1}",
parts.first.height.raw()
);
assert!(
parts.second.height > Pt::ZERO,
"the continuation keeps real height"
);
}
#[test]
fn continuation_inherits_a_top_border_from_the_original_bottom() {
let bottom = super::super::types::TableBorderLine {
width: Pt::new(2.0),
color: crate::render::resolve::color::RgbColor::BLACK,
style: super::super::types::TableBorderStyle::Single,
};
let mut mr = measure(&[row_n_lines(6, 0.0)]).rows.pop().expect("one row");
mr.borders[0] = CellBorders {
top: CellEdge::Absent,
bottom: CellEdge::Line(bottom),
left: CellEdge::Absent,
right: CellEdge::Absent,
};
let rows = [row_n_lines(6, 0.0)];
let cut = find_row_cut(&RowCutInput {
mr: &mr,
row: &rows[0],
available: Pt::new(4.0 * 14.0),
})
.expect("splittable");
let parts = split_row_at(&mr, &cut);
assert!(
parts.first.borders[0].top.line().is_none(),
"the first half keeps the original borders verbatim"
);
assert_eq!(
parts.second.borders[0].top.line().map(|b| b.width),
Some(Pt::new(2.0)),
"the continuation falls back to the original bottom for its top edge"
);
}
#[test]
fn continuation_keeps_an_explicit_top_border() {
let thin = super::super::types::TableBorderLine {
width: Pt::new(1.0),
color: crate::render::resolve::color::RgbColor::BLACK,
style: super::super::types::TableBorderStyle::Single,
};
let thick = super::super::types::TableBorderLine {
width: Pt::new(4.0),
..thin
};
let mut mr = measure(&[row_n_lines(6, 0.0)]).rows.pop().expect("one row");
mr.borders[0] = CellBorders {
top: CellEdge::Line(thin),
bottom: CellEdge::Line(thick),
left: CellEdge::Absent,
right: CellEdge::Absent,
};
let rows = [row_n_lines(6, 0.0)];
let cut = find_row_cut(&RowCutInput {
mr: &mr,
row: &rows[0],
available: Pt::new(4.0 * 14.0),
})
.expect("splittable");
let parts = split_row_at(&mr, &cut);
assert_eq!(
parts.second.borders[0].top.line().map(|b| b.width),
Some(Pt::new(1.0)),
"an existing top border wins over the bottom fallback"
);
}
#[test]
fn a_command_exactly_at_the_threshold_goes_to_the_continuation() {
use crate::render::geometry::PtRect;
let rect_at = |y: f32| DrawCommand::Rect {
rect: PtRect::from_xywh(Pt::ZERO, Pt::new(y), Pt::new(10.0), Pt::new(2.0)),
color: crate::render::resolve::color::RgbColor::BLACK,
};
let cmds = vec![rect_at(0.0), rect_at(14.0), rect_at(28.0)];
let (first, second) = partition_commands(&cmds, Pt::new(14.0), Pt::new(14.0));
let ys = |v: &[DrawCommand]| -> Vec<f32> {
v.iter()
.map(|c| match c {
DrawCommand::Rect { rect, .. } => rect.origin.y.raw(),
_ => unreachable!(),
})
.collect()
};
assert_eq!(
ys(&first),
vec![0.0],
"only the command strictly above the cut stays"
);
assert_eq!(
ys(&second),
vec![0.0, 14.0],
"the threshold command continues, rebased by the shift"
);
}
}