Skip to main content

lightweight_pdf_layout/
layoutable.rs

1use crate::font_resolver::FontResolver;
2use crate::geometry::{Constraints, Rect, Size};
3use crate::render_node::{align_offset, RenderNode};
4use crate::text::{text_width_pt, wrap_text};
5use crate::warnings::{LayoutWarning, LayoutWarningKind};
6use lightweight_pdf_core::{Column, Element, Line, Overflow, Rect as RectElement, Row, Spacer, Text};
7
8/// Threshold for the widow/orphan rule (Grundprinzip 9): a paragraph is
9/// never split leaving fewer than `N` lines on either side of the break.
10const WIDOW_ORPHAN_N: usize = 2;
11const EPS: f32 = 0.01;
12
13pub struct LayoutCtx<'a> {
14    pub resolver: &'a dyn FontResolver,
15}
16
17/// Result of laying an element out into a bounded area: either it fully
18/// fit, or the fitting part plus a materialized remainder element for the
19/// next page. `Text`, `Column` and `Table` produce
20/// `Split` in V1.
21pub enum LayoutResult {
22    Fit(RenderNode),
23    Split { current: RenderNode, remainder: Element },
24}
25
26/// `measure`/`layout`. Implemented for every
27/// concrete element type (not `Element` variants with `todo!()`, since all
28/// V1-through-Phase-2 variants are implemented) plus a dispatching impl on
29/// `Element` itself so containers can recurse over `Vec<Element>` children.
30pub trait Layoutable {
31    fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size;
32    fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult;
33}
34
35// ---------------------------------------------------------------------
36// Element: dispatch to the concrete impls below. `PageBreak` has no
37// intrinsic size/rendering of its own — `Column`'s layout loop intercepts
38// it before ever calling into this generic path.
39// ---------------------------------------------------------------------
40
41impl Layoutable for Element {
42    fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
43        match self {
44            Element::Text(t) => t.measure(ctx, constraints),
45            Element::Row(r) => r.measure(ctx, constraints),
46            Element::Column(c) => c.measure(ctx, constraints),
47            Element::Spacer(s) => s.measure(ctx, constraints),
48            Element::Line(l) => l.measure(ctx, constraints),
49            Element::Rect(r) => r.measure(ctx, constraints),
50            Element::Table(t) => t.measure(ctx, constraints),
51            Element::Image(i) => i.measure(ctx, constraints),
52            Element::List(l) => l.measure(ctx, constraints),
53            Element::PageBreak => Size::default(),
54        }
55    }
56
57    fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
58        match self {
59            Element::Text(t) => t.layout(ctx, area, warnings, page),
60            Element::Row(r) => r.layout(ctx, area, warnings, page),
61            Element::Column(c) => c.layout(ctx, area, warnings, page),
62            Element::Spacer(s) => s.layout(ctx, area, warnings, page),
63            Element::Line(l) => l.layout(ctx, area, warnings, page),
64            Element::Rect(r) => r.layout(ctx, area, warnings, page),
65            Element::Table(t) => t.layout(ctx, area, warnings, page),
66            Element::Image(i) => i.layout(ctx, area, warnings, page),
67            Element::List(l) => l.layout(ctx, area, warnings, page),
68            Element::PageBreak => LayoutResult::Fit(RenderNode::Empty),
69        }
70    }
71}
72
73// ---------------------------------------------------------------------
74// Text
75// ---------------------------------------------------------------------
76
77fn line_height_pt(style: &lightweight_pdf_core::TextStyle) -> f32 {
78    style.size * style.line_height
79}
80
81fn text_lines_node(area: Rect, style: lightweight_pdf_core::TextStyle, lines: Vec<String>, lh: f32) -> RenderNode {
82    let height = lines.len() as f32 * lh;
83    RenderNode::clipped(
84        area,
85        RenderNode::TextLines {
86            area: Rect { height, ..area },
87            style,
88            lines,
89            line_height_pt: lh,
90        },
91    )
92}
93
94impl Layoutable for Text {
95    fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
96        let width = self.common.width.unwrap_or(constraints.max_width);
97        let lines = wrap_text(ctx.resolver, &self.style, &self.content, width);
98        let lh = line_height_pt(&self.style);
99        let actual_width = lines
100            .iter()
101            .map(|l| text_width_pt(ctx.resolver, self.style.font, self.style.size, l))
102            .fold(0.0f32, f32::max);
103        Size {
104            width: self.common.width.unwrap_or(actual_width.min(width)),
105            height: self.common.height.unwrap_or(lines.len() as f32 * lh),
106        }
107    }
108
109    fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
110        let lines = wrap_text(ctx.resolver, &self.style, &self.content, area.width);
111        let lh = line_height_pt(&self.style);
112        let total_height = lines.len() as f32 * lh;
113
114        if total_height <= area.height + EPS || lines.len() <= 1 {
115            if total_height > area.height + EPS {
116                warnings.push(LayoutWarning {
117                    kind: LayoutWarningKind::TextClipped,
118                    page,
119                    element_hint: format!("Text \"{}\"", truncate_hint(&self.content)),
120                });
121            }
122            return LayoutResult::Fit(text_lines_node(area, self.style, lines, lh));
123        }
124
125        // An explicit, fixed `.height(...)` means this box's overflow is
126        // governed by the `overflow` property (Grundprinzip 3: Clip/
127        // Ellipsis), not by pagination — it must never turn into a
128        // page-spanning `Split`. Only the ambient, pagination-provided
129        // budget (no explicit height) may split.
130        if self.common.height.is_some() {
131            return LayoutResult::Fit(layout_text_fixed_overflow(self, ctx, area, lines, lh, warnings, page));
132        }
133
134        let max_lines_by_height = ((area.height + EPS) / lh).floor().max(0.0) as usize;
135        let max_lines_by_height = max_lines_by_height.min(lines.len());
136
137        let mut split_at = max_lines_by_height;
138        if lines.len() < 2 * WIDOW_ORPHAN_N {
139            // Short paragraph: never split, move as a whole.
140            split_at = 0;
141        } else if split_at < WIDOW_ORPHAN_N {
142            // Orphan: too few lines would remain before the break.
143            split_at = 0;
144        } else if lines.len() - split_at < WIDOW_ORPHAN_N {
145            // Widow: pull lines up so the remainder has >= N lines.
146            let adjusted = lines.len().saturating_sub(WIDOW_ORPHAN_N);
147            split_at = if adjusted >= WIDOW_ORPHAN_N { adjusted } else { 0 };
148        }
149
150        if split_at == 0 {
151            return LayoutResult::Split {
152                current: RenderNode::Empty,
153                remainder: Element::Text(self.clone()),
154            };
155        }
156
157        let (current_lines, remainder_lines) = lines.split_at(split_at);
158        let current = text_lines_node(
159            Rect {
160                height: current_lines.len() as f32 * lh,
161                ..area
162            },
163            self.style,
164            current_lines.to_vec(),
165            lh,
166        );
167        let remainder_text = remainder_lines.join(" ");
168        let mut remainder = self.clone();
169        remainder.content = remainder_text;
170        LayoutResult::Split {
171            current,
172            remainder: Element::Text(remainder),
173        }
174    }
175}
176
177/// Overflow handling for an explicitly, fixed-size text box (Grundprinzip
178/// 3): `Clip` drops lines that don't fit, `Ellipsis` truncates the last
179/// visible line with a trailing "…" (single-line use case: a long label in
180/// a narrow, fixed column). Free function (not an inherent impl) because
181/// `Text` is defined in `lightweight-pdf-core`, outside this crate.
182fn layout_text_fixed_overflow(
183    text: &Text,
184    ctx: &LayoutCtx,
185    area: Rect,
186    lines: Vec<String>,
187    lh: f32,
188    warnings: &mut Vec<LayoutWarning>,
189    page: usize,
190) -> RenderNode {
191    let max_lines = (((area.height + EPS) / lh).floor().max(0.0) as usize).min(lines.len());
192    if max_lines >= lines.len() {
193        return text_lines_node(area, text.style, lines, lh);
194    }
195    warnings.push(LayoutWarning {
196        kind: LayoutWarningKind::TextClipped,
197        page,
198        element_hint: format!("Text \"{}\"", truncate_hint(&text.content)),
199    });
200    let take = if text.common.overflow == Overflow::Ellipsis {
201        max_lines.max(1).min(lines.len())
202    } else {
203        max_lines
204    };
205    let mut kept: Vec<String> = lines.into_iter().take(take).collect();
206    if text.common.overflow == Overflow::Ellipsis {
207        if let Some(last) = kept.last_mut() {
208            *last = fit_with_ellipsis(ctx, &text.style, last, area.width);
209        }
210    }
211    text_lines_node(area, text.style, kept, lh)
212}
213
214/// Trims `line` character by character (from the end) until `line + "…"`
215/// fits `max_width`, then appends the ellipsis.
216fn fit_with_ellipsis(ctx: &LayoutCtx, style: &lightweight_pdf_core::TextStyle, line: &str, max_width: f32) -> String {
217    let mut chars: Vec<char> = line.chars().collect();
218    loop {
219        let candidate: String = chars.iter().collect::<String>() + "…";
220        if text_width_pt(ctx.resolver, style.font, style.size, &candidate) <= max_width || chars.is_empty() {
221            return candidate;
222        }
223        chars.pop();
224    }
225}
226
227fn truncate_hint(s: &str) -> String {
228    if s.len() > 24 {
229        format!("{}…", &s[..24])
230    } else {
231        s.to_string()
232    }
233}
234
235// ---------------------------------------------------------------------
236// Spacer (special-cased by Row/Column before generic dispatch — the axis
237// a Spacer consumes depends on its parent, which a standalone measure/
238// layout call cannot know).
239// ---------------------------------------------------------------------
240
241impl Layoutable for Spacer {
242    fn measure(&self, _ctx: &LayoutCtx, _constraints: Constraints) -> Size {
243        Size {
244            width: self.size,
245            height: self.size,
246        }
247    }
248
249    fn layout(&self, _ctx: &LayoutCtx, _area: Rect, _warnings: &mut Vec<LayoutWarning>, _page: usize) -> LayoutResult {
250        LayoutResult::Fit(RenderNode::Empty)
251    }
252}
253
254// ---------------------------------------------------------------------
255// Line
256// ---------------------------------------------------------------------
257
258impl Layoutable for Line {
259    fn measure(&self, _ctx: &LayoutCtx, constraints: Constraints) -> Size {
260        Size {
261            width: self.common.width.unwrap_or(constraints.max_width),
262            height: self.common.height.unwrap_or(self.thickness),
263        }
264    }
265
266    fn layout(&self, _ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
267        if self.thickness > area.height + EPS {
268            warnings.push(LayoutWarning {
269                kind: LayoutWarningKind::ContentOverflow,
270                page,
271                element_hint: "Line".to_string(),
272            });
273        }
274        let y_mid = area.y + (self.thickness / 2.0).min(area.height);
275        let node = RenderNode::Line {
276            x1: area.x,
277            y1: y_mid,
278            x2: area.x + area.width,
279            y2: y_mid,
280            thickness: self.thickness,
281            color: self.color,
282        };
283        LayoutResult::Fit(RenderNode::clipped(area, node))
284    }
285}
286
287// ---------------------------------------------------------------------
288// Rect
289// ---------------------------------------------------------------------
290
291impl Layoutable for RectElement {
292    fn measure(&self, _ctx: &LayoutCtx, constraints: Constraints) -> Size {
293        Size {
294            width: self.common.width.unwrap_or(constraints.max_width),
295            height: self.common.height.unwrap_or(0.0),
296        }
297    }
298
299    fn layout(&self, _ctx: &LayoutCtx, area: Rect, _warnings: &mut Vec<LayoutWarning>, _page: usize) -> LayoutResult {
300        let node = RenderNode::Rect {
301            area,
302            background: self.common.background,
303            border: self.common.border,
304        };
305        LayoutResult::Fit(RenderNode::clipped(area, node))
306    }
307}
308
309// ---------------------------------------------------------------------
310// Row: horizontal main axis, always bound by the incoming area width.
311// Non-flex children measure at that bound (approximation, see
312// `plan/03-builder-api-design.md`); flex children share the leftover
313// space proportionally (taffy flex-grow analogy, ADR-004). No Row-level
314// Split in V1 — only Column/Text split (phase-2 plan, step 1).
315// ---------------------------------------------------------------------
316
317impl Layoutable for Row {
318    fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
319        let width = self.common.width.unwrap_or(constraints.max_width);
320        let inner_width = (width - 2.0 * self.common.padding).max(0.0);
321        let (natural_heights, used_width) = row_natural_layout(self, ctx, inner_width);
322        let height = self
323            .common
324            .height
325            .unwrap_or(natural_heights.iter().cloned().fold(0.0f32, f32::max) + 2.0 * self.common.padding);
326        Size {
327            width: self.common.width.unwrap_or((used_width + 2.0 * self.common.padding).min(width)),
328            height,
329        }
330    }
331
332    fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
333        let inner = area.shrink(self.common.padding);
334        let resolved_widths = resolve_row_widths(self, ctx, inner.width);
335
336        let mut children_nodes = Vec::with_capacity(self.children.len());
337        let mut cursor_x = inner.x;
338        let mut max_child_height = 0.0f32;
339        let mut child_sizes = Vec::with_capacity(self.children.len());
340
341        for (child, w) in self.children.iter().zip(resolved_widths.iter()) {
342            if let Element::Spacer(s) = child {
343                cursor_x += s.size + self.gap;
344                continue;
345            }
346            let size = child.measure(
347                ctx,
348                Constraints {
349                    max_width: *w,
350                    max_height: f32::INFINITY,
351                },
352            );
353            max_child_height = max_child_height.max(size.height);
354            child_sizes.push((cursor_x, *w, size.height));
355            cursor_x += w + self.gap;
356        }
357
358        let row_height = self.common.height.unwrap_or(max_child_height).max(0.0);
359        let bounded_row_height = row_height.min(inner.height.max(row_height));
360
361        let mut idx = 0;
362        for child in self.children.iter() {
363            if matches!(child, Element::Spacer(_)) {
364                continue;
365            }
366            let (x, w, h) = child_sizes[idx];
367            idx += 1;
368            let y_offset = align_offset(self.align, bounded_row_height, h);
369            let child_area = Rect {
370                x,
371                y: inner.y + y_offset,
372                width: w,
373                height: h,
374            };
375            match child.layout(ctx, child_area, warnings, page) {
376                LayoutResult::Fit(node) => children_nodes.push(node),
377                LayoutResult::Split { current, .. } => {
378                    // Row does not support splitting across pages (V1
379                    // scope): keep what fits, clip the rest, warn.
380                    warnings.push(LayoutWarning {
381                        kind: LayoutWarningKind::ContentOverflow,
382                        page,
383                        element_hint: "Row child taller than available space".to_string(),
384                    });
385                    children_nodes.push(current);
386                }
387            }
388        }
389
390        let outer_height = self.common.height.unwrap_or(bounded_row_height + 2.0 * self.common.padding);
391        let outer = Rect {
392            height: outer_height,
393            ..area
394        };
395        let group = RenderNode::Group {
396            area: outer,
397            clip: true,
398            background: self.common.background,
399            border: self.common.border,
400            children: children_nodes,
401        };
402        LayoutResult::Fit(group)
403    }
404}
405
406/// Natural (non-flex-adjusted) child heights plus the total width used by
407/// non-flex children, at a given bound width — used by `measure`.
408fn row_natural_layout(row: &Row, ctx: &LayoutCtx, bound_width: f32) -> (Vec<f32>, f32) {
409    let mut heights = Vec::new();
410    let mut used = 0.0f32;
411    let n = row.children.len();
412    for (i, child) in row.children.iter().enumerate() {
413        if let Element::Spacer(s) = child {
414            used += s.size;
415        } else {
416            let size = child.measure(
417                ctx,
418                Constraints {
419                    max_width: bound_width,
420                    max_height: f32::INFINITY,
421                },
422            );
423            heights.push(size.height);
424            used += size.width;
425        }
426        if i + 1 < n {
427            used += row.gap;
428        }
429    }
430    (heights, used)
431}
432
433/// Resolves each non-spacer child's width: fixed/natural for non-flex
434/// children, leftover space shared proportionally among flex children.
435fn resolve_row_widths(row: &Row, ctx: &LayoutCtx, bound_width: f32) -> Vec<f32> {
436    let n = row.children.len();
437    let gaps = if n > 0 { (n - 1) as f32 * row.gap } else { 0.0 };
438    let mut natural = vec![0.0f32; n];
439    let mut flex_sum = 0.0f32;
440    let mut fixed_total = 0.0f32;
441
442    for (i, child) in row.children.iter().enumerate() {
443        if let Element::Spacer(s) = child {
444            natural[i] = s.size;
445            fixed_total += s.size;
446            continue;
447        }
448        let common = child.common();
449        if let Some(f) = common.and_then(|c| c.flex) {
450            flex_sum += f;
451            continue;
452        }
453        let size = child.measure(
454            ctx,
455            Constraints {
456                max_width: bound_width,
457                max_height: f32::INFINITY,
458            },
459        );
460        natural[i] = size.width;
461        fixed_total += size.width;
462    }
463
464    let leftover = (bound_width - fixed_total - gaps).max(0.0);
465    if flex_sum > 0.0 {
466        for (i, child) in row.children.iter().enumerate() {
467            if let Some(f) = child.common().and_then(|c| c.flex) {
468                natural[i] = leftover * (f / flex_sum);
469            }
470        }
471    }
472    natural
473}
474
475// ---------------------------------------------------------------------
476// Column: vertical main axis. Splittable (Text/Column children only, per
477// phase-2 plan step 1), widow/orphan + keep_with_next + forced-page-break
478// fallback for atomic elements bigger than a page (Grundprinzip 7/9).
479// ---------------------------------------------------------------------
480
481impl Layoutable for Column {
482    fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
483        // Cross-axis "auto" width is shrink-to-fit (widest child), not
484        // fill-available: `Column::layout` already fills its own children
485        // to the full cross-axis width it's given (see below), but *this*
486        // method answers "how much space does this Column want", which a
487        // `Row` parent needs to distribute flex siblings correctly — always
488        // claiming the full bound here would starve any flex sibling.
489        let bound_width = self.common.width.unwrap_or(constraints.max_width);
490        let inner_width = (bound_width - 2.0 * self.common.padding).max(0.0);
491        let mut total_height = 0.0f32;
492        let mut max_child_width = 0.0f32;
493        let n = self.children.len();
494        for (i, child) in self.children.iter().enumerate() {
495            if let Element::PageBreak = child {
496                continue;
497            }
498            let (w, h) = if let Element::Spacer(s) = child {
499                (0.0, s.size)
500            } else {
501                let size = child.measure(
502                    ctx,
503                    Constraints {
504                        max_width: inner_width,
505                        max_height: f32::INFINITY,
506                    },
507                );
508                (size.width, size.height)
509            };
510            max_child_width = max_child_width.max(w);
511            total_height += h;
512            if i + 1 < n {
513                total_height += self.gap;
514            }
515        }
516        Size {
517            width: self
518                .common
519                .width
520                .unwrap_or((max_child_width + 2.0 * self.common.padding).min(bound_width)),
521            height: self.common.height.unwrap_or(total_height + 2.0 * self.common.padding),
522        }
523    }
524
525    fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
526        let inner = area.shrink(self.common.padding);
527        let bound_height = self.common.height.map(|h| h - 2.0 * self.common.padding).unwrap_or(inner.height);
528
529        let mut rendered = Vec::new();
530        let mut cursor_y = 0.0f32;
531
532        for (i, child) in self.children.iter().enumerate() {
533            if let Element::PageBreak = child {
534                let remainder_children: Vec<Element> = self.children[i + 1..].to_vec();
535                return finish_page(self, area, cursor_y, rendered, remainder_children, warnings, page);
536            }
537
538            let child_width = child
539                .common()
540                .and_then(|c| c.width)
541                .unwrap_or(inner.width - self.common.padding.min(inner.width));
542            let child_width = if child.common().and_then(|c| c.width).is_some() {
543                child_width
544            } else {
545                inner.width
546            };
547
548            if let Element::Spacer(s) = child {
549                cursor_y += s.size + self.gap;
550                continue;
551            }
552
553            let remaining_height = (bound_height - cursor_y).max(0.0);
554            let natural = child.measure(
555                ctx,
556                Constraints {
557                    max_width: child_width,
558                    max_height: f32::INFINITY,
559                },
560            );
561
562            let fits_fully = natural.height <= remaining_height + EPS;
563            let keep_ok = !fits_fully
564                || !child.common().map(|c| c.keep_with_next).unwrap_or(false)
565                || keep_with_next_satisfied(self, ctx, i, child_width, remaining_height - natural.height - self.gap);
566
567            if fits_fully && keep_ok {
568                let x_offset = align_offset(self.align, inner.width, child_width);
569                let child_area = Rect {
570                    x: inner.x + x_offset,
571                    y: inner.y + cursor_y,
572                    width: child_width,
573                    height: natural.height,
574                };
575                match child.layout(ctx, child_area, warnings, page) {
576                    LayoutResult::Fit(node) => {
577                        rendered.push(node);
578                        cursor_y += natural.height + self.gap;
579                    }
580                    LayoutResult::Split { current, remainder } => {
581                        // Shouldn't normally happen (it fit), but handle
582                        // defensively: keep what we got, move the rest on.
583                        // `cursor_y` must account for the height `current`
584                        // actually consumed — otherwise `finish_page` sees
585                        // a stale (too-small) cursor and can wrongly
586                        // discard already-rendered content as "empty".
587                        let consumed = current.height();
588                        if !matches!(current, RenderNode::Empty) {
589                            rendered.push(current);
590                        }
591                        let mut remainder_children = vec![remainder];
592                        remainder_children.extend(self.children[i + 1..].to_vec());
593                        return finish_page(self, area, cursor_y + consumed, rendered, remainder_children, warnings, page);
594                    }
595                }
596                continue;
597            }
598
599            // Does not fit fully on this page (or keep_with_next failed).
600            let splittable = matches!(child, Element::Text(_) | Element::Column(_) | Element::Table(_));
601            let min_unit = match child {
602                Element::Text(t) => line_height_pt(&t.style),
603                Element::Table(t) => crate::table::table_min_unit(ctx, t, child_width),
604                _ => natural.height,
605            };
606
607            if cursor_y > EPS && (remaining_height < min_unit - EPS || !keep_ok) {
608                // Not worth attempting here: move this whole child (and
609                // everything after it) to the next page.
610                let mut remainder_children = vec![child.clone()];
611                remainder_children.extend(self.children[i + 1..].to_vec());
612                return finish_page(self, area, cursor_y, rendered, remainder_children, warnings, page);
613            }
614
615            if !splittable {
616                // Either an atomic element that doesn't fit even a full,
617                // empty page (force placement, clip, warn — Grundprinzip
618                // 7), or one whose `keep_with_next` couldn't be honored at
619                // the very start of a page (nothing to defer to, place at
620                // its natural size instead of stretching it).
621                let x_offset = align_offset(self.align, inner.width, child_width);
622                let forced_height = if fits_fully { natural.height } else { remaining_height.max(0.0) };
623                let child_area = Rect {
624                    x: inner.x + x_offset,
625                    y: inner.y + cursor_y,
626                    width: child_width,
627                    height: forced_height,
628                };
629                if let LayoutResult::Fit(node) = child.layout(ctx, child_area, warnings, page) {
630                    rendered.push(node);
631                }
632                if !fits_fully {
633                    warnings.push(LayoutWarning {
634                        kind: LayoutWarningKind::ForcedPageBreak,
635                        page,
636                        element_hint: "atomic element larger than one page".to_string(),
637                    });
638                }
639                cursor_y += forced_height + self.gap;
640                continue;
641            }
642
643            // Splittable (Text/Column) and we're at/near the top of a
644            // fresh page budget: attempt the real split.
645            let x_offset = align_offset(self.align, inner.width, child_width);
646            let child_area = Rect {
647                x: inner.x + x_offset,
648                y: inner.y + cursor_y,
649                width: child_width,
650                height: remaining_height,
651            };
652            match child.layout(ctx, child_area, warnings, page) {
653                LayoutResult::Fit(node) => {
654                    rendered.push(node);
655                    cursor_y += natural.height.min(remaining_height) + self.gap;
656                }
657                LayoutResult::Split { current, remainder } => {
658                    // Same fix as above: `current` may hold real, sized
659                    // content (the normal case for a child that partially
660                    // fits) — `cursor_y` must reflect that before
661                    // `finish_page` decides whether this page is empty.
662                    let consumed = current.height();
663                    if !matches!(current, RenderNode::Empty) {
664                        rendered.push(current);
665                    }
666                    let mut remainder_children = vec![remainder];
667                    remainder_children.extend(self.children[i + 1..].to_vec());
668                    return finish_page(self, area, cursor_y + consumed, rendered, remainder_children, warnings, page);
669                }
670            }
671        }
672
673        let outer_height = self
674            .common
675            .height
676            .unwrap_or(cursor_y.max(0.0) - self.gap.min(cursor_y) + 2.0 * self.common.padding)
677            .max(0.0);
678        let outer_height = if cursor_y <= EPS { 0.0 } else { outer_height };
679        let outer = Rect {
680            height: outer_height,
681            ..area
682        };
683        LayoutResult::Fit(RenderNode::Group {
684            area: outer,
685            clip: true,
686            background: self.common.background,
687            border: self.common.border,
688            children: rendered,
689        })
690    }
691}
692
693/// Peeks at the sibling right after index `i` to decide whether
694/// `keep_with_next` is satisfiable: does its minimal content (one line for
695/// Text, full natural height otherwise) fit in the space left after
696/// placing the current child?
697fn keep_with_next_satisfied(col: &Column, ctx: &LayoutCtx, i: usize, width: f32, leftover: f32) -> bool {
698    let Some(next) = col.children.get(i + 1) else {
699        return true;
700    };
701    let min_needed = match next {
702        Element::Text(t) => line_height_pt(&t.style),
703        Element::Spacer(s) => s.size,
704        _ => {
705            next.measure(
706                ctx,
707                Constraints {
708                    max_width: width,
709                    max_height: f32::INFINITY,
710                },
711            )
712            .height
713        }
714    };
715    leftover + EPS >= min_needed
716}
717
718/// Ends the current page's placement for a `Column`. An auto-sized/
719/// pagination-driven `Column` (no explicit `.height()`) produces a real
720/// `Split` so the remainder continues on the next page. A `Column` with an
721/// explicit fixed height instead clips right here and stays a `Fit` — its
722/// overflow is governed by `overflow`/Grundprinzip 3, not by pagination
723/// (mirrors the same distinction made for `Text`, see `layout_fixed_overflow`).
724fn finish_page(
725    col: &Column,
726    outer_area: Rect,
727    cursor_y: f32,
728    rendered: Vec<RenderNode>,
729    remainder_children: Vec<Element>,
730    warnings: &mut Vec<LayoutWarning>,
731    page: usize,
732) -> LayoutResult {
733    if let Some(fixed_height) = col.common.height {
734        if !remainder_children.is_empty() {
735            warnings.push(LayoutWarning {
736                kind: LayoutWarningKind::ContentOverflow,
737                page,
738                element_hint: "Column content exceeds its fixed height".to_string(),
739            });
740        }
741        return LayoutResult::Fit(RenderNode::Group {
742            area: Rect {
743                height: fixed_height,
744                ..outer_area
745            },
746            clip: true,
747            background: col.common.background,
748            border: col.common.border,
749            children: rendered,
750        });
751    }
752
753    let outer_height = if cursor_y <= EPS {
754        0.0
755    } else {
756        (cursor_y - col.gap.min(cursor_y) + 2.0 * col.common.padding).max(0.0)
757    };
758    let current = RenderNode::Group {
759        area: Rect {
760            height: outer_height,
761            ..outer_area
762        },
763        clip: true,
764        background: col.common.background,
765        border: col.common.border,
766        children: rendered,
767    };
768    let remainder = Column {
769        children: remainder_children,
770        gap: col.gap,
771        align: col.align,
772        common: lightweight_pdf_core::Common {
773            height: None,
774            ..col.common
775        },
776    };
777    LayoutResult::Split {
778        current: if cursor_y <= EPS { RenderNode::Empty } else { current },
779        remainder: Element::Column(remainder),
780    }
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786    use crate::pagination::paginate_body;
787    use lightweight_pdf_core::{Common, Overflow as OverflowKind, Text as TextEl};
788
789    struct FixedMetrics;
790    impl crate::font_resolver::FontMetrics for FixedMetrics {
791        fn advance(&self, ch: char) -> f32 {
792            if ch == ' ' {
793                300.0
794            } else {
795                600.0
796            }
797        }
798        fn ascent(&self) -> f32 {
799            800.0
800        }
801        fn descent(&self) -> f32 {
802            -200.0
803        }
804    }
805    struct FixedResolver;
806    impl FontResolver for FixedResolver {
807        fn metrics(&self, _key: lightweight_pdf_core::FontKey) -> &dyn crate::font_resolver::FontMetrics {
808            &FixedMetrics
809        }
810    }
811
812    fn ctx() -> LayoutCtx<'static> {
813        LayoutCtx { resolver: &FixedResolver }
814    }
815
816    // --- Grundprinzip 1: auto-size is the default -----------------------
817
818    #[test]
819    fn column_auto_size_grows_with_content() {
820        let short = Column::new().child(TextEl::new("Hi").size(10.0).line_height(1.0));
821        let long = Column::new().children(vec![
822            TextEl::new("Line one").size(10.0).line_height(1.0),
823            TextEl::new("Line two").size(10.0).line_height(1.0),
824            TextEl::new("Line three").size(10.0).line_height(1.0),
825        ]);
826        let c = ctx();
827        let constraints = Constraints {
828            max_width: 400.0,
829            max_height: f32::INFINITY,
830        };
831        let short_size = short.measure(&c, constraints);
832        let long_size = long.measure(&c, constraints);
833        assert!(long_size.height > short_size.height, "more content must measure taller");
834    }
835
836    // --- Grundprinzip 2/3: hard-break + fixed-size Clip (never Split) ---
837
838    #[test]
839    fn fixed_height_text_clips_instead_of_splitting() {
840        let text = TextEl::new("AAAA BBBB CCCC DDDD").size(10.0).line_height(1.0).height(10.0);
841        let c = ctx();
842        let mut warnings = Vec::new();
843        // Narrow width forces multiple lines; the box is only 1 line tall.
844        let area = Rect {
845            x: 0.0,
846            y: 0.0,
847            width: 30.0,
848            height: 10.0,
849        };
850        let result = text.layout(&c, area, &mut warnings, 1);
851        assert!(
852            matches!(result, LayoutResult::Fit(_)),
853            "fixed-size box must Clip, never Split across pages"
854        );
855        assert!(warnings.iter().any(|w| w.kind == LayoutWarningKind::TextClipped));
856    }
857
858    #[test]
859    fn fixed_height_column_clips_instead_of_splitting() {
860        let col = Column::new().height(10.0).children(vec![
861            TextEl::new("Line one").size(10.0).line_height(1.0),
862            TextEl::new("Line two").size(10.0).line_height(1.0),
863            TextEl::new("Line three").size(10.0).line_height(1.0),
864        ]);
865        let c = ctx();
866        let mut warnings = Vec::new();
867        let area = Rect {
868            x: 0.0,
869            y: 0.0,
870            width: 400.0,
871            height: 10.0,
872        };
873        let result = col.layout(&c, area, &mut warnings, 1);
874        assert!(matches!(result, LayoutResult::Fit(_)), "fixed-height Column must Clip, never Split");
875        assert!(warnings.iter().any(|w| w.kind == LayoutWarningKind::ContentOverflow));
876    }
877
878    // --- Grundprinzip 4/6: containers/children never overlap ------------
879
880    #[test]
881    fn row_children_do_not_overlap_horizontally() {
882        let row = Row::new()
883            .gap(10.0)
884            .child(TextEl::new("Left").size(10.0))
885            .child(TextEl::new("Right").size(10.0));
886        let c = ctx();
887        let mut warnings = Vec::new();
888        let area = Rect {
889            x: 0.0,
890            y: 0.0,
891            width: 400.0,
892            height: 50.0,
893        };
894        let result = row.layout(&c, area, &mut warnings, 1);
895        let LayoutResult::Fit(RenderNode::Group { children, .. }) = result else {
896            panic!("expected a Fit Group");
897        };
898        assert_eq!(children.len(), 2);
899        let rects: Vec<Rect> = children
900            .iter()
901            .map(|n| match n {
902                RenderNode::Group { area, .. } => *area,
903                other => panic!("expected nested Group, got {other:?}"),
904            })
905            .collect();
906        assert!(
907            rects[0].x + rects[0].width <= rects[1].x + EPS,
908            "children must not overlap: {:?} vs {:?}",
909            rects[0],
910            rects[1]
911        );
912    }
913
914    // --- Phase 2: PageBreak ----------------------------------------------
915
916    #[test]
917    fn page_break_forces_a_split_at_the_marker() {
918        let col = Column::new().children(vec![
919            Element::Text(TextEl::new("a")),
920            Element::PageBreak,
921            Element::Text(TextEl::new("b")),
922        ]);
923        let c = ctx();
924        let mut warnings = Vec::new();
925        let area = Rect {
926            x: 0.0,
927            y: 0.0,
928            width: 400.0,
929            height: 400.0, // plenty of room — the break must still trigger.
930        };
931        match col.layout(&c, area, &mut warnings, 1) {
932            LayoutResult::Split { remainder, .. } => match remainder {
933                Element::Column(rem) => {
934                    assert_eq!(rem.children.len(), 1);
935                    match &rem.children[0] {
936                        Element::Text(t) => assert_eq!(t.content, "b"),
937                        other => panic!("expected Text, got {other:?}"),
938                    }
939                }
940                other => panic!("expected Column remainder, got {other:?}"),
941            },
942            LayoutResult::Fit(_) => panic!("PageBreak must force a Split even when content would otherwise fit"),
943        }
944    }
945
946    // --- Grundprinzip 7: atomic element bigger than a page --------------
947
948    #[test]
949    fn oversized_atomic_element_is_forced_onto_its_own_page_and_terminates() {
950        let children = vec![
951            Element::Rect(RectElement::new().height(5000.0).background(lightweight_pdf_core::Color::BLACK)),
952            Element::Rect(RectElement::new().height(20.0)),
953        ];
954        let c = ctx();
955        let mut warnings = Vec::new();
956        let area = Rect {
957            x: 0.0,
958            y: 0.0,
959            width: 200.0,
960            height: 100.0,
961        };
962        let pages = paginate_body(&children, area, &c, &mut warnings);
963        assert_eq!(
964            pages.len(),
965            2,
966            "oversized element consumes its own page, second Rect starts a fresh one"
967        );
968        assert_eq!(warnings.iter().filter(|w| w.kind == LayoutWarningKind::ForcedPageBreak).count(), 1);
969    }
970
971    // --- Grundprinzip 9: widow/orphan + short-paragraph-never-split -----
972
973    fn line_text(n: usize) -> String {
974        (0..n).map(|i| format!("L{i}")).collect::<Vec<_>>().join("\n")
975    }
976
977    #[test]
978    fn short_paragraph_is_never_split() {
979        // 3 lines < 2*N(=4): must move as a whole even though 2 lines
980        // would technically fit.
981        let text = TextEl::new(line_text(3)).size(10.0).line_height(1.0);
982        let c = ctx();
983        let mut warnings = Vec::new();
984        let area = Rect {
985            x: 0.0,
986            y: 0.0,
987            width: 400.0,
988            height: 20.0, // fits 2 of 3 lines by height alone
989        };
990        match text.layout(&c, area, &mut warnings, 1) {
991            LayoutResult::Split { current, remainder } => {
992                assert!(
993                    matches!(current, RenderNode::Empty),
994                    "short paragraph must move whole, nothing placed on this page"
995                );
996                match remainder {
997                    Element::Text(t) => assert_eq!(t.content, line_text(3)),
998                    other => panic!("expected Text remainder, got {other:?}"),
999                }
1000            }
1001            LayoutResult::Fit(_) => panic!("expected a Split (paragraph doesn't fully fit)"),
1002        }
1003    }
1004
1005    #[test]
1006    fn widow_is_avoided_by_pulling_lines_up() {
1007        // 5 lines, only 4 fit by height -> naive split would leave 1
1008        // (widow). Rule pulls lines up so >= N=2 remain after the break.
1009        let text = TextEl::new(line_text(5)).size(10.0).line_height(1.0);
1010        let c = ctx();
1011        let mut warnings = Vec::new();
1012        let area = Rect {
1013            x: 0.0,
1014            y: 0.0,
1015            width: 400.0,
1016            height: 40.0, // exactly 4 lines at 10pt line-height
1017        };
1018        match text.layout(&c, area, &mut warnings, 1) {
1019            LayoutResult::Split { current, remainder } => {
1020                let RenderNode::Group { children, .. } = current else {
1021                    panic!("expected the clip-wrapping Group");
1022                };
1023                let RenderNode::TextLines { lines, .. } = &children[0] else {
1024                    panic!("expected TextLines");
1025                };
1026                assert_eq!(lines.len(), 3, "must pull one line up so the remainder has >= 2 lines");
1027                match remainder {
1028                    Element::Text(t) => assert_eq!(t.content.split(' ').count(), 2),
1029                    other => panic!("expected Text remainder, got {other:?}"),
1030                }
1031            }
1032            LayoutResult::Fit(_) => panic!("expected a Split"),
1033        }
1034    }
1035
1036    #[test]
1037    fn orphan_moves_whole_paragraph_when_room_is_too_small() {
1038        // 5 lines, only 1 fits by height -> orphan (< N before break) ->
1039        // move the whole paragraph.
1040        let text = TextEl::new(line_text(5)).size(10.0).line_height(1.0);
1041        let c = ctx();
1042        let mut warnings = Vec::new();
1043        let area = Rect {
1044            x: 0.0,
1045            y: 0.0,
1046            width: 400.0,
1047            height: 10.0,
1048        };
1049        match text.layout(&c, area, &mut warnings, 1) {
1050            LayoutResult::Split { current, .. } => {
1051                assert!(matches!(current, RenderNode::Empty));
1052            }
1053            LayoutResult::Fit(_) => panic!("expected a Split"),
1054        }
1055    }
1056
1057    // --- Grundprinzip 9: keep_with_next ----------------------------------
1058
1059    #[test]
1060    fn keep_with_next_moves_heading_along_with_its_body() {
1061        let col = Column::new().gap(0.0).children(vec![
1062            Element::Text(TextEl::new("Filler").size(10.0).line_height(1.0)),
1063            Element::Text(TextEl::new("Heading").size(10.0).line_height(1.0).keep_with_next()),
1064            Element::Text(TextEl::new("Body").size(10.0).line_height(1.0)),
1065        ]);
1066        let c = ctx();
1067        let mut warnings = Vec::new();
1068        // 10 (filler) + 10 (heading) fits, but leaves only 5pt — not
1069        // enough for one more 10pt line of body text.
1070        let area = Rect {
1071            x: 0.0,
1072            y: 0.0,
1073            width: 400.0,
1074            height: 25.0,
1075        };
1076        match col.layout(&c, area, &mut warnings, 1) {
1077            LayoutResult::Split { current, remainder } => {
1078                let RenderNode::Group { children, .. } = current else {
1079                    panic!("expected Group");
1080                };
1081                assert_eq!(children.len(), 1, "only the filler should remain on this page");
1082                match remainder {
1083                    Element::Column(rem) => {
1084                        assert_eq!(rem.children.len(), 2);
1085                        match &rem.children[0] {
1086                            Element::Text(t) => assert_eq!(t.content, "Heading"),
1087                            other => panic!("expected Heading Text, got {other:?}"),
1088                        }
1089                    }
1090                    other => panic!("expected Column remainder, got {other:?}"),
1091                }
1092            }
1093            LayoutResult::Fit(_) => panic!("expected keep_with_next to force a Split before the heading"),
1094        }
1095    }
1096
1097    #[test]
1098    fn overflow_ellipsis_truncates_fixed_single_line_text() {
1099        let text = TextEl::new("AAAAAAAAAAAAAAAA")
1100            .size(10.0)
1101            .line_height(1.0)
1102            .height(10.0)
1103            .overflow(OverflowKind::Ellipsis);
1104        let c = ctx();
1105        let mut warnings = Vec::new();
1106        let area = Rect {
1107            x: 0.0,
1108            y: 0.0,
1109            width: 40.0,
1110            height: 10.0,
1111        };
1112        let result = text.layout(&c, area, &mut warnings, 1);
1113        let LayoutResult::Fit(RenderNode::Group { children, .. }) = result else {
1114            panic!("expected Fit Group (clip wrapper)");
1115        };
1116        let RenderNode::TextLines { lines, .. } = &children[0] else {
1117            panic!("expected TextLines");
1118        };
1119        assert_eq!(lines.len(), 1);
1120        assert!(lines[0].ends_with('…'), "expected an ellipsis, got {:?}", lines[0]);
1121    }
1122
1123    #[test]
1124    fn common_default_is_used() {
1125        // Sanity check that Common::default() means "auto", not zero-sized.
1126        let c = Common::default();
1127        assert_eq!(c.width, None);
1128        assert_eq!(c.height, None);
1129    }
1130}