Skip to main content

lightweight_pdf_layout/layoutable/
leaf.rs

1//! Leaf `Layoutable` impls: `Text`, `Spacer`, `Line`, `Rect`. None of these
2//! have children of their own to recurse into.
3
4use super::shared::{line_height_pt, push_warning, size_with_defaults, EPS};
5use super::{LayoutCtx, LayoutResult, Layoutable};
6use crate::geometry::{Constraints, Rect, Size};
7use crate::render_node::RenderNode;
8use crate::text::{text_width_pt, wrap_text};
9use crate::warnings::{LayoutWarning, LayoutWarningKind};
10use lightweight_pdf_core::{Element, Line, Overflow, Rect as RectElement, Spacer, Text, TextStyle};
11
12/// Threshold for the widow/orphan rule (Grundprinzip 9): a paragraph is
13/// never split leaving fewer than `N` lines on either side of the break.
14const WIDOW_ORPHAN_N: usize = 2;
15
16/// How many lines of height `lh` fit within `area_height` (never more than
17/// `available`) — the line-count budget shared by `Text::layout`'s
18/// pagination split point and `layout_text_fixed_overflow`'s clip point.
19fn max_lines_fitting(area_height: f32, lh: f32, available: usize) -> usize {
20    (((area_height + EPS) / lh).floor().max(0.0) as usize).min(available)
21}
22
23fn text_lines_node(area: Rect, style: TextStyle, lines: Vec<String>, lh: f32) -> RenderNode {
24    let height = lines.len() as f32 * lh;
25    RenderNode::clipped(
26        area,
27        RenderNode::TextLines {
28            area: Rect { height, ..area },
29            style,
30            lines,
31            line_height_pt: lh,
32        },
33    )
34}
35
36impl Layoutable for Text {
37    fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
38        let width = self.common.width.unwrap_or(constraints.max_width);
39        let lines = wrap_text(ctx.resolver, &self.style, &self.content, width);
40        let lh = line_height_pt(&self.style);
41        let actual_width = lines
42            .iter()
43            .map(|l| text_width_pt(ctx.resolver, self.style.font, self.style.size, l))
44            .fold(0.0f32, f32::max);
45        Size {
46            width: self.common.width.unwrap_or(actual_width.min(width)),
47            height: self.common.height.unwrap_or(lines.len() as f32 * lh),
48        }
49    }
50
51    fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
52        let lines = wrap_text(ctx.resolver, &self.style, &self.content, area.width);
53        let lh = line_height_pt(&self.style);
54        let total_height = lines.len() as f32 * lh;
55
56        if total_height <= area.height + EPS || lines.len() <= 1 {
57            if total_height > area.height + EPS {
58                push_warning(warnings, LayoutWarningKind::TextClipped, page, text_clipped_hint(&self.content));
59            }
60            return LayoutResult::Fit(text_lines_node(area, self.style, lines, lh));
61        }
62
63        // An explicit, fixed `.height(...)` means this box's overflow is
64        // governed by the `overflow` property (Grundprinzip 3: Clip/
65        // Ellipsis), not by pagination — it must never turn into a
66        // page-spanning `Split`. Only the ambient, pagination-provided
67        // budget (no explicit height) may split.
68        if self.common.height.is_some() {
69            return LayoutResult::Fit(layout_text_fixed_overflow(self, ctx, area, lines, lh, warnings, page));
70        }
71
72        let max_lines_by_height = max_lines_fitting(area.height, lh, lines.len());
73
74        let mut split_at = max_lines_by_height;
75        if lines.len() < 2 * WIDOW_ORPHAN_N {
76            // Short paragraph: never split, move as a whole.
77            split_at = 0;
78        } else if split_at < WIDOW_ORPHAN_N {
79            // Orphan: too few lines would remain before the break.
80            split_at = 0;
81        } else if lines.len() - split_at < WIDOW_ORPHAN_N {
82            // Widow: pull lines up so the remainder has >= N lines.
83            let adjusted = lines.len().saturating_sub(WIDOW_ORPHAN_N);
84            split_at = if adjusted >= WIDOW_ORPHAN_N { adjusted } else { 0 };
85        }
86
87        if split_at == 0 {
88            return LayoutResult::Split {
89                current: RenderNode::Empty,
90                remainder: Element::Text(self.clone()),
91            };
92        }
93
94        let (current_lines, remainder_lines) = lines.split_at(split_at);
95        let current = text_lines_node(
96            Rect {
97                height: current_lines.len() as f32 * lh,
98                ..area
99            },
100            self.style,
101            current_lines.to_vec(),
102            lh,
103        );
104        let remainder_text = remainder_lines.join(" ");
105        let mut remainder = self.clone();
106        remainder.content = remainder_text;
107        LayoutResult::Split {
108            current,
109            remainder: Element::Text(remainder),
110        }
111    }
112}
113
114/// Overflow handling for an explicitly, fixed-size text box (Grundprinzip
115/// 3): `Clip` drops lines that don't fit, `Ellipsis` truncates the last
116/// visible line with a trailing "…" (single-line use case: a long label in
117/// a narrow, fixed column). Free function (not an inherent impl) because
118/// `Text` is defined in `lightweight-pdf-core`, outside this crate.
119fn layout_text_fixed_overflow(
120    text: &Text,
121    ctx: &LayoutCtx,
122    area: Rect,
123    lines: Vec<String>,
124    lh: f32,
125    warnings: &mut Vec<LayoutWarning>,
126    page: usize,
127) -> RenderNode {
128    let max_lines = max_lines_fitting(area.height, lh, lines.len());
129    if max_lines >= lines.len() {
130        return text_lines_node(area, text.style, lines, lh);
131    }
132    push_warning(warnings, LayoutWarningKind::TextClipped, page, text_clipped_hint(&text.content));
133    let take = if text.common.overflow == Overflow::Ellipsis {
134        max_lines.max(1).min(lines.len())
135    } else {
136        max_lines
137    };
138    let mut kept: Vec<String> = lines.into_iter().take(take).collect();
139    if text.common.overflow == Overflow::Ellipsis {
140        if let Some(last) = kept.last_mut() {
141            *last = fit_with_ellipsis(ctx, &text.style, last, area.width);
142        }
143    }
144    text_lines_node(area, text.style, kept, lh)
145}
146
147/// Trims `line` character by character (from the end) until `line + "…"`
148/// fits `max_width`, then appends the ellipsis.
149fn fit_with_ellipsis(ctx: &LayoutCtx, style: &TextStyle, line: &str, max_width: f32) -> String {
150    let mut chars: Vec<char> = line.chars().collect();
151    loop {
152        let candidate: String = chars.iter().collect::<String>() + "…";
153        if text_width_pt(ctx.resolver, style.font, style.size, &candidate) <= max_width || chars.is_empty() {
154            return candidate;
155        }
156        chars.pop();
157    }
158}
159
160/// The `element_hint` used for both `Text::layout`'s and
161/// `layout_text_fixed_overflow`'s `TextClipped` warning.
162fn text_clipped_hint(content: &str) -> String {
163    format!("Text \"{}\"", truncate_hint(content))
164}
165
166fn truncate_hint(s: &str) -> String {
167    if s.len() > 24 {
168        format!("{}…", &s[..24])
169    } else {
170        s.to_string()
171    }
172}
173
174// ---------------------------------------------------------------------
175// Spacer (special-cased by Row/Column before generic dispatch — the axis
176// a Spacer consumes depends on its parent, which a standalone measure/
177// layout call cannot know).
178// ---------------------------------------------------------------------
179
180impl Layoutable for Spacer {
181    fn measure(&self, _ctx: &LayoutCtx, _constraints: Constraints) -> Size {
182        Size {
183            width: self.size,
184            height: self.size,
185        }
186    }
187
188    fn layout(&self, _ctx: &LayoutCtx, _area: Rect, _warnings: &mut Vec<LayoutWarning>, _page: usize) -> LayoutResult {
189        LayoutResult::Fit(RenderNode::Empty)
190    }
191}
192
193// ---------------------------------------------------------------------
194// Line
195// ---------------------------------------------------------------------
196
197impl Layoutable for Line {
198    fn measure(&self, _ctx: &LayoutCtx, constraints: Constraints) -> Size {
199        size_with_defaults(&self.common, constraints, self.thickness)
200    }
201
202    fn layout(&self, _ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
203        if self.thickness > area.height + EPS {
204            push_warning(warnings, LayoutWarningKind::ContentOverflow, page, "Line");
205        }
206        let y_mid = area.y + (self.thickness / 2.0).min(area.height);
207        let node = RenderNode::Line {
208            x1: area.x,
209            y1: y_mid,
210            x2: area.x + area.width,
211            y2: y_mid,
212            thickness: self.thickness,
213            color: self.color,
214        };
215        LayoutResult::Fit(RenderNode::clipped(area, node))
216    }
217}
218
219// ---------------------------------------------------------------------
220// Rect
221// ---------------------------------------------------------------------
222
223impl Layoutable for RectElement {
224    fn measure(&self, _ctx: &LayoutCtx, constraints: Constraints) -> Size {
225        size_with_defaults(&self.common, constraints, 0.0)
226    }
227
228    fn layout(&self, _ctx: &LayoutCtx, area: Rect, _warnings: &mut Vec<LayoutWarning>, _page: usize) -> LayoutResult {
229        let node = RenderNode::Rect {
230            area,
231            background: self.common.background,
232            border: self.common.border,
233        };
234        LayoutResult::Fit(RenderNode::clipped(area, node))
235    }
236}