use crate::geometry::{Constraints, Rect, Size};
use crate::layoutable::{LayoutCtx, LayoutResult, Layoutable};
use crate::render_node::{align_offset, RenderNode};
use crate::warnings::{LayoutWarning, LayoutWarningKind};
use lightweight_pdf_core::{Color, ColumnWidth, Common, Element, Table, TableColumn};
const EPS: f32 = 0.01;
fn resolve_column_widths(columns: &[TableColumn], available_width: f32) -> Vec<f32> {
let fixed_total: f32 = columns
.iter()
.filter_map(|c| match c.width {
ColumnWidth::Fixed(w) => Some(w),
ColumnWidth::Flex(_) => None,
})
.sum();
let flex_sum: f32 = columns
.iter()
.filter_map(|c| match c.width {
ColumnWidth::Flex(w) => Some(w),
ColumnWidth::Fixed(_) => None,
})
.sum();
let leftover = (available_width - fixed_total).max(0.0);
let mut widths = Vec::with_capacity(columns.len());
let mut last_flex_idx = None;
for (i, c) in columns.iter().enumerate() {
match c.width {
ColumnWidth::Fixed(w) => widths.push(w),
ColumnWidth::Flex(weight) => {
widths.push(if flex_sum > 0.0 { leftover * (weight / flex_sum) } else { 0.0 });
last_flex_idx = Some(i);
}
}
}
if let Some(i) = last_flex_idx {
let sum: f32 = widths.iter().sum();
widths[i] += available_width - sum;
}
widths
}
fn measure_row_height(ctx: &LayoutCtx, cells: &[Element], col_widths: &[f32], cell_padding: f32) -> f32 {
cells
.iter()
.zip(col_widths.iter())
.map(|(cell, w)| {
let inner_w = (w - 2.0 * cell_padding).max(0.0);
cell.measure(
ctx,
Constraints {
max_width: inner_w,
max_height: f32::INFINITY,
},
)
.height
+ 2.0 * cell_padding
})
.fold(0.0f32, f32::max)
}
pub fn table_min_unit(ctx: &LayoutCtx, table: &Table, width: f32) -> f32 {
let col_widths = resolve_column_widths(&table.columns, (width - 2.0 * table.common.padding).max(0.0));
let header_h = table
.header
.as_ref()
.map(|h| measure_row_height(ctx, h, &col_widths, table.cell_padding))
.unwrap_or(0.0);
let first_row_h = table
.rows
.first()
.map(|r| measure_row_height(ctx, r, &col_widths, table.cell_padding))
.unwrap_or(0.0);
header_h + first_row_h
}
#[allow(clippy::too_many_arguments)]
fn layout_row_cells(
ctx: &LayoutCtx,
cells: &[Element],
columns: &[TableColumn],
col_widths: &[f32],
row_area: Rect,
cell_padding: f32,
warnings: &mut Vec<LayoutWarning>,
page: usize,
) -> Vec<RenderNode> {
let mut nodes = Vec::with_capacity(cells.len());
let mut cursor_x = row_area.x;
for ((cell, col), w) in cells.iter().zip(columns.iter()).zip(col_widths.iter()) {
let inner_w = (w - 2.0 * cell_padding).max(0.0);
let content_h = (row_area.height - 2.0 * cell_padding).max(0.0);
let cell_size = cell.measure(
ctx,
Constraints {
max_width: inner_w,
max_height: f32::INFINITY,
},
);
let box_width = cell_size.width.min(inner_w).max(0.0);
let x_offset = align_offset(col.align, inner_w, box_width);
let cell_area = Rect {
x: cursor_x + cell_padding + x_offset,
y: row_area.y + cell_padding,
width: box_width,
height: content_h,
};
match cell.layout(ctx, cell_area, warnings, page) {
LayoutResult::Fit(node) => nodes.push(node),
LayoutResult::Split { current, .. } => {
nodes.push(current);
}
}
cursor_x += *w;
}
nodes
}
#[allow(clippy::too_many_arguments)]
fn render_row(
ctx: &LayoutCtx,
table: &Table,
cells: &[Element],
col_widths: &[f32],
y: f32,
inner: &Rect,
row_height: f32,
background: Option<Color>,
warnings: &mut Vec<LayoutWarning>,
page: usize,
) -> RenderNode {
let row_area = Rect {
x: inner.x,
y: inner.y + y,
width: inner.width,
height: row_height,
};
let nodes = layout_row_cells(ctx, cells, &table.columns, col_widths, row_area, table.cell_padding, warnings, page);
RenderNode::Group {
area: row_area,
clip: true,
background,
border: None,
children: nodes,
}
}
impl Layoutable for Table {
fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
let width = self.common.width.unwrap_or(constraints.max_width);
let inner_width = (width - 2.0 * self.common.padding).max(0.0);
let col_widths = resolve_column_widths(&self.columns, inner_width);
let mut total = 0.0f32;
if let Some(header) = &self.header {
total += measure_row_height(ctx, header, &col_widths, self.cell_padding);
}
for row in &self.rows {
total += measure_row_height(ctx, row, &col_widths, self.cell_padding);
}
Size {
width,
height: self.common.height.unwrap_or(total + 2.0 * self.common.padding),
}
}
fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
let inner = area.shrink(self.common.padding);
let col_widths = resolve_column_widths(&self.columns, inner.width);
let bound_height = self.common.height.map(|h| h - 2.0 * self.common.padding).unwrap_or(inner.height);
let header_height = self
.header
.as_ref()
.map(|h| measure_row_height(ctx, h, &col_widths, self.cell_padding))
.unwrap_or(0.0);
let mut rendered = Vec::new();
let mut cursor_y = 0.0f32;
if let Some(header) = &self.header {
rendered.push(render_row(
ctx,
self,
header,
&col_widths,
cursor_y,
&inner,
header_height,
None,
warnings,
page,
));
cursor_y += header_height;
}
for (i, row) in self.rows.iter().enumerate() {
let absolute_i = self.row_offset + i;
let row_height = measure_row_height(ctx, row, &col_widths, self.cell_padding);
let remaining = (bound_height - cursor_y).max(0.0);
let stripe = self.striped.filter(|_| absolute_i % 2 == 1);
if row_height <= remaining + EPS {
rendered.push(render_row(
ctx,
self,
row,
&col_widths,
cursor_y,
&inner,
row_height,
stripe,
warnings,
page,
));
cursor_y += row_height;
continue;
}
if cursor_y <= header_height + EPS {
rendered.push(render_row(
ctx,
self,
row,
&col_widths,
cursor_y,
&inner,
remaining,
stripe,
warnings,
page,
));
warnings.push(LayoutWarning {
kind: LayoutWarningKind::ForcedPageBreak,
page,
element_hint: format!("Table row {absolute_i} larger than one page"),
});
cursor_y = bound_height;
continue;
}
if let Some(fixed_height) = self.common.height {
if !self.rows[i..].is_empty() {
warnings.push(LayoutWarning {
kind: LayoutWarningKind::ContentOverflow,
page,
element_hint: "Table content exceeds its fixed height".to_string(),
});
}
return LayoutResult::Fit(RenderNode::Group {
area: Rect {
height: fixed_height,
..area
},
clip: true,
background: self.common.background,
border: self.common.border,
children: rendered,
});
}
let remainder = Table {
columns: self.columns.clone(),
header: self.header.clone(),
rows: self.rows[i..].to_vec(),
striped: self.striped,
cell_padding: self.cell_padding,
row_offset: absolute_i,
common: Common {
height: None,
..self.common
},
};
let current = RenderNode::Group {
area: Rect { height: cursor_y, ..area },
clip: true,
background: self.common.background,
border: self.common.border,
children: rendered,
};
return LayoutResult::Split {
current,
remainder: Element::Table(remainder),
};
}
let outer_height = self.common.height.unwrap_or(cursor_y + 2.0 * self.common.padding);
LayoutResult::Fit(RenderNode::Group {
area: Rect {
height: outer_height,
..area
},
clip: true,
background: self.common.background,
border: self.common.border,
children: rendered,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::warnings::LayoutWarningKind;
use lightweight_pdf_core::{Align, Element, Text as TextEl};
struct FixedMetrics;
impl crate::font_resolver::FontMetrics for FixedMetrics {
fn advance(&self, ch: char) -> f32 {
if ch == ' ' {
300.0
} else {
600.0
}
}
fn ascent(&self) -> f32 {
800.0
}
fn descent(&self) -> f32 {
-200.0
}
}
struct FixedResolver;
impl crate::font_resolver::FontResolver for FixedResolver {
fn metrics(&self, _key: lightweight_pdf_core::FontKey) -> &dyn crate::font_resolver::FontMetrics {
&FixedMetrics
}
}
fn ctx() -> LayoutCtx<'static> {
LayoutCtx { resolver: &FixedResolver }
}
fn row(cells: &[&str]) -> Vec<Element> {
cells.iter().map(|c| Element::Text(TextEl::new(*c))).collect()
}
#[test]
fn column_widths_sum_exactly_to_available_width() {
let columns = vec![
TableColumn::flex(1.0),
TableColumn::fixed(37.3),
TableColumn::flex(2.0),
TableColumn::fixed(19.9),
];
let widths = resolve_column_widths(&columns, 400.0);
let sum: f32 = widths.iter().sum();
assert!((sum - 400.0).abs() < 1e-3, "widths must sum exactly to available width, got {sum}");
assert_eq!(widths[1], 37.3);
assert_eq!(widths[3], 19.9);
}
#[test]
fn header_repeats_and_all_rows_survive_a_page_split() {
let table = Table::new()
.columns([TableColumn::flex(1.0)])
.header(["Beschreibung"])
.rows((0..20).map(|i| row(&[Box::leak(format!("Zeile {i}").into_boxed_str())])));
let c = ctx();
let mut warnings = Vec::new();
let area = Rect {
x: 0.0,
y: 0.0,
width: 200.0,
height: 60.0, };
let mut pages = Vec::new();
let mut current = Element::Table(table);
loop {
match current.layout(&c, area, &mut warnings, pages.len() + 1) {
LayoutResult::Fit(node) => {
pages.push(node);
break;
}
LayoutResult::Split { current: node, remainder } => {
pages.push(node);
current = remainder;
}
}
if pages.len() > 100 {
panic!("pagination did not terminate");
}
}
assert!(pages.len() > 1, "expected the table to span multiple pages");
let mut seen_rows = Vec::new();
for page in &pages {
let RenderNode::Group { children, .. } = page else {
panic!("expected a Group");
};
assert!(!children.is_empty(), "every page must render at least the header");
for row_node in children {
let RenderNode::Group { children: cells, .. } = row_node else {
panic!("expected row Group");
};
let RenderNode::Group { children: text_wrap, .. } = &cells[0] else {
panic!("expected clipped text wrapper");
};
let RenderNode::TextLines { lines, .. } = &text_wrap[0] else {
panic!("expected TextLines");
};
seen_rows.push(lines.join(" "));
}
}
let header_count = seen_rows.iter().filter(|s| *s == "Beschreibung").count();
assert_eq!(header_count, pages.len(), "header must repeat on every page exactly once");
let data_rows: Vec<_> = seen_rows.iter().filter(|s| *s != "Beschreibung").collect();
assert_eq!(data_rows.len(), 20, "no row may be lost or duplicated across the split");
for (i, row) in data_rows.iter().enumerate() {
assert_eq!(*row, &format!("Zeile {i}"), "rows must stay in order");
}
}
#[test]
fn cell_hard_breaks_a_token_wider_than_the_column() {
let table = Table::new().columns([TableColumn::fixed(30.0)]).rows([row(&["ABCDEFGHIJ"])]); let c = ctx();
let mut warnings = Vec::new();
let area = Rect {
x: 0.0,
y: 0.0,
width: 30.0,
height: 200.0,
};
let LayoutResult::Fit(RenderNode::Group { children, .. }) = Element::Table(table).layout(&c, area, &mut warnings, 1) else {
panic!("expected Fit");
};
let RenderNode::Group { children: cells, .. } = &children[0] else {
panic!("expected row group");
};
let RenderNode::Group { children: text_wrap, .. } = &cells[0] else {
panic!("expected clipped text wrapper");
};
let RenderNode::TextLines { lines, .. } = &text_wrap[0] else {
panic!("expected TextLines");
};
assert!(lines.len() > 1, "a token wider than the column must hard-break onto multiple lines");
}
#[test]
fn row_height_grows_with_tallest_cell_without_moving_other_rows() {
let table = Table::new().columns([TableColumn::fixed(30.0), TableColumn::fixed(30.0)]).rows([
row(&["kurz", "kurz"]),
row(&["ein sehr sehr sehr sehr langer Zellinhalt der umbricht", "kurz"]),
row(&["kurz", "kurz"]),
]);
let c = ctx();
let mut warnings = Vec::new();
let area = Rect {
x: 0.0,
y: 0.0,
width: 60.0,
height: 400.0,
};
let LayoutResult::Fit(RenderNode::Group { children: rows, .. }) = Element::Table(table).layout(&c, area, &mut warnings, 1) else {
panic!("expected Fit");
};
assert_eq!(rows.len(), 3);
let heights: Vec<f32> = rows
.iter()
.map(|r| match r {
RenderNode::Group { area, .. } => area.height,
_ => panic!("expected Group"),
})
.collect();
assert!(heights[1] > heights[0], "the row with more content must be taller");
assert_eq!(heights[0], heights[2], "unrelated rows keep their own (equal) height");
let ys: Vec<f32> = rows
.iter()
.map(|r| match r {
RenderNode::Group { area, .. } => area.y,
_ => unreachable!(),
})
.collect();
assert!(ys[1] >= ys[0] + heights[0] - EPS);
assert!(ys[2] >= ys[1] + heights[1] - EPS);
}
#[test]
fn striped_alternates_and_survives_a_split() {
let table = Table::new()
.columns([TableColumn::flex(1.0)])
.header(["H"])
.striped(Color::rgb(240, 240, 240))
.rows((0..6).map(|i| row(&[Box::leak(format!("R{i}").into_boxed_str())])));
let c = ctx();
let mut warnings = Vec::new();
let area = Rect {
x: 0.0,
y: 0.0,
width: 100.0,
height: 3.5 * 22.4,
};
let LayoutResult::Split { remainder, .. } = Element::Table(table).layout(&c, area, &mut warnings, 1) else {
panic!("expected a Split");
};
let Element::Table(remainder_table) = remainder else {
panic!("expected Table remainder");
};
assert_eq!(remainder_table.row_offset, 2);
}
#[test]
fn oversized_row_forces_its_own_page() {
let table = Table::new()
.columns([TableColumn::flex(1.0)])
.rows([row(&["normal"]), row(&["a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np"])]);
let c = ctx();
let mut warnings = Vec::new();
let area = Rect {
x: 0.0,
y: 0.0,
width: 100.0,
height: 100.0,
};
let mut pages = 0;
let mut current = Element::Table(table);
loop {
match current.layout(&c, area, &mut warnings, pages + 1) {
LayoutResult::Fit(_) => {
pages += 1;
break;
}
LayoutResult::Split { remainder, .. } => {
pages += 1;
current = remainder;
}
}
if pages > 50 {
panic!("pagination did not terminate");
}
}
assert!(pages >= 2, "the oversized row should push onto its own page");
assert!(warnings.iter().any(|w| w.kind == LayoutWarningKind::ForcedPageBreak));
}
#[test]
fn table_column_align_positions_short_content_in_the_column() {
let table = Table::new()
.columns([TableColumn::fixed(100.0).align(Align::End)])
.rows([row(&["42"])]);
let c = ctx();
let mut warnings = Vec::new();
let area = Rect {
x: 0.0,
y: 0.0,
width: 100.0,
height: 50.0,
};
let LayoutResult::Fit(RenderNode::Group { children: rows, .. }) = Element::Table(table).layout(&c, area, &mut warnings, 1) else {
panic!("expected Fit");
};
let RenderNode::Group { children: cells, .. } = &rows[0] else {
panic!("expected row group");
};
let RenderNode::Group { area: cell_area, .. } = &cells[0] else {
panic!("expected clipped cell wrapper");
};
assert!(cell_area.x > 50.0, "expected right-aligned cell, got x={}", cell_area.x);
}
}