mod column;
mod leaf;
mod row;
mod shared;
pub(crate) use shared::{
clip_to_fixed_height, coerce_to_fit, coerce_to_fit_and_warn, finish_fit, measure_at_width, push_warning, resolve_auto_size,
resolve_bound, shrink_and_bound_height, wrap_children,
};
use crate::font_resolver::FontResolver;
use crate::geometry::{Constraints, Rect, Size};
use crate::render_node::RenderNode;
use crate::warnings::LayoutWarning;
use lightweight_pdf_core::Element;
pub struct LayoutCtx<'a> {
pub resolver: &'a dyn FontResolver,
}
pub enum LayoutResult {
Fit(RenderNode),
Split { current: RenderNode, remainder: Element },
}
pub trait Layoutable {
fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size;
fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult;
}
impl Layoutable for Element {
fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
match self {
Element::Text(t) => t.measure(ctx, constraints),
Element::Row(r) => r.measure(ctx, constraints),
Element::Column(c) => c.measure(ctx, constraints),
Element::Spacer(s) => s.measure(ctx, constraints),
Element::Line(l) => l.measure(ctx, constraints),
Element::Rect(r) => r.measure(ctx, constraints),
Element::Table(t) => t.measure(ctx, constraints),
Element::Image(i) => i.measure(ctx, constraints),
Element::List(l) => l.measure(ctx, constraints),
Element::PageBreak => Size::default(),
}
}
fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
match self {
Element::Text(t) => t.layout(ctx, area, warnings, page),
Element::Row(r) => r.layout(ctx, area, warnings, page),
Element::Column(c) => c.layout(ctx, area, warnings, page),
Element::Spacer(s) => s.layout(ctx, area, warnings, page),
Element::Line(l) => l.layout(ctx, area, warnings, page),
Element::Rect(r) => r.layout(ctx, area, warnings, page),
Element::Table(t) => t.layout(ctx, area, warnings, page),
Element::Image(i) => i.layout(ctx, area, warnings, page),
Element::List(l) => l.layout(ctx, area, warnings, page),
Element::PageBreak => LayoutResult::Fit(RenderNode::Empty),
}
}
}
#[cfg(test)]
mod tests {
use super::shared::EPS;
use super::*;
use crate::pagination::paginate_body;
use crate::warnings::LayoutWarningKind;
use lightweight_pdf_core::{Column, Common, Overflow as OverflowKind, Rect as RectElement, Row, 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 FontResolver for FixedResolver {
fn metrics(&self, _key: lightweight_pdf_core::FontKey) -> &dyn crate::font_resolver::FontMetrics {
&FixedMetrics
}
}
fn ctx() -> LayoutCtx<'static> {
LayoutCtx { resolver: &FixedResolver }
}
#[test]
fn column_auto_size_grows_with_content() {
let short = Column::new().child(TextEl::new("Hi").size(10.0).line_height(1.0));
let long = Column::new().children(vec![
TextEl::new("Line one").size(10.0).line_height(1.0),
TextEl::new("Line two").size(10.0).line_height(1.0),
TextEl::new("Line three").size(10.0).line_height(1.0),
]);
let c = ctx();
let constraints = Constraints {
max_width: 400.0,
max_height: f32::INFINITY,
};
let short_size = short.measure(&c, constraints);
let long_size = long.measure(&c, constraints);
assert!(long_size.height > short_size.height, "more content must measure taller");
}
#[test]
fn fixed_height_text_clips_instead_of_splitting() {
let text = TextEl::new("AAAA BBBB CCCC DDDD").size(10.0).line_height(1.0).height(10.0);
let c = ctx();
let mut warnings = Vec::new();
let area = Rect {
x: 0.0,
y: 0.0,
width: 30.0,
height: 10.0,
};
let result = text.layout(&c, area, &mut warnings, 1);
assert!(
matches!(result, LayoutResult::Fit(_)),
"fixed-size box must Clip, never Split across pages"
);
assert!(warnings.iter().any(|w| w.kind == LayoutWarningKind::TextClipped));
}
#[test]
fn fixed_height_column_clips_instead_of_splitting() {
let col = Column::new().height(10.0).children(vec![
TextEl::new("Line one").size(10.0).line_height(1.0),
TextEl::new("Line two").size(10.0).line_height(1.0),
TextEl::new("Line three").size(10.0).line_height(1.0),
]);
let c = ctx();
let mut warnings = Vec::new();
let area = Rect {
x: 0.0,
y: 0.0,
width: 400.0,
height: 10.0,
};
let result = col.layout(&c, area, &mut warnings, 1);
assert!(matches!(result, LayoutResult::Fit(_)), "fixed-height Column must Clip, never Split");
assert!(warnings.iter().any(|w| w.kind == LayoutWarningKind::ContentOverflow));
}
#[test]
fn row_children_do_not_overlap_horizontally() {
let row = Row::new()
.gap(10.0)
.child(TextEl::new("Left").size(10.0))
.child(TextEl::new("Right").size(10.0));
let c = ctx();
let mut warnings = Vec::new();
let area = Rect {
x: 0.0,
y: 0.0,
width: 400.0,
height: 50.0,
};
let result = row.layout(&c, area, &mut warnings, 1);
let LayoutResult::Fit(RenderNode::Group { children, .. }) = result else {
panic!("expected a Fit Group");
};
assert_eq!(children.len(), 2);
let rects: Vec<Rect> = children
.iter()
.map(|n| match n {
RenderNode::Group { area, .. } => *area,
other => panic!("expected nested Group, got {other:?}"),
})
.collect();
assert!(
rects[0].x + rects[0].width <= rects[1].x + EPS,
"children must not overlap: {:?} vs {:?}",
rects[0],
rects[1]
);
}
#[test]
fn page_break_forces_a_split_at_the_marker() {
let col = Column::new().children(vec![
Element::Text(TextEl::new("a")),
Element::PageBreak,
Element::Text(TextEl::new("b")),
]);
let c = ctx();
let mut warnings = Vec::new();
let area = Rect {
x: 0.0,
y: 0.0,
width: 400.0,
height: 400.0, };
match col.layout(&c, area, &mut warnings, 1) {
LayoutResult::Split { remainder, .. } => match remainder {
Element::Column(rem) => {
assert_eq!(rem.children.len(), 1);
match &rem.children[0] {
Element::Text(t) => assert_eq!(t.content, "b"),
other => panic!("expected Text, got {other:?}"),
}
}
other => panic!("expected Column remainder, got {other:?}"),
},
LayoutResult::Fit(_) => panic!("PageBreak must force a Split even when content would otherwise fit"),
}
}
#[test]
fn oversized_atomic_element_is_forced_onto_its_own_page_and_terminates() {
let children = vec![
Element::Rect(RectElement::new().height(5000.0).background(lightweight_pdf_core::Color::BLACK)),
Element::Rect(RectElement::new().height(20.0)),
];
let c = ctx();
let mut warnings = Vec::new();
let area = Rect {
x: 0.0,
y: 0.0,
width: 200.0,
height: 100.0,
};
let pages = paginate_body(&children, area, &c, &mut warnings);
assert_eq!(
pages.len(),
2,
"oversized element consumes its own page, second Rect starts a fresh one"
);
assert_eq!(warnings.iter().filter(|w| w.kind == LayoutWarningKind::ForcedPageBreak).count(), 1);
}
fn line_text(n: usize) -> String {
(0..n).map(|i| format!("L{i}")).collect::<Vec<_>>().join("\n")
}
#[test]
fn short_paragraph_is_never_split() {
let text = TextEl::new(line_text(3)).size(10.0).line_height(1.0);
let c = ctx();
let mut warnings = Vec::new();
let area = Rect {
x: 0.0,
y: 0.0,
width: 400.0,
height: 20.0, };
match text.layout(&c, area, &mut warnings, 1) {
LayoutResult::Split { current, remainder } => {
assert!(
matches!(current, RenderNode::Empty),
"short paragraph must move whole, nothing placed on this page"
);
match remainder {
Element::Text(t) => assert_eq!(t.content, line_text(3)),
other => panic!("expected Text remainder, got {other:?}"),
}
}
LayoutResult::Fit(_) => panic!("expected a Split (paragraph doesn't fully fit)"),
}
}
#[test]
fn widow_is_avoided_by_pulling_lines_up() {
let text = TextEl::new(line_text(5)).size(10.0).line_height(1.0);
let c = ctx();
let mut warnings = Vec::new();
let area = Rect {
x: 0.0,
y: 0.0,
width: 400.0,
height: 40.0, };
match text.layout(&c, area, &mut warnings, 1) {
LayoutResult::Split { current, remainder } => {
let RenderNode::Group { children, .. } = current else {
panic!("expected the clip-wrapping Group");
};
let RenderNode::TextLines { lines, .. } = &children[0] else {
panic!("expected TextLines");
};
assert_eq!(lines.len(), 3, "must pull one line up so the remainder has >= 2 lines");
match remainder {
Element::Text(t) => assert_eq!(t.content.split(' ').count(), 2),
other => panic!("expected Text remainder, got {other:?}"),
}
}
LayoutResult::Fit(_) => panic!("expected a Split"),
}
}
#[test]
fn orphan_moves_whole_paragraph_when_room_is_too_small() {
let text = TextEl::new(line_text(5)).size(10.0).line_height(1.0);
let c = ctx();
let mut warnings = Vec::new();
let area = Rect {
x: 0.0,
y: 0.0,
width: 400.0,
height: 10.0,
};
match text.layout(&c, area, &mut warnings, 1) {
LayoutResult::Split { current, .. } => {
assert!(matches!(current, RenderNode::Empty));
}
LayoutResult::Fit(_) => panic!("expected a Split"),
}
}
#[test]
fn keep_with_next_moves_heading_along_with_its_body() {
let col = Column::new().gap(0.0).children(vec![
Element::Text(TextEl::new("Filler").size(10.0).line_height(1.0)),
Element::Text(TextEl::new("Heading").size(10.0).line_height(1.0).keep_with_next()),
Element::Text(TextEl::new("Body").size(10.0).line_height(1.0)),
]);
let c = ctx();
let mut warnings = Vec::new();
let area = Rect {
x: 0.0,
y: 0.0,
width: 400.0,
height: 25.0,
};
match col.layout(&c, area, &mut warnings, 1) {
LayoutResult::Split { current, remainder } => {
let RenderNode::Group { children, .. } = current else {
panic!("expected Group");
};
assert_eq!(children.len(), 1, "only the filler should remain on this page");
match remainder {
Element::Column(rem) => {
assert_eq!(rem.children.len(), 2);
match &rem.children[0] {
Element::Text(t) => assert_eq!(t.content, "Heading"),
other => panic!("expected Heading Text, got {other:?}"),
}
}
other => panic!("expected Column remainder, got {other:?}"),
}
}
LayoutResult::Fit(_) => panic!("expected keep_with_next to force a Split before the heading"),
}
}
#[test]
fn overflow_ellipsis_truncates_fixed_single_line_text() {
let text = TextEl::new("AAAAAAAAAAAAAAAA")
.size(10.0)
.line_height(1.0)
.height(10.0)
.overflow(OverflowKind::Ellipsis);
let c = ctx();
let mut warnings = Vec::new();
let area = Rect {
x: 0.0,
y: 0.0,
width: 40.0,
height: 10.0,
};
let result = text.layout(&c, area, &mut warnings, 1);
let LayoutResult::Fit(RenderNode::Group { children, .. }) = result else {
panic!("expected Fit Group (clip wrapper)");
};
let RenderNode::TextLines { lines, .. } = &children[0] else {
panic!("expected TextLines");
};
assert_eq!(lines.len(), 1);
assert!(lines[0].ends_with('…'), "expected an ellipsis, got {:?}", lines[0]);
}
#[test]
fn common_default_is_used() {
let c = Common::default();
assert_eq!(c.width, None);
assert_eq!(c.height, None);
}
}