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, line_height_pt, measure_at_width, push_warning,
19    resolve_auto_size, 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, StructRole};
25use crate::toc::TocHeading;
26use crate::warnings::LayoutWarning;
27use lightweight_pdf_core::Element;
28use std::collections::HashMap;
29
30/// Wraps a `LayoutResult`'s `RenderNode` (the `Fit` case, or `Split`'s
31/// `current`/already-fitted part) with `role` — `Split`'s `remainder` is
32/// still an `Element`, not a `RenderNode` yet, and gets tagged again on
33/// its own when it's laid out on the next page (issue #27: a logical
34/// element split across pages becomes sibling `StructElem`s, one per
35/// page, rather than one element straddling a page boundary — simpler,
36/// and still valid tagged PDF).
37pub(crate) fn wrap_result(result: LayoutResult, role: StructRole) -> LayoutResult {
38    match result {
39        LayoutResult::Fit(node) => LayoutResult::Fit(RenderNode::tagged(role, node)),
40        LayoutResult::Split { current, remainder } => LayoutResult::Split {
41            current: RenderNode::tagged(role, current),
42            remainder,
43        },
44    }
45}
46
47pub struct LayoutCtx<'a> {
48    pub resolver: &'a dyn FontResolver,
49    /// Every heading (`Text::outline_level`) in the whole document, in
50    /// document order — independent of pagination (issue #10's
51    /// `TableOfContents`), so identical in both layout passes.
52    pub toc_headings: &'a [TocHeading],
53    /// `None` during pass 1 (page numbers aren't known yet — a
54    /// `TableOfContents` renders its entries without one). `Some` during
55    /// pass 2, keyed by each heading's `TocHeading::anchor`, filled in
56    /// from pass 1's own result — the same "pass 1 informs pass 2"
57    /// mechanism `PageContext.total_pages` uses for Header/Footer.
58    pub toc_heading_pages: Option<&'a HashMap<String, usize>>,
59}
60
61impl<'a> LayoutCtx<'a> {
62    /// A `LayoutCtx` with no `TableOfContents` data — what every caller
63    /// outside `pagination::paginate` wants (it fills in the real
64    /// per-pass values itself).
65    pub fn new(resolver: &'a dyn FontResolver) -> Self {
66        LayoutCtx {
67            resolver,
68            toc_headings: &[],
69            toc_heading_pages: None,
70        }
71    }
72}
73
74/// Result of laying an element out into a bounded area: either it fully
75/// fit, or the fitting part plus a materialized remainder element for the
76/// next page. `Text`, `Column` and `Table` produce
77/// `Split` in V1.
78pub enum LayoutResult {
79    Fit(RenderNode),
80    Split { current: RenderNode, remainder: Element },
81}
82
83pub trait Layoutable {
84    fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size;
85    fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult;
86}
87
88// ---------------------------------------------------------------------
89// Element: dispatch to the concrete impls below. `PageBreak` has no
90// intrinsic size/rendering of its own — `Column`'s layout loop intercepts
91// it before ever calling into this generic path.
92// ---------------------------------------------------------------------
93
94impl Layoutable for Element {
95    fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
96        match self {
97            Element::Text(t) => t.measure(ctx, constraints),
98            Element::Row(r) => r.measure(ctx, constraints),
99            Element::Column(c) => c.measure(ctx, constraints),
100            Element::Spacer(s) => s.measure(ctx, constraints),
101            Element::Line(l) => l.measure(ctx, constraints),
102            Element::Rect(r) => r.measure(ctx, constraints),
103            Element::Table(t) => t.measure(ctx, constraints),
104            Element::Image(i) => i.measure(ctx, constraints),
105            Element::List(l) => l.measure(ctx, constraints),
106            Element::TableOfContents(t) => t.measure(ctx, constraints),
107            Element::PageBreak => Size::default(),
108        }
109    }
110
111    fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
112        match self {
113            // Structure-tree tagging (issue #27): attached here, at the
114            // one place every element's `RenderNode` output already
115            // passes through, rather than at each element's own
116            // construction site. `Table`/`List`/`TableOfContents` tag
117            // their own row/cell/item structure internally (only they
118            // know it) — this just adds the outer `Table`/`List`/`Toc`
119            // wrapper. `Row`/`Column`/`Spacer`/`Line`/`Rect` are left
120            // unwrapped: pure containers contribute no content of their
121            // own (their children tag themselves recursively).
122            Element::Text(t) => {
123                let role = match t.outline_level {
124                    Some(n) => StructRole::Heading(n),
125                    None => StructRole::Paragraph,
126                };
127                wrap_result(t.layout(ctx, area, warnings, page), role)
128            }
129            Element::Row(r) => r.layout(ctx, area, warnings, page),
130            Element::Column(c) => c.layout(ctx, area, warnings, page),
131            Element::Spacer(s) => s.layout(ctx, area, warnings, page),
132            Element::Line(l) => l.layout(ctx, area, warnings, page),
133            Element::Rect(r) => r.layout(ctx, area, warnings, page),
134            Element::Table(t) => wrap_result(t.layout(ctx, area, warnings, page), StructRole::Table),
135            Element::Image(i) => wrap_result(i.layout(ctx, area, warnings, page), StructRole::Figure),
136            Element::List(l) => wrap_result(l.layout(ctx, area, warnings, page), StructRole::List),
137            Element::TableOfContents(t) => wrap_result(t.layout(ctx, area, warnings, page), StructRole::Toc),
138            Element::PageBreak => LayoutResult::Fit(RenderNode::Empty),
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::shared::EPS;
146    use super::*;
147    use crate::pagination::paginate_body;
148    use crate::warnings::LayoutWarningKind;
149    use lightweight_pdf_core::{Column, Common, Overflow as OverflowKind, Rect as RectElement, Row, Span, Text as TextEl, TextStyle};
150
151    struct FixedMetrics;
152    impl crate::font_resolver::FontMetrics for FixedMetrics {
153        fn advance(&self, ch: char) -> f32 {
154            if ch == ' ' {
155                300.0
156            } else {
157                600.0
158            }
159        }
160        fn ascent(&self) -> f32 {
161            800.0
162        }
163        fn descent(&self) -> f32 {
164            -200.0
165        }
166    }
167    struct FixedResolver;
168    impl FontResolver for FixedResolver {
169        fn metrics(&self, _key: lightweight_pdf_core::FontKey) -> &dyn crate::font_resolver::FontMetrics {
170            &FixedMetrics
171        }
172    }
173
174    fn ctx() -> LayoutCtx<'static> {
175        LayoutCtx::new(&FixedResolver)
176    }
177
178    // --- Grundprinzip 1: auto-size is the default -----------------------
179
180    #[test]
181    fn column_auto_size_grows_with_content() {
182        let short = Column::new().child(TextEl::new("Hi").size(10.0).line_height(1.0));
183        let long = Column::new().children(vec![
184            TextEl::new("Line one").size(10.0).line_height(1.0),
185            TextEl::new("Line two").size(10.0).line_height(1.0),
186            TextEl::new("Line three").size(10.0).line_height(1.0),
187        ]);
188        let c = ctx();
189        let constraints = Constraints {
190            max_width: 400.0,
191            max_height: f32::INFINITY,
192        };
193        let short_size = short.measure(&c, constraints);
194        let long_size = long.measure(&c, constraints);
195        assert!(long_size.height > short_size.height, "more content must measure taller");
196    }
197
198    // --- Grundprinzip 2/3: hard-break + fixed-size Clip (never Split) ---
199
200    #[test]
201    fn fixed_height_text_clips_instead_of_splitting() {
202        let text = TextEl::new("AAAA BBBB CCCC DDDD").size(10.0).line_height(1.0).height(10.0);
203        let c = ctx();
204        let mut warnings = Vec::new();
205        // Narrow width forces multiple lines; the box is only 1 line tall.
206        let area = Rect {
207            x: 0.0,
208            y: 0.0,
209            width: 30.0,
210            height: 10.0,
211        };
212        let result = text.layout(&c, area, &mut warnings, 1);
213        assert!(
214            matches!(result, LayoutResult::Fit(_)),
215            "fixed-size box must Clip, never Split across pages"
216        );
217        assert!(warnings.iter().any(|w| w.kind == LayoutWarningKind::TextClipped));
218    }
219
220    #[test]
221    fn fixed_height_column_clips_instead_of_splitting() {
222        let col = Column::new().height(10.0).children(vec![
223            TextEl::new("Line one").size(10.0).line_height(1.0),
224            TextEl::new("Line two").size(10.0).line_height(1.0),
225            TextEl::new("Line three").size(10.0).line_height(1.0),
226        ]);
227        let c = ctx();
228        let mut warnings = Vec::new();
229        let area = Rect {
230            x: 0.0,
231            y: 0.0,
232            width: 400.0,
233            height: 10.0,
234        };
235        let result = col.layout(&c, area, &mut warnings, 1);
236        assert!(matches!(result, LayoutResult::Fit(_)), "fixed-height Column must Clip, never Split");
237        assert!(warnings.iter().any(|w| w.kind == LayoutWarningKind::ContentOverflow));
238    }
239
240    // --- Grundprinzip 4/6: containers/children never overlap ------------
241
242    #[test]
243    fn row_children_do_not_overlap_horizontally() {
244        let row = Row::new()
245            .gap(10.0)
246            .child(TextEl::new("Left").size(10.0))
247            .child(TextEl::new("Right").size(10.0));
248        let c = ctx();
249        let mut warnings = Vec::new();
250        let area = Rect {
251            x: 0.0,
252            y: 0.0,
253            width: 400.0,
254            height: 50.0,
255        };
256        let result = row.layout(&c, area, &mut warnings, 1);
257        let LayoutResult::Fit(RenderNode::Group { children, .. }) = result else {
258            panic!("expected a Fit Group");
259        };
260        assert_eq!(children.len(), 2);
261        let rects: Vec<Rect> = children
262            .iter()
263            .map(|n| match n.untagged() {
264                RenderNode::Group { area, .. } => *area,
265                other => panic!("expected nested Group, got {other:?}"),
266            })
267            .collect();
268        assert!(
269            rects[0].x + rects[0].width <= rects[1].x + EPS,
270            "children must not overlap: {:?} vs {:?}",
271            rects[0],
272            rects[1]
273        );
274    }
275
276    // --- Phase 2: PageBreak ----------------------------------------------
277
278    #[test]
279    fn page_break_forces_a_split_at_the_marker() {
280        let col = Column::new().children(vec![
281            Element::Text(TextEl::new("a")),
282            Element::PageBreak,
283            Element::Text(TextEl::new("b")),
284        ]);
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: 400.0, // plenty of room — the break must still trigger.
292        };
293        match col.layout(&c, area, &mut warnings, 1) {
294            LayoutResult::Split { remainder, .. } => match remainder {
295                Element::Column(rem) => {
296                    assert_eq!(rem.children.len(), 1);
297                    match &rem.children[0] {
298                        Element::Text(t) => assert_eq!(t.content, "b"),
299                        other => panic!("expected Text, got {other:?}"),
300                    }
301                }
302                other => panic!("expected Column remainder, got {other:?}"),
303            },
304            LayoutResult::Fit(_) => panic!("PageBreak must force a Split even when content would otherwise fit"),
305        }
306    }
307
308    // --- Grundprinzip 7: atomic element bigger than a page --------------
309
310    #[test]
311    fn oversized_atomic_element_is_forced_onto_its_own_page_and_terminates() {
312        let children = vec![
313            Element::Rect(RectElement::new().height(5000.0).background(lightweight_pdf_core::Color::BLACK)),
314            Element::Rect(RectElement::new().height(20.0)),
315        ];
316        let c = ctx();
317        let mut warnings = Vec::new();
318        let area = Rect {
319            x: 0.0,
320            y: 0.0,
321            width: 200.0,
322            height: 100.0,
323        };
324        let pages = paginate_body(&children, area, &c, &mut warnings);
325        assert_eq!(
326            pages.len(),
327            2,
328            "oversized element consumes its own page, second Rect starts a fresh one"
329        );
330        assert_eq!(warnings.iter().filter(|w| w.kind == LayoutWarningKind::ForcedPageBreak).count(), 1);
331    }
332
333    // --- Grundprinzip 9: widow/orphan + short-paragraph-never-split -----
334
335    fn line_text(n: usize) -> String {
336        (0..n).map(|i| format!("L{i}")).collect::<Vec<_>>().join("\n")
337    }
338
339    #[test]
340    fn short_paragraph_is_never_split() {
341        // 3 lines < 2*N(=4): must move as a whole even though 2 lines
342        // would technically fit.
343        let text = TextEl::new(line_text(3)).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: 20.0, // fits 2 of 3 lines by height alone
351        };
352        match text.layout(&c, area, &mut warnings, 1) {
353            LayoutResult::Split { current, remainder } => {
354                assert!(
355                    matches!(current, RenderNode::Empty),
356                    "short paragraph must move whole, nothing placed on this page"
357                );
358                match remainder {
359                    Element::Text(t) => assert_eq!(t.content, line_text(3)),
360                    other => panic!("expected Text remainder, got {other:?}"),
361                }
362            }
363            LayoutResult::Fit(_) => panic!("expected a Split (paragraph doesn't fully fit)"),
364        }
365    }
366
367    #[test]
368    fn widow_is_avoided_by_pulling_lines_up() {
369        // 5 lines, only 4 fit by height -> naive split would leave 1
370        // (widow). Rule pulls lines up so >= N=2 remain after the break.
371        let text = TextEl::new(line_text(5)).size(10.0).line_height(1.0);
372        let c = ctx();
373        let mut warnings = Vec::new();
374        let area = Rect {
375            x: 0.0,
376            y: 0.0,
377            width: 400.0,
378            height: 40.0, // exactly 4 lines at 10pt line-height
379        };
380        match text.layout(&c, area, &mut warnings, 1) {
381            LayoutResult::Split { current, remainder } => {
382                let RenderNode::Group { children, .. } = current else {
383                    panic!("expected the clip-wrapping Group");
384                };
385                let RenderNode::TextLines { lines, .. } = &children[0] else {
386                    panic!("expected TextLines");
387                };
388                assert_eq!(lines.len(), 3, "must pull one line up so the remainder has >= 2 lines");
389                match remainder {
390                    Element::Text(t) => assert_eq!(t.content.split(' ').count(), 2),
391                    other => panic!("expected Text remainder, got {other:?}"),
392                }
393            }
394            LayoutResult::Fit(_) => panic!("expected a Split"),
395        }
396    }
397
398    #[test]
399    fn orphan_moves_whole_paragraph_when_room_is_too_small() {
400        // 5 lines, only 1 fits by height -> orphan (< N before break) ->
401        // move the whole paragraph.
402        let text = TextEl::new(line_text(5)).size(10.0).line_height(1.0);
403        let c = ctx();
404        let mut warnings = Vec::new();
405        let area = Rect {
406            x: 0.0,
407            y: 0.0,
408            width: 400.0,
409            height: 10.0,
410        };
411        match text.layout(&c, area, &mut warnings, 1) {
412            LayoutResult::Split { current, .. } => {
413                assert!(matches!(current, RenderNode::Empty));
414            }
415            LayoutResult::Fit(_) => panic!("expected a Split"),
416        }
417    }
418
419    // --- Grundprinzip 9: keep_with_next ----------------------------------
420
421    #[test]
422    fn keep_with_next_moves_heading_along_with_its_body() {
423        let col = Column::new().gap(0.0).children(vec![
424            Element::Text(TextEl::new("Filler").size(10.0).line_height(1.0)),
425            Element::Text(TextEl::new("Heading").size(10.0).line_height(1.0).keep_with_next()),
426            Element::Text(TextEl::new("Body").size(10.0).line_height(1.0)),
427        ]);
428        let c = ctx();
429        let mut warnings = Vec::new();
430        // 10 (filler) + 10 (heading) fits, but leaves only 5pt — not
431        // enough for one more 10pt line of body text.
432        let area = Rect {
433            x: 0.0,
434            y: 0.0,
435            width: 400.0,
436            height: 25.0,
437        };
438        match col.layout(&c, area, &mut warnings, 1) {
439            LayoutResult::Split { current, remainder } => {
440                let RenderNode::Group { children, .. } = current else {
441                    panic!("expected Group");
442                };
443                assert_eq!(children.len(), 1, "only the filler should remain on this page");
444                match remainder {
445                    Element::Column(rem) => {
446                        assert_eq!(rem.children.len(), 2);
447                        match &rem.children[0] {
448                            Element::Text(t) => assert_eq!(t.content, "Heading"),
449                            other => panic!("expected Heading Text, got {other:?}"),
450                        }
451                    }
452                    other => panic!("expected Column remainder, got {other:?}"),
453                }
454            }
455            LayoutResult::Fit(_) => panic!("expected keep_with_next to force a Split before the heading"),
456        }
457    }
458
459    #[test]
460    fn overflow_ellipsis_truncates_fixed_single_line_text() {
461        let text = TextEl::new("AAAAAAAAAAAAAAAA")
462            .size(10.0)
463            .line_height(1.0)
464            .height(10.0)
465            .overflow(OverflowKind::Ellipsis);
466        let c = ctx();
467        let mut warnings = Vec::new();
468        let area = Rect {
469            x: 0.0,
470            y: 0.0,
471            width: 40.0,
472            height: 10.0,
473        };
474        let result = text.layout(&c, area, &mut warnings, 1);
475        let LayoutResult::Fit(RenderNode::Group { children, .. }) = result else {
476            panic!("expected Fit Group (clip wrapper)");
477        };
478        let RenderNode::TextLines { lines, .. } = &children[0] else {
479            panic!("expected TextLines");
480        };
481        assert_eq!(lines.len(), 1);
482        assert!(lines[0].ends_with('…'), "expected an ellipsis, got {:?}", lines[0]);
483    }
484
485    #[test]
486    fn common_default_is_used() {
487        // Sanity check that Common::default() means "auto", not zero-sized.
488        let c = Common::default();
489        assert_eq!(c.width, None);
490        assert_eq!(c.height, None);
491    }
492
493    // --- Text::rich(..) (issue #11) --------------------------------------
494
495    #[test]
496    fn rich_text_wraps_words_from_multiple_spans_in_order() {
497        let style = TextStyle {
498            size: 10.0,
499            line_height: 1.0,
500            ..Default::default()
501        };
502        let text = TextEl::rich([Span::new("AAAA", style), Span::new(" BBBB", style)]);
503        let c = ctx();
504        let mut warnings = Vec::new();
505        let area = Rect {
506            x: 0.0,
507            y: 0.0,
508            width: 400.0,
509            height: 100.0,
510        };
511        let LayoutResult::Fit(RenderNode::Group { children, .. }) = text.layout(&c, area, &mut warnings, 1) else {
512            panic!("expected Fit");
513        };
514        let RenderNode::RichTextLines { lines, .. } = &children[0] else {
515            panic!("expected RichTextLines");
516        };
517        assert_eq!(lines.len(), 1, "both words fit on one line");
518        let words: Vec<&str> = lines[0].words.iter().map(|w| w.text.as_str()).collect();
519        assert_eq!(words, vec!["AAAA", "BBBB"], "words from both spans, in order, on the same line");
520    }
521
522    #[test]
523    fn rich_text_mixed_sizes_share_one_line_height_and_ascent() {
524        let small = TextStyle {
525            size: 10.0,
526            line_height: 1.0,
527            ..Default::default()
528        };
529        let big = TextStyle {
530            size: 20.0,
531            line_height: 1.0,
532            ..Default::default()
533        };
534        let text = TextEl::rich([Span::new("a", small), Span::new(" B", big)]);
535        let c = ctx();
536        let mut warnings = Vec::new();
537        let area = Rect {
538            x: 0.0,
539            y: 0.0,
540            width: 400.0,
541            height: 100.0,
542        };
543        let LayoutResult::Fit(RenderNode::Group { children, .. }) = text.layout(&c, area, &mut warnings, 1) else {
544            panic!("expected Fit");
545        };
546        let RenderNode::RichTextLines { lines, .. } = &children[0] else {
547            panic!("expected RichTextLines");
548        };
549        assert_eq!(lines.len(), 1);
550        // FixedMetrics.ascent() == 800/1000 -> 16pt at size 20; the line's
551        // shared baseline reference must come from the larger word, not
552        // the smaller one placed first.
553        assert_eq!(lines[0].height, 20.0);
554        assert_eq!(lines[0].ascent_pt, 16.0);
555    }
556
557    #[test]
558    fn rich_text_can_split_in_the_middle_of_a_single_span() {
559        // One span, 5 short words -> 5 lines at width 15 (each "LN" word is
560        // 12pt, two of them plus a 3pt space is 27pt > 15).
561        let style = TextStyle {
562            size: 10.0,
563            line_height: 1.0,
564            ..Default::default()
565        };
566        let text = TextEl::rich([Span::new("L0 L1 L2 L3 L4", style)]);
567        let c = ctx();
568        let mut warnings = Vec::new();
569        let area = Rect {
570            x: 0.0,
571            y: 0.0,
572            width: 15.0,
573            height: 40.0, // fits 4 of 5 lines by height alone
574        };
575        match text.layout(&c, area, &mut warnings, 1) {
576            LayoutResult::Split { current, remainder } => {
577                let RenderNode::Group { children, .. } = current else {
578                    panic!("expected the clip-wrapping Group");
579                };
580                let RenderNode::RichTextLines { lines, .. } = &children[0] else {
581                    panic!("expected RichTextLines");
582                };
583                assert_eq!(lines.len(), 3, "widow/orphan rule pulls one line up, same as plain text");
584                match remainder {
585                    Element::Text(t) => {
586                        let spans = t.spans.expect("remainder of a rich Text must still be rich text");
587                        assert_eq!(spans.len(), 1, "the single span continues as a single span, split mid-span");
588                        assert_eq!(spans[0].text, "L3 L4");
589                    }
590                    other => panic!("expected Text remainder, got {other:?}"),
591                }
592            }
593            LayoutResult::Fit(_) => panic!("expected a Split"),
594        }
595    }
596}