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::{hyphenated_content, text_width_pt, wrap_spans, wrap_text, wrap_text_marking_paragraph_ends, RichLine};
9use crate::warnings::{LayoutWarning, LayoutWarningKind};
10use lightweight_pdf_core::{Align, Element, Line, Overflow, Rect as RectElement, Spacer, Span, 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
23/// `wrap_text_marking_paragraph_ends`'s two parallel `Vec`s, bundled so
24/// they travel together as a single parameter through `Text::layout`'s
25/// split/overflow helpers instead of two.
26struct WrappedLines {
27    lines: Vec<String>,
28    paragraph_end: Vec<bool>,
29}
30
31impl WrappedLines {
32    fn len(&self) -> usize {
33        self.lines.len()
34    }
35}
36
37/// `Text`'s link/bookmark-related fields, bundled for the same reason as
38/// `WrappedLines`: one parameter through the split/overflow helpers
39/// instead of four, all cloned together at each `Text::layout` exit
40/// point.
41struct TextLinks {
42    url: Option<String>,
43    anchor: Option<String>,
44    link_to: Option<String>,
45    outline_level: Option<u8>,
46}
47
48impl TextLinks {
49    fn from(text: &Text) -> Self {
50        TextLinks {
51            url: text.url.clone(),
52            anchor: text.anchor.clone(),
53            link_to: text.link_to.clone(),
54            outline_level: text.outline_level,
55        }
56    }
57}
58
59fn text_lines_node(area: Rect, style: TextStyle, wrapped: WrappedLines, lh: f32, links: TextLinks) -> RenderNode {
60    let height = wrapped.lines.len() as f32 * lh;
61    RenderNode::clipped(
62        area,
63        RenderNode::TextLines {
64            area: Rect { height, ..area },
65            style,
66            lines: wrapped.lines,
67            paragraph_end: wrapped.paragraph_end,
68            line_height_pt: lh,
69            url: links.url,
70            anchor: links.anchor,
71            link_to: links.link_to,
72            outline_level: links.outline_level,
73        },
74    )
75}
76
77impl Layoutable for Text {
78    fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
79        let width = self.common.width.unwrap_or(constraints.max_width);
80        if let Some(spans) = &self.spans {
81            let rich_lines = wrap_spans(ctx.resolver, spans, width);
82            let actual_width = rich_lines.iter().map(|l| rich_line_width(ctx.resolver, l)).fold(0.0f32, f32::max);
83            let total_height: f32 = rich_lines.iter().map(|l| l.height).sum();
84            return Size {
85                width: self.common.width.unwrap_or(actual_width.min(width)),
86                height: self.common.height.unwrap_or(total_height),
87            };
88        }
89        let content = hyphenated_content(self);
90        let lines = wrap_text(ctx.resolver, &self.style, &content, width);
91        let lh = line_height_pt(&self.style);
92        let actual_width = lines
93            .iter()
94            .map(|l| text_width_pt(ctx.resolver, self.style.font, self.style.size, l))
95            .fold(0.0f32, f32::max);
96        Size {
97            width: self.common.width.unwrap_or(actual_width.min(width)),
98            height: self.common.height.unwrap_or(lines.len() as f32 * lh),
99        }
100    }
101
102    fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
103        if let Some(spans) = &self.spans {
104            return layout_rich_text(self, spans, ctx, area, warnings, page);
105        }
106        push_missing_glyph_warnings(ctx, &self.style, &self.content, warnings, page);
107        let content = hyphenated_content(self);
108        let (lines, paragraph_end) = wrap_text_marking_paragraph_ends(ctx.resolver, &self.style, &content, area.width);
109        let wrapped = WrappedLines { lines, paragraph_end };
110        let lh = line_height_pt(&self.style);
111        let total_height = wrapped.len() as f32 * lh;
112
113        if total_height <= area.height + EPS || wrapped.len() <= 1 {
114            if total_height > area.height + EPS {
115                push_warning(warnings, LayoutWarningKind::TextClipped, page, text_clipped_hint(&self.content));
116            }
117            return LayoutResult::Fit(text_lines_node(area, self.style, wrapped, lh, TextLinks::from(self)));
118        }
119
120        // An explicit, fixed `.height(...)` means this box's overflow is
121        // governed by the `overflow` property (Grundprinzip 3: Clip/
122        // Ellipsis), not by pagination — it must never turn into a
123        // page-spanning `Split`. Only the ambient, pagination-provided
124        // budget (no explicit height) may split.
125        if self.common.height.is_some() {
126            return LayoutResult::Fit(layout_text_fixed_overflow(self, ctx, area, wrapped, lh, warnings, page));
127        }
128
129        let max_lines_by_height = max_lines_fitting(area.height, lh, wrapped.len());
130
131        let mut split_at = max_lines_by_height;
132        if wrapped.len() < 2 * WIDOW_ORPHAN_N {
133            // Short paragraph: never split, move as a whole.
134            split_at = 0;
135        } else if split_at < WIDOW_ORPHAN_N {
136            // Orphan: too few lines would remain before the break.
137            split_at = 0;
138        } else if wrapped.len() - split_at < WIDOW_ORPHAN_N {
139            // Widow: pull lines up so the remainder has >= N lines.
140            let adjusted = wrapped.len().saturating_sub(WIDOW_ORPHAN_N);
141            split_at = if adjusted >= WIDOW_ORPHAN_N { adjusted } else { 0 };
142        }
143
144        if split_at == 0 {
145            return LayoutResult::Split {
146                current: RenderNode::Empty,
147                remainder: Element::Text(self.clone()),
148            };
149        }
150
151        let (current_lines, remainder_lines) = wrapped.lines.split_at(split_at);
152        let (current_paragraph_end, _) = wrapped.paragraph_end.split_at(split_at);
153        let current = text_lines_node(
154            Rect {
155                height: current_lines.len() as f32 * lh,
156                ..area
157            },
158            self.style,
159            WrappedLines {
160                lines: current_lines.to_vec(),
161                paragraph_end: current_paragraph_end.to_vec(),
162            },
163            lh,
164            TextLinks::from(self),
165        );
166        let remainder_text = remainder_lines.join(" ");
167        let mut remainder = self.clone();
168        remainder.content = remainder_text;
169        // `current` (just above) already carries the original outline
170        // entry — the remainder is a continuation of the same paragraph,
171        // not a second heading, so it must not register its own bookmark.
172        remainder.outline_level = None;
173        LayoutResult::Split {
174            current,
175            remainder: Element::Text(remainder),
176        }
177    }
178}
179
180// ---------------------------------------------------------------------
181// Text::rich(..) (issue #11) — mirrors the plain-text pagination
182// structure above (fit / widow-orphan split / forced-atomic), just with
183// a per-line height (`RichLine::height`, the tallest word's own) instead
184// of one uniform `line_height_pt` for the whole paragraph. No
185// Align::Justify, no url/anchor/link_to/outline_level support (V1 scope,
186// see `Text::spans`' doc comment) and no Ellipsis on a fixed-height box
187// (Clip only) — plain `Text` remains the only way to get those.
188// ---------------------------------------------------------------------
189
190fn rich_line_width(resolver: &dyn crate::font_resolver::FontResolver, line: &RichLine) -> f32 {
191    let mut width = 0.0f32;
192    for (i, word) in line.words.iter().enumerate() {
193        if i > 0 {
194            width += text_width_pt(resolver, word.style.font, word.style.size, " ");
195        }
196        width += text_width_pt(resolver, word.style.font, word.style.size, &word.text);
197    }
198    width
199}
200
201fn rich_text_lines_node(area: Rect, align: Align, lines: Vec<RichLine>) -> RenderNode {
202    let height: f32 = lines.iter().map(|l| l.height).sum();
203    RenderNode::clipped(
204        area,
205        RenderNode::RichTextLines {
206            area: Rect { height, ..area },
207            align,
208            lines,
209        },
210    )
211}
212
213/// Rebuilds a `Vec<Span>` from wrapped lines that didn't fit on the
214/// current page — the `Text::rich(..)` counterpart to plain `Text`'s
215/// `remainder_lines.join(" ")`. Adjacent words with the *same* style
216/// merge into one `Span` (joined by a space); a style change always
217/// starts a new one.
218fn rebuild_spans_from_lines(lines: &[RichLine]) -> Vec<Span> {
219    let mut spans: Vec<Span> = Vec::new();
220    for line in lines {
221        for word in &line.words {
222            match spans.last_mut() {
223                Some(last) if last.style == word.style => {
224                    last.text.push(' ');
225                    last.text.push_str(&word.text);
226                }
227                _ => spans.push(Span::new(word.text.clone(), word.style)),
228            }
229        }
230    }
231    spans
232}
233
234fn layout_rich_text(
235    text: &Text,
236    spans: &[Span],
237    ctx: &LayoutCtx,
238    area: Rect,
239    warnings: &mut Vec<LayoutWarning>,
240    page: usize,
241) -> LayoutResult {
242    for span in spans {
243        push_missing_glyph_warnings(ctx, &span.style, &span.text, warnings, page);
244    }
245    let rich_lines = wrap_spans(ctx.resolver, spans, area.width);
246    let heights: Vec<f32> = rich_lines.iter().map(|l| l.height).collect();
247    let total_height: f32 = heights.iter().sum();
248    let n = rich_lines.len();
249
250    if total_height <= area.height + EPS || n <= 1 {
251        if total_height > area.height + EPS {
252            push_warning(warnings, LayoutWarningKind::TextClipped, page, text_clipped_hint(&text.content));
253        }
254        return LayoutResult::Fit(rich_text_lines_node(area, text.style.align, rich_lines));
255    }
256
257    if text.common.height.is_some() {
258        // No Ellipsis for rich text (V1 scope) — Clip only: keep as many
259        // whole lines as fit, same "never split a fixed-height box"
260        // invariant as plain Text's layout_text_fixed_overflow.
261        let mut kept = Vec::new();
262        let mut acc = 0.0f32;
263        for line in rich_lines {
264            if acc + line.height > area.height + EPS && !kept.is_empty() {
265                break;
266            }
267            acc += line.height;
268            kept.push(line);
269        }
270        if kept.len() < n {
271            push_warning(warnings, LayoutWarningKind::TextClipped, page, text_clipped_hint(&text.content));
272        }
273        return LayoutResult::Fit(rich_text_lines_node(area, text.style.align, kept));
274    }
275
276    // Same widow/orphan rule as plain text (count-based, height-agnostic)
277    // — only the fit budget itself (a running height sum instead of
278    // `count * uniform_height`) differs.
279    let mut split_at = 0;
280    let mut acc = 0.0f32;
281    for (i, h) in heights.iter().enumerate() {
282        if acc + h > area.height + EPS {
283            break;
284        }
285        acc += h;
286        split_at = i + 1;
287    }
288    if n < 2 * WIDOW_ORPHAN_N || split_at < WIDOW_ORPHAN_N {
289        // Short paragraph, or an orphan (too few lines before the break).
290        split_at = 0;
291    } else if n - split_at < WIDOW_ORPHAN_N {
292        let adjusted = n.saturating_sub(WIDOW_ORPHAN_N);
293        split_at = if adjusted >= WIDOW_ORPHAN_N { adjusted } else { 0 };
294    }
295
296    if split_at == 0 {
297        return LayoutResult::Split {
298            current: RenderNode::Empty,
299            remainder: Element::Text(text.clone()),
300        };
301    }
302
303    let (current_lines, remainder_lines) = rich_lines.split_at(split_at);
304    let current_height: f32 = current_lines.iter().map(|l| l.height).sum();
305    let current = rich_text_lines_node(
306        Rect {
307            height: current_height,
308            ..area
309        },
310        text.style.align,
311        current_lines.to_vec(),
312    );
313
314    let mut remainder = text.clone();
315    remainder.spans = Some(Box::new(rebuild_spans_from_lines(remainder_lines)));
316    LayoutResult::Split {
317        current,
318        remainder: Element::Text(remainder),
319    }
320}
321
322/// Overflow handling for an explicitly, fixed-size text box (Grundprinzip
323/// 3): `Clip` drops lines that don't fit, `Ellipsis` truncates the last
324/// visible line with a trailing "…" (single-line use case: a long label in
325/// a narrow, fixed column). Free function (not an inherent impl) because
326/// `Text` is defined in `lightweight-pdf-core`, outside this crate.
327fn layout_text_fixed_overflow(
328    text: &Text,
329    ctx: &LayoutCtx,
330    area: Rect,
331    wrapped: WrappedLines,
332    lh: f32,
333    warnings: &mut Vec<LayoutWarning>,
334    page: usize,
335) -> RenderNode {
336    let max_lines = max_lines_fitting(area.height, lh, wrapped.len());
337    if max_lines >= wrapped.len() {
338        return text_lines_node(area, text.style, wrapped, lh, TextLinks::from(text));
339    }
340    push_warning(warnings, LayoutWarningKind::TextClipped, page, text_clipped_hint(&text.content));
341    let take = if text.common.overflow == Overflow::Ellipsis {
342        max_lines.max(1).min(wrapped.len())
343    } else {
344        max_lines
345    };
346    let mut kept: Vec<String> = wrapped.lines.into_iter().take(take).collect();
347    let mut kept_paragraph_end: Vec<bool> = wrapped.paragraph_end.into_iter().take(take).collect();
348    if text.common.overflow == Overflow::Ellipsis {
349        if let Some(last) = kept.last_mut() {
350            *last = fit_with_ellipsis(ctx, &text.style, last, area.width);
351        }
352        // An ellipsis-truncated line is never stretched, regardless of
353        // whether it happened to be its paragraph's real last line.
354        if let Some(last) = kept_paragraph_end.last_mut() {
355            *last = true;
356        }
357    }
358    text_lines_node(
359        area,
360        text.style,
361        WrappedLines {
362            lines: kept,
363            paragraph_end: kept_paragraph_end,
364        },
365        lh,
366        TextLinks::from(text),
367    )
368}
369
370/// Trims `line` character by character (from the end) until `line + "…"`
371/// fits `max_width`, then appends the ellipsis.
372fn fit_with_ellipsis(ctx: &LayoutCtx, style: &TextStyle, line: &str, max_width: f32) -> String {
373    let mut chars: Vec<char> = line.chars().collect();
374    loop {
375        let candidate: String = chars.iter().collect::<String>() + "…";
376        if text_width_pt(ctx.resolver, style.font, style.size, &candidate) <= max_width || chars.is_empty() {
377            return candidate;
378        }
379        chars.pop();
380    }
381}
382
383/// The `element_hint` used for both `Text::layout`'s and
384/// `layout_text_fixed_overflow`'s `TextClipped` warning.
385/// Emits `LayoutWarningKind::MissingGlyph` for every character in `content`
386/// the resolved font has no glyph for — deduplicated per (`ch`, `font`)
387/// against everything already in `warnings`, not per occurrence (a
388/// document-wide repeated character/font miss would otherwise drown the
389/// diagnosis in noise).
390fn push_missing_glyph_warnings(ctx: &LayoutCtx, style: &TextStyle, content: &str, warnings: &mut Vec<LayoutWarning>, page: usize) {
391    let metrics = ctx.resolver.metrics(style.font);
392    for ch in content.chars() {
393        if ch.is_whitespace() || metrics.has_glyph(ch) {
394            continue;
395        }
396        let kind = LayoutWarningKind::MissingGlyph { ch, font: style.font };
397        if !warnings.iter().any(|w| w.kind == kind) {
398            push_warning(warnings, kind, page, format!("missing glyph for {ch:?}"));
399        }
400    }
401}
402
403fn text_clipped_hint(content: &str) -> String {
404    format!("Text \"{}\"", truncate_hint(content))
405}
406
407fn truncate_hint(s: &str) -> String {
408    if s.len() > 24 {
409        format!("{}…", &s[..24])
410    } else {
411        s.to_string()
412    }
413}
414
415// ---------------------------------------------------------------------
416// Spacer (special-cased by Row/Column before generic dispatch — the axis
417// a Spacer consumes depends on its parent, which a standalone measure/
418// layout call cannot know).
419// ---------------------------------------------------------------------
420
421impl Layoutable for Spacer {
422    fn measure(&self, _ctx: &LayoutCtx, _constraints: Constraints) -> Size {
423        Size {
424            width: self.size,
425            height: self.size,
426        }
427    }
428
429    fn layout(&self, _ctx: &LayoutCtx, _area: Rect, _warnings: &mut Vec<LayoutWarning>, _page: usize) -> LayoutResult {
430        LayoutResult::Fit(RenderNode::Empty)
431    }
432}
433
434// ---------------------------------------------------------------------
435// Line
436// ---------------------------------------------------------------------
437
438impl Layoutable for Line {
439    fn measure(&self, _ctx: &LayoutCtx, constraints: Constraints) -> Size {
440        size_with_defaults(&self.common, constraints, self.thickness)
441    }
442
443    fn layout(&self, _ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
444        if self.thickness > area.height + EPS {
445            push_warning(warnings, LayoutWarningKind::ContentOverflow, page, "Line");
446        }
447        let y_mid = area.y + (self.thickness / 2.0).min(area.height);
448        let node = RenderNode::Line {
449            x1: area.x,
450            y1: y_mid,
451            x2: area.x + area.width,
452            y2: y_mid,
453            thickness: self.thickness,
454            color: self.color,
455        };
456        LayoutResult::Fit(RenderNode::clipped(area, node))
457    }
458}
459
460// ---------------------------------------------------------------------
461// Rect
462// ---------------------------------------------------------------------
463
464impl Layoutable for RectElement {
465    fn measure(&self, _ctx: &LayoutCtx, constraints: Constraints) -> Size {
466        size_with_defaults(&self.common, constraints, 0.0)
467    }
468
469    fn layout(&self, _ctx: &LayoutCtx, area: Rect, _warnings: &mut Vec<LayoutWarning>, _page: usize) -> LayoutResult {
470        let node = RenderNode::Rect {
471            area,
472            background: self.common.background,
473            border: self.common.border,
474            corner_radius: self.common.corner_radius,
475        };
476        LayoutResult::Fit(RenderNode::clipped(area, node))
477    }
478}