1mod column;
13mod leaf;
14mod row;
15mod shared;
16
17pub(crate) use shared::{
18 clip_to_fixed_height, coerce_to_fit, coerce_to_fit_and_warn, finish_fit, measure_at_width, push_warning, resolve_auto_size,
19 resolve_bound, shrink_and_bound_height, wrap_children,
20};
21
22use crate::font_resolver::FontResolver;
23use crate::geometry::{Constraints, Rect, Size};
24use crate::render_node::RenderNode;
25use crate::warnings::LayoutWarning;
26use lightweight_pdf_core::Element;
27
28pub struct LayoutCtx<'a> {
29 pub resolver: &'a dyn FontResolver,
30}
31
32pub enum LayoutResult {
37 Fit(RenderNode),
38 Split { current: RenderNode, remainder: Element },
39}
40
41pub trait Layoutable {
42 fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size;
43 fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult;
44}
45
46impl Layoutable for Element {
53 fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
54 match self {
55 Element::Text(t) => t.measure(ctx, constraints),
56 Element::Row(r) => r.measure(ctx, constraints),
57 Element::Column(c) => c.measure(ctx, constraints),
58 Element::Spacer(s) => s.measure(ctx, constraints),
59 Element::Line(l) => l.measure(ctx, constraints),
60 Element::Rect(r) => r.measure(ctx, constraints),
61 Element::Table(t) => t.measure(ctx, constraints),
62 Element::Image(i) => i.measure(ctx, constraints),
63 Element::List(l) => l.measure(ctx, constraints),
64 Element::PageBreak => Size::default(),
65 }
66 }
67
68 fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
69 match self {
70 Element::Text(t) => t.layout(ctx, area, warnings, page),
71 Element::Row(r) => r.layout(ctx, area, warnings, page),
72 Element::Column(c) => c.layout(ctx, area, warnings, page),
73 Element::Spacer(s) => s.layout(ctx, area, warnings, page),
74 Element::Line(l) => l.layout(ctx, area, warnings, page),
75 Element::Rect(r) => r.layout(ctx, area, warnings, page),
76 Element::Table(t) => t.layout(ctx, area, warnings, page),
77 Element::Image(i) => i.layout(ctx, area, warnings, page),
78 Element::List(l) => l.layout(ctx, area, warnings, page),
79 Element::PageBreak => LayoutResult::Fit(RenderNode::Empty),
80 }
81 }
82}
83
84#[cfg(test)]
85mod tests {
86 use super::shared::EPS;
87 use super::*;
88 use crate::pagination::paginate_body;
89 use crate::warnings::LayoutWarningKind;
90 use lightweight_pdf_core::{Column, Common, Overflow as OverflowKind, Rect as RectElement, Row, Text as TextEl};
91
92 struct FixedMetrics;
93 impl crate::font_resolver::FontMetrics for FixedMetrics {
94 fn advance(&self, ch: char) -> f32 {
95 if ch == ' ' {
96 300.0
97 } else {
98 600.0
99 }
100 }
101 fn ascent(&self) -> f32 {
102 800.0
103 }
104 fn descent(&self) -> f32 {
105 -200.0
106 }
107 }
108 struct FixedResolver;
109 impl FontResolver for FixedResolver {
110 fn metrics(&self, _key: lightweight_pdf_core::FontKey) -> &dyn crate::font_resolver::FontMetrics {
111 &FixedMetrics
112 }
113 }
114
115 fn ctx() -> LayoutCtx<'static> {
116 LayoutCtx { resolver: &FixedResolver }
117 }
118
119 #[test]
122 fn column_auto_size_grows_with_content() {
123 let short = Column::new().child(TextEl::new("Hi").size(10.0).line_height(1.0));
124 let long = Column::new().children(vec![
125 TextEl::new("Line one").size(10.0).line_height(1.0),
126 TextEl::new("Line two").size(10.0).line_height(1.0),
127 TextEl::new("Line three").size(10.0).line_height(1.0),
128 ]);
129 let c = ctx();
130 let constraints = Constraints {
131 max_width: 400.0,
132 max_height: f32::INFINITY,
133 };
134 let short_size = short.measure(&c, constraints);
135 let long_size = long.measure(&c, constraints);
136 assert!(long_size.height > short_size.height, "more content must measure taller");
137 }
138
139 #[test]
142 fn fixed_height_text_clips_instead_of_splitting() {
143 let text = TextEl::new("AAAA BBBB CCCC DDDD").size(10.0).line_height(1.0).height(10.0);
144 let c = ctx();
145 let mut warnings = Vec::new();
146 let area = Rect {
148 x: 0.0,
149 y: 0.0,
150 width: 30.0,
151 height: 10.0,
152 };
153 let result = text.layout(&c, area, &mut warnings, 1);
154 assert!(
155 matches!(result, LayoutResult::Fit(_)),
156 "fixed-size box must Clip, never Split across pages"
157 );
158 assert!(warnings.iter().any(|w| w.kind == LayoutWarningKind::TextClipped));
159 }
160
161 #[test]
162 fn fixed_height_column_clips_instead_of_splitting() {
163 let col = Column::new().height(10.0).children(vec![
164 TextEl::new("Line one").size(10.0).line_height(1.0),
165 TextEl::new("Line two").size(10.0).line_height(1.0),
166 TextEl::new("Line three").size(10.0).line_height(1.0),
167 ]);
168 let c = ctx();
169 let mut warnings = Vec::new();
170 let area = Rect {
171 x: 0.0,
172 y: 0.0,
173 width: 400.0,
174 height: 10.0,
175 };
176 let result = col.layout(&c, area, &mut warnings, 1);
177 assert!(matches!(result, LayoutResult::Fit(_)), "fixed-height Column must Clip, never Split");
178 assert!(warnings.iter().any(|w| w.kind == LayoutWarningKind::ContentOverflow));
179 }
180
181 #[test]
184 fn row_children_do_not_overlap_horizontally() {
185 let row = Row::new()
186 .gap(10.0)
187 .child(TextEl::new("Left").size(10.0))
188 .child(TextEl::new("Right").size(10.0));
189 let c = ctx();
190 let mut warnings = Vec::new();
191 let area = Rect {
192 x: 0.0,
193 y: 0.0,
194 width: 400.0,
195 height: 50.0,
196 };
197 let result = row.layout(&c, area, &mut warnings, 1);
198 let LayoutResult::Fit(RenderNode::Group { children, .. }) = result else {
199 panic!("expected a Fit Group");
200 };
201 assert_eq!(children.len(), 2);
202 let rects: Vec<Rect> = children
203 .iter()
204 .map(|n| match n {
205 RenderNode::Group { area, .. } => *area,
206 other => panic!("expected nested Group, got {other:?}"),
207 })
208 .collect();
209 assert!(
210 rects[0].x + rects[0].width <= rects[1].x + EPS,
211 "children must not overlap: {:?} vs {:?}",
212 rects[0],
213 rects[1]
214 );
215 }
216
217 #[test]
220 fn page_break_forces_a_split_at_the_marker() {
221 let col = Column::new().children(vec![
222 Element::Text(TextEl::new("a")),
223 Element::PageBreak,
224 Element::Text(TextEl::new("b")),
225 ]);
226 let c = ctx();
227 let mut warnings = Vec::new();
228 let area = Rect {
229 x: 0.0,
230 y: 0.0,
231 width: 400.0,
232 height: 400.0, };
234 match col.layout(&c, area, &mut warnings, 1) {
235 LayoutResult::Split { remainder, .. } => match remainder {
236 Element::Column(rem) => {
237 assert_eq!(rem.children.len(), 1);
238 match &rem.children[0] {
239 Element::Text(t) => assert_eq!(t.content, "b"),
240 other => panic!("expected Text, got {other:?}"),
241 }
242 }
243 other => panic!("expected Column remainder, got {other:?}"),
244 },
245 LayoutResult::Fit(_) => panic!("PageBreak must force a Split even when content would otherwise fit"),
246 }
247 }
248
249 #[test]
252 fn oversized_atomic_element_is_forced_onto_its_own_page_and_terminates() {
253 let children = vec![
254 Element::Rect(RectElement::new().height(5000.0).background(lightweight_pdf_core::Color::BLACK)),
255 Element::Rect(RectElement::new().height(20.0)),
256 ];
257 let c = ctx();
258 let mut warnings = Vec::new();
259 let area = Rect {
260 x: 0.0,
261 y: 0.0,
262 width: 200.0,
263 height: 100.0,
264 };
265 let pages = paginate_body(&children, area, &c, &mut warnings);
266 assert_eq!(
267 pages.len(),
268 2,
269 "oversized element consumes its own page, second Rect starts a fresh one"
270 );
271 assert_eq!(warnings.iter().filter(|w| w.kind == LayoutWarningKind::ForcedPageBreak).count(), 1);
272 }
273
274 fn line_text(n: usize) -> String {
277 (0..n).map(|i| format!("L{i}")).collect::<Vec<_>>().join("\n")
278 }
279
280 #[test]
281 fn short_paragraph_is_never_split() {
282 let text = TextEl::new(line_text(3)).size(10.0).line_height(1.0);
285 let c = ctx();
286 let mut warnings = Vec::new();
287 let area = Rect {
288 x: 0.0,
289 y: 0.0,
290 width: 400.0,
291 height: 20.0, };
293 match text.layout(&c, area, &mut warnings, 1) {
294 LayoutResult::Split { current, remainder } => {
295 assert!(
296 matches!(current, RenderNode::Empty),
297 "short paragraph must move whole, nothing placed on this page"
298 );
299 match remainder {
300 Element::Text(t) => assert_eq!(t.content, line_text(3)),
301 other => panic!("expected Text remainder, got {other:?}"),
302 }
303 }
304 LayoutResult::Fit(_) => panic!("expected a Split (paragraph doesn't fully fit)"),
305 }
306 }
307
308 #[test]
309 fn widow_is_avoided_by_pulling_lines_up() {
310 let text = TextEl::new(line_text(5)).size(10.0).line_height(1.0);
313 let c = ctx();
314 let mut warnings = Vec::new();
315 let area = Rect {
316 x: 0.0,
317 y: 0.0,
318 width: 400.0,
319 height: 40.0, };
321 match text.layout(&c, area, &mut warnings, 1) {
322 LayoutResult::Split { current, remainder } => {
323 let RenderNode::Group { children, .. } = current else {
324 panic!("expected the clip-wrapping Group");
325 };
326 let RenderNode::TextLines { lines, .. } = &children[0] else {
327 panic!("expected TextLines");
328 };
329 assert_eq!(lines.len(), 3, "must pull one line up so the remainder has >= 2 lines");
330 match remainder {
331 Element::Text(t) => assert_eq!(t.content.split(' ').count(), 2),
332 other => panic!("expected Text remainder, got {other:?}"),
333 }
334 }
335 LayoutResult::Fit(_) => panic!("expected a Split"),
336 }
337 }
338
339 #[test]
340 fn orphan_moves_whole_paragraph_when_room_is_too_small() {
341 let text = TextEl::new(line_text(5)).size(10.0).line_height(1.0);
344 let c = ctx();
345 let mut warnings = Vec::new();
346 let area = Rect {
347 x: 0.0,
348 y: 0.0,
349 width: 400.0,
350 height: 10.0,
351 };
352 match text.layout(&c, area, &mut warnings, 1) {
353 LayoutResult::Split { current, .. } => {
354 assert!(matches!(current, RenderNode::Empty));
355 }
356 LayoutResult::Fit(_) => panic!("expected a Split"),
357 }
358 }
359
360 #[test]
363 fn keep_with_next_moves_heading_along_with_its_body() {
364 let col = Column::new().gap(0.0).children(vec![
365 Element::Text(TextEl::new("Filler").size(10.0).line_height(1.0)),
366 Element::Text(TextEl::new("Heading").size(10.0).line_height(1.0).keep_with_next()),
367 Element::Text(TextEl::new("Body").size(10.0).line_height(1.0)),
368 ]);
369 let c = ctx();
370 let mut warnings = Vec::new();
371 let area = Rect {
374 x: 0.0,
375 y: 0.0,
376 width: 400.0,
377 height: 25.0,
378 };
379 match col.layout(&c, area, &mut warnings, 1) {
380 LayoutResult::Split { current, remainder } => {
381 let RenderNode::Group { children, .. } = current else {
382 panic!("expected Group");
383 };
384 assert_eq!(children.len(), 1, "only the filler should remain on this page");
385 match remainder {
386 Element::Column(rem) => {
387 assert_eq!(rem.children.len(), 2);
388 match &rem.children[0] {
389 Element::Text(t) => assert_eq!(t.content, "Heading"),
390 other => panic!("expected Heading Text, got {other:?}"),
391 }
392 }
393 other => panic!("expected Column remainder, got {other:?}"),
394 }
395 }
396 LayoutResult::Fit(_) => panic!("expected keep_with_next to force a Split before the heading"),
397 }
398 }
399
400 #[test]
401 fn overflow_ellipsis_truncates_fixed_single_line_text() {
402 let text = TextEl::new("AAAAAAAAAAAAAAAA")
403 .size(10.0)
404 .line_height(1.0)
405 .height(10.0)
406 .overflow(OverflowKind::Ellipsis);
407 let c = ctx();
408 let mut warnings = Vec::new();
409 let area = Rect {
410 x: 0.0,
411 y: 0.0,
412 width: 40.0,
413 height: 10.0,
414 };
415 let result = text.layout(&c, area, &mut warnings, 1);
416 let LayoutResult::Fit(RenderNode::Group { children, .. }) = result else {
417 panic!("expected Fit Group (clip wrapper)");
418 };
419 let RenderNode::TextLines { lines, .. } = &children[0] else {
420 panic!("expected TextLines");
421 };
422 assert_eq!(lines.len(), 1);
423 assert!(lines[0].ends_with('…'), "expected an ellipsis, got {:?}", lines[0]);
424 }
425
426 #[test]
427 fn common_default_is_used() {
428 let c = Common::default();
430 assert_eq!(c.width, None);
431 assert_eq!(c.height, None);
432 }
433}