Skip to main content

lightweight_pdf_layout/layoutable/
column.rs

1//! Column: vertical main axis. Splittable (Text/Column children only, per
2//! phase-2 plan step 1), widow/orphan + keep_with_next + forced-page-break
3//! fallback for atomic elements bigger than a page (Grundprinzip 7/9).
4
5use super::shared::{
6    clip_to_fixed_height, line_height_pt, measure_at_width, push_warning, resolve_auto_size, resolve_bound, shrink_and_bound_height,
7    wrap_children, EPS,
8};
9use super::{LayoutCtx, LayoutResult, Layoutable};
10use crate::geometry::{Constraints, Rect, Size};
11use crate::render_node::{align_offset, RenderNode};
12use crate::warnings::{LayoutWarning, LayoutWarningKind};
13use lightweight_pdf_core::{Align, Column, Common, Element};
14
15impl Layoutable for Column {
16    fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
17        // Cross-axis "auto" width is shrink-to-fit (widest child), not
18        // fill-available: `Column::layout` already fills its own children
19        // to the full cross-axis width it's given (see below), but *this*
20        // method answers "how much space does this Column want", which a
21        // `Row` parent needs to distribute flex siblings correctly — always
22        // claiming the full bound here would starve any flex sibling.
23        let (bound_width, inner_width) = resolve_bound(self.common.width, constraints.max_width, self.common.padding);
24        let mut total_height = 0.0f32;
25        let mut max_child_width = 0.0f32;
26        let n = self.children.len();
27        for (i, child) in self.children.iter().enumerate() {
28            if let Element::PageBreak = child {
29                continue;
30            }
31            let (w, h) = if let Element::Spacer(s) = child {
32                (0.0, s.size)
33            } else {
34                let size = measure_at_width(ctx, child, inner_width);
35                (size.width, size.height)
36            };
37            max_child_width = max_child_width.max(w);
38            total_height += h;
39            if i + 1 < n {
40                total_height += self.gap;
41            }
42        }
43        Size {
44            width: self
45                .common
46                .width
47                .unwrap_or((max_child_width + 2.0 * self.common.padding).min(bound_width)),
48            height: resolve_auto_size(self.common.height, total_height, self.common.padding),
49        }
50    }
51
52    fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
53        let (inner, bound_height) = shrink_and_bound_height(area, self.common.height, self.common.padding);
54
55        let mut rendered = Vec::new();
56        let mut cursor_y = 0.0f32;
57
58        for (i, child) in self.children.iter().enumerate() {
59            if let Element::PageBreak = child {
60                let remainder_children: Vec<Element> = self.children[i + 1..].to_vec();
61                return finish_page(self, area, cursor_y, rendered, remainder_children, warnings, page);
62            }
63
64            let child_width = child
65                .common()
66                .and_then(|c| c.width)
67                .unwrap_or(inner.width - self.common.padding.min(inner.width));
68            let child_width = if child.common().and_then(|c| c.width).is_some() {
69                child_width
70            } else {
71                inner.width
72            };
73
74            if let Element::Spacer(s) = child {
75                cursor_y += s.size + self.gap;
76                continue;
77            }
78
79            let remaining_height = (bound_height - cursor_y).max(0.0);
80            let natural = measure_at_width(ctx, child, child_width);
81
82            let fits_fully = natural.height <= remaining_height + EPS;
83            let keep_ok = !fits_fully
84                || !child.common().map(|c| c.keep_with_next).unwrap_or(false)
85                || keep_with_next_satisfied(self, ctx, i, child_width, remaining_height - natural.height - self.gap);
86
87            if fits_fully && keep_ok {
88                let child_area = column_child_rect(inner, self.align, cursor_y, child_width, natural.height);
89                match child.layout(ctx, child_area, warnings, page) {
90                    LayoutResult::Fit(node) => {
91                        rendered.push(node);
92                        cursor_y += natural.height + self.gap;
93                    }
94                    // Shouldn't normally happen (it fit), but handle
95                    // defensively: keep what we got, move the rest on.
96                    LayoutResult::Split { current, remainder } => {
97                        let split = ChildSplit {
98                            current,
99                            remainder,
100                            remaining_siblings: &self.children[i + 1..],
101                        };
102                        return split_child_and_finish_page(self, area, cursor_y, rendered, split, warnings, page);
103                    }
104                }
105                continue;
106            }
107
108            // Does not fit fully on this page (or keep_with_next failed).
109            let splittable = matches!(
110                child,
111                Element::Text(_) | Element::Column(_) | Element::Table(_) | Element::TableOfContents(_)
112            );
113            let min_unit = match child {
114                Element::Text(t) => line_height_pt(&t.style),
115                Element::Table(t) => crate::table::table_min_unit(ctx, t, child_width),
116                Element::TableOfContents(t) => line_height_pt(&t.style),
117                _ => natural.height,
118            };
119
120            if cursor_y > EPS && (remaining_height < min_unit - EPS || !keep_ok) {
121                // Not worth attempting here: move this whole child (and
122                // everything after it) to the next page.
123                let mut remainder_children = vec![child.clone()];
124                remainder_children.extend(self.children[i + 1..].to_vec());
125                return finish_page(self, area, cursor_y, rendered, remainder_children, warnings, page);
126            }
127
128            if !splittable {
129                // Either an atomic element that doesn't fit even a full,
130                // empty page (force placement, clip, warn — Grundprinzip
131                // 7), or one whose `keep_with_next` couldn't be honored at
132                // the very start of a page (nothing to defer to, place at
133                // its natural size instead of stretching it).
134                let forced_height = if fits_fully { natural.height } else { remaining_height.max(0.0) };
135                let child_area = column_child_rect(inner, self.align, cursor_y, child_width, forced_height);
136                if let LayoutResult::Fit(node) = child.layout(ctx, child_area, warnings, page) {
137                    rendered.push(node);
138                }
139                if !fits_fully {
140                    push_warning(
141                        warnings,
142                        LayoutWarningKind::ForcedPageBreak,
143                        page,
144                        "atomic element larger than one page",
145                    );
146                }
147                cursor_y += forced_height + self.gap;
148                continue;
149            }
150
151            // Splittable (Text/Column) and we're at/near the top of a
152            // fresh page budget: attempt the real split.
153            let child_area = column_child_rect(inner, self.align, cursor_y, child_width, remaining_height);
154            match child.layout(ctx, child_area, warnings, page) {
155                LayoutResult::Fit(node) => {
156                    rendered.push(node);
157                    cursor_y += natural.height.min(remaining_height) + self.gap;
158                }
159                // `current` may hold real, sized content (the normal case
160                // for a child that partially fits) — folded into `cursor_y`
161                // by the helper so `finish_page` doesn't see a stale
162                // (too-small) cursor and wrongly discard it as "empty".
163                LayoutResult::Split { current, remainder } => {
164                    let split = ChildSplit {
165                        current,
166                        remainder,
167                        remaining_siblings: &self.children[i + 1..],
168                    };
169                    return split_child_and_finish_page(self, area, cursor_y, rendered, split, warnings, page);
170                }
171            }
172        }
173
174        let outer_height = self
175            .common
176            .height
177            .unwrap_or(cursor_y.max(0.0) - self.gap.min(cursor_y) + 2.0 * self.common.padding)
178            .max(0.0);
179        let outer_height = if cursor_y <= EPS { 0.0 } else { outer_height };
180        LayoutResult::Fit(wrap_children(area, outer_height, &self.common, rendered))
181    }
182}
183
184/// Builds a child's placement `Rect` within `Column::layout`'s content
185/// box: cross-axis-aligned (`align`) within `inner`'s width, stacked at
186/// `cursor_y` — all three of `Column::layout`'s placement sites (fits
187/// fully, forced atomic, split attempt) build this identically, differing
188/// only in the height they hand the child.
189fn column_child_rect(inner: Rect, align: Align, cursor_y: f32, child_width: f32, height: f32) -> Rect {
190    let x_offset = align_offset(align, inner.width, child_width);
191    Rect {
192        x: inner.x + x_offset,
193        y: inner.y + cursor_y,
194        width: child_width,
195        height,
196    }
197}
198
199/// A child's `Split` halves plus the siblings still waiting after it — the
200/// data `split_child_and_finish_page` needs, grouped so the function
201/// doesn't need one positional parameter per field (mirrors
202/// `RowRenderParams` in `table.rs`).
203struct ChildSplit<'a> {
204    current: RenderNode,
205    remainder: Element,
206    remaining_siblings: &'a [Element],
207}
208
209/// Handles a child that itself `Split` mid-placement inside
210/// `Column::layout` (both the "fits fully" and "near top of page, real
211/// split attempt" call sites end up here identically): keeps whatever
212/// fit, folds its already-consumed height into `cursor_y`, and ends the
213/// page. `cursor_y` must include `current`'s consumed height — otherwise
214/// `finish_page` sees a stale (too-small) cursor and can wrongly discard
215/// already-rendered content as "empty".
216fn split_child_and_finish_page(
217    col: &Column,
218    area: Rect,
219    cursor_y: f32,
220    mut rendered: Vec<RenderNode>,
221    split: ChildSplit,
222    warnings: &mut Vec<LayoutWarning>,
223    page: usize,
224) -> LayoutResult {
225    let consumed = split.current.height();
226    if !matches!(split.current, RenderNode::Empty) {
227        rendered.push(split.current);
228    }
229    let mut remainder_children = vec![split.remainder];
230    remainder_children.extend(split.remaining_siblings.to_vec());
231    finish_page(col, area, cursor_y + consumed, rendered, remainder_children, warnings, page)
232}
233
234/// Peeks at the sibling right after index `i` to decide whether
235/// `keep_with_next` is satisfiable: does its minimal content (one line for
236/// Text, full natural height otherwise) fit in the space left after
237/// placing the current child?
238fn keep_with_next_satisfied(col: &Column, ctx: &LayoutCtx, i: usize, width: f32, leftover: f32) -> bool {
239    let Some(next) = col.children.get(i + 1) else {
240        return true;
241    };
242    let min_needed = match next {
243        Element::Text(t) => line_height_pt(&t.style),
244        Element::Spacer(s) => s.size,
245        _ => measure_at_width(ctx, next, width).height,
246    };
247    leftover + EPS >= min_needed
248}
249
250/// Ends the current page's placement for a `Column`. An auto-sized/
251/// pagination-driven `Column` (no explicit `.height()`) produces a real
252/// `Split` so the remainder continues on the next page. A `Column` with an
253/// explicit fixed height instead clips right here and stays a `Fit` — its
254/// overflow is governed by `overflow`/Grundprinzip 3, not by pagination
255/// (mirrors the same distinction made for `Text`, see `layout_fixed_overflow`).
256fn finish_page(
257    col: &Column,
258    outer_area: Rect,
259    cursor_y: f32,
260    rendered: Vec<RenderNode>,
261    remainder_children: Vec<Element>,
262    warnings: &mut Vec<LayoutWarning>,
263    page: usize,
264) -> LayoutResult {
265    if let Some(fixed_height) = col.common.height {
266        let overflow_hint = (!remainder_children.is_empty()).then_some("Column content exceeds its fixed height");
267        return clip_to_fixed_height(outer_area, fixed_height, &col.common, rendered, warnings, page, overflow_hint);
268    }
269
270    let outer_height = if cursor_y <= EPS {
271        0.0
272    } else {
273        (cursor_y - col.gap.min(cursor_y) + 2.0 * col.common.padding).max(0.0)
274    };
275    let current = wrap_children(outer_area, outer_height, &col.common, rendered);
276    let remainder = Column {
277        children: remainder_children,
278        gap: col.gap,
279        align: col.align,
280        common: Common {
281            height: None,
282            ..col.common
283        },
284    };
285    LayoutResult::Split {
286        current: if cursor_y <= EPS { RenderNode::Empty } else { current },
287        remainder: Element::Column(remainder),
288    }
289}