Skip to main content

lightweight_pdf_layout/
text.rs

1//! Greedy word-boundary wrapping with a hard-break fallback for tokens
2//! wider than the available width (`plan/05-overflow-and-robustness.md`
3//! Grundprinzip 2). No hyphenation.
4
5use crate::font_resolver::FontResolver;
6use lightweight_pdf_core::{FontKey, TextStyle};
7
8pub fn text_width_pt(resolver: &dyn FontResolver, font: FontKey, size: f32, text: &str) -> f32 {
9    let m = resolver.metrics(font);
10    text.chars().map(|c| m.advance(c)).sum::<f32>() / 1000.0 * size
11}
12
13/// `text_width_pt` for a `TextStyle`'s font/size — the `style.font,
14/// style.size` pair otherwise repeats at every measurement call site below.
15fn styled_width_pt(resolver: &dyn FontResolver, style: &TextStyle, text: &str) -> f32 {
16    text_width_pt(resolver, style.font, style.size, text)
17}
18
19/// Splits a single word into pieces that each fit `max_width`, breaking on
20/// character boundaries as a last resort (never truncated, never drawn
21/// past the edge).
22fn hard_break_word(resolver: &dyn FontResolver, style: &TextStyle, word: &str, max_width: f32) -> Vec<String> {
23    let mut pieces = Vec::new();
24    let mut current = String::new();
25    for ch in word.chars() {
26        let mut candidate = current.clone();
27        candidate.push(ch);
28        let w = styled_width_pt(resolver, style, &candidate);
29        if w > max_width && !current.is_empty() {
30            pieces.push(std::mem::take(&mut current));
31        }
32        current.push(ch);
33    }
34    if !current.is_empty() || pieces.is_empty() {
35        pieces.push(current);
36    }
37    pieces
38}
39
40/// Starts a fresh line with `word`: if it fits `max_width` whole, it
41/// becomes the line's only content so far; otherwise it's hard-broken,
42/// with all but the last piece pushed straight into `lines` and the last
43/// piece returned as the new line-in-progress. Shared by both places
44/// `wrap_text` begins a line (the very first word of a paragraph, and the
45/// word right after a line-full break).
46fn start_line(resolver: &dyn FontResolver, style: &TextStyle, word: &str, max_width: f32, lines: &mut Vec<String>) -> String {
47    let w = styled_width_pt(resolver, style, word);
48    if w <= max_width {
49        return word.to_string();
50    }
51    let mut pieces = hard_break_word(resolver, style, word, max_width);
52    // `hard_break_word` always returns at least one piece (it pushes
53    // `current` unconditionally when `pieces` would otherwise be empty),
54    // so popping the last one off can never actually hit the default.
55    let last = pieces.pop().expect("hard_break_word always returns at least one piece");
56    lines.extend(pieces);
57    last
58}
59
60/// Wraps `text` to `max_width` points. Explicit `\n` in the source text
61/// start a new paragraph/line unconditionally.
62pub fn wrap_text(resolver: &dyn FontResolver, style: &TextStyle, text: &str, max_width: f32) -> Vec<String> {
63    let max_width = max_width.max(0.0);
64    let mut lines = Vec::new();
65    for paragraph in text.split('\n') {
66        let words: Vec<&str> = paragraph.split(' ').filter(|w| !w.is_empty()).collect();
67        if words.is_empty() {
68            lines.push(String::new());
69            continue;
70        }
71        let mut current = String::new();
72        for word in words {
73            if current.is_empty() {
74                current = start_line(resolver, style, word, max_width, &mut lines);
75                continue;
76            }
77            let candidate = format!("{current} {word}");
78            let w = styled_width_pt(resolver, style, &candidate);
79            if w <= max_width {
80                current = candidate;
81            } else {
82                lines.push(std::mem::take(&mut current));
83                current = start_line(resolver, style, word, max_width, &mut lines);
84            }
85        }
86        lines.push(current);
87    }
88    lines
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    struct FixedMetrics;
96    impl crate::font_resolver::FontMetrics for FixedMetrics {
97        fn advance(&self, ch: char) -> f32 {
98            if ch == ' ' {
99                300.0
100            } else {
101                600.0
102            }
103        }
104        fn ascent(&self) -> f32 {
105            800.0
106        }
107        fn descent(&self) -> f32 {
108            -200.0
109        }
110    }
111    struct FixedResolver;
112    impl FontResolver for FixedResolver {
113        fn metrics(&self, _key: FontKey) -> &dyn crate::font_resolver::FontMetrics {
114            &FixedMetrics
115        }
116    }
117
118    #[test]
119    fn wraps_on_word_boundaries() {
120        let style = TextStyle {
121            size: 10.0,
122            ..Default::default()
123        };
124        // Each char = 6pt at size 10 (600/1000*10). "AAAA BBBB" at width 30
125        // -> "AAAA" is 24pt, fits; adding " BBBB" would be way over.
126        let lines = wrap_text(&FixedResolver, &style, "AAAA BBBB", 30.0);
127        assert_eq!(lines, vec!["AAAA".to_string(), "BBBB".to_string()]);
128    }
129
130    #[test]
131    fn hard_breaks_a_single_too_wide_token() {
132        let style = TextStyle {
133            size: 10.0,
134            ..Default::default()
135        };
136        // A single 10-char token, each char 6pt, max width 18pt -> 3 chars/line.
137        let lines = wrap_text(&FixedResolver, &style, "ABCDEFGHIJ", 18.0);
138        assert_eq!(lines, vec!["ABC", "DEF", "GHI", "J"]);
139    }
140
141    #[test]
142    fn respects_explicit_newlines() {
143        let style = TextStyle::default();
144        let lines = wrap_text(&FixedResolver, &style, "a\nb", 1000.0);
145        assert_eq!(lines, vec!["a".to_string(), "b".to_string()]);
146    }
147}