Skip to main content

lightweight_pdf_layout/layoutable/
mod.rs

1//! `measure`/`layout`. Implemented for every concrete element type (not
2//! `Element` variants with `todo!()`, since all V1-through-Phase-2
3//! variants are implemented) plus a dispatching impl on `Element` itself
4//! so containers can recurse over `Vec<Element>` children.
5//!
6//! Split across submodules by concern: `shared` holds the box-model
7//! helpers reused by every impl (also re-exported here for `table.rs`,
8//! `list.rs`, `pagination.rs`), `row`/`column` hold the two container
9//! impls, and `leaf` holds the childless element impls (`Text`, `Spacer`,
10//! `Line`, `Rect`).
11
12mod 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
32/// Result of laying an element out into a bounded area: either it fully
33/// fit, or the fitting part plus a materialized remainder element for the
34/// next page. `Text`, `Column` and `Table` produce
35/// `Split` in V1.
36pub 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
46// ---------------------------------------------------------------------
47// Element: dispatch to the concrete impls below. `PageBreak` has no
48// intrinsic size/rendering of its own — `Column`'s layout loop intercepts
49// it before ever calling into this generic path.
50// ---------------------------------------------------------------------
51
52impl 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    // --- Grundprinzip 1: auto-size is the default -----------------------
120
121    #[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    // --- Grundprinzip 2/3: hard-break + fixed-size Clip (never Split) ---
140
141    #[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        // Narrow width forces multiple lines; the box is only 1 line tall.
147        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    // --- Grundprinzip 4/6: containers/children never overlap ------------
182
183    #[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    // --- Phase 2: PageBreak ----------------------------------------------
218
219    #[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, // plenty of room — the break must still trigger.
233        };
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    // --- Grundprinzip 7: atomic element bigger than a page --------------
250
251    #[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    // --- Grundprinzip 9: widow/orphan + short-paragraph-never-split -----
275
276    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        // 3 lines < 2*N(=4): must move as a whole even though 2 lines
283        // would technically fit.
284        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, // fits 2 of 3 lines by height alone
292        };
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        // 5 lines, only 4 fit by height -> naive split would leave 1
311        // (widow). Rule pulls lines up so >= N=2 remain after the break.
312        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, // exactly 4 lines at 10pt line-height
320        };
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        // 5 lines, only 1 fits by height -> orphan (< N before break) ->
342        // move the whole paragraph.
343        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    // --- Grundprinzip 9: keep_with_next ----------------------------------
361
362    #[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        // 10 (filler) + 10 (heading) fits, but leaves only 5pt — not
372        // enough for one more 10pt line of body text.
373        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        // Sanity check that Common::default() means "auto", not zero-sized.
429        let c = Common::default();
430        assert_eq!(c.width, None);
431        assert_eq!(c.height, None);
432    }
433}