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), plus soft-hyphen-aware breaking (issue #13, Stage 1):
4//! a word may carry U+00AD marks for where it's allowed to break — used
5//! if wrapping needs it, stripped invisibly if not. Stage 2 (automatic,
6//! dictionary-driven hyphenation) lives in the feature-gated `hyphenate`
7//! module and works by inserting these same U+00AD marks before a word
8//! ever reaches this file.
9
10use crate::font_resolver::FontResolver;
11use lightweight_pdf_core::{FontKey, Span, Text, TextStyle};
12use std::borrow::Cow;
13use std::collections::VecDeque;
14
15/// Marks an optional break point within a word: rendered as a visible
16/// `-` if a line actually breaks there, invisible (stripped) otherwise.
17const SOFT_HYPHEN: char = '\u{00AD}';
18
19/// `text.hyphenate` (issue #13, Stage 2), applied if set and the
20/// `hyphenation` feature is compiled in. Without the feature, a `Text`
21/// with `.hyphenate(..)` set falls back to Stage 1 only (whatever soft
22/// hyphens the author placed by hand) — documented on `Text::hyphenate`
23/// itself, since skipping automatic hyphenation only changes where a
24/// line wraps, never what the text says.
25pub fn hyphenated_content(text: &Text) -> Cow<'_, str> {
26    #[cfg(feature = "hyphenation")]
27    if let Some(lang) = text.hyphenate {
28        return Cow::Owned(crate::hyphenate::auto_hyphenate(&text.content, lang));
29    }
30    Cow::Borrowed(&text.content)
31}
32
33pub fn text_width_pt(resolver: &dyn FontResolver, font: FontKey, size: f32, text: &str) -> f32 {
34    let m = resolver.metrics(font);
35    text.chars().map(|c| m.advance(c)).sum::<f32>() / 1000.0 * size
36}
37
38/// `text_width_pt` for a `TextStyle`'s font/size — the `style.font,
39/// style.size` pair otherwise repeats at every measurement call site below.
40fn styled_width_pt(resolver: &dyn FontResolver, style: &TextStyle, text: &str) -> f32 {
41    text_width_pt(resolver, style.font, style.size, text)
42}
43
44/// Splits a single word into pieces that each fit `max_width`, breaking on
45/// character boundaries as a last resort (never truncated, never drawn
46/// past the edge).
47fn hard_break_word(resolver: &dyn FontResolver, style: &TextStyle, word: &str, max_width: f32) -> Vec<String> {
48    let mut pieces = Vec::new();
49    let mut current = String::new();
50    for ch in word.chars() {
51        let mut candidate = current.clone();
52        candidate.push(ch);
53        let w = styled_width_pt(resolver, style, &candidate);
54        if w > max_width && !current.is_empty() {
55            pieces.push(std::mem::take(&mut current));
56        }
57        current.push(ch);
58    }
59    if !current.is_empty() || pieces.is_empty() {
60        pieces.push(current);
61    }
62    pieces
63}
64
65/// `word`, with every soft hyphen (U+00AD) removed — used whenever a word
66/// is placed without breaking at one of them, since an unused soft hyphen
67/// must never show up in the rendered/extracted text.
68fn strip_soft_hyphens(word: &str) -> String {
69    word.chars().filter(|&c| c != SOFT_HYPHEN).collect()
70}
71
72/// Finds the soft hyphen in `word` that lets the largest possible prefix
73/// (rendered with a trailing visible `-`) fit within `max_width`, and
74/// returns `(prefix_with_hyphen, rest_of_word)` — `rest_of_word` keeps
75/// its own remaining soft hyphens, in case it needs breaking again.
76/// `None` if `word` has no soft hyphen, or not even its first segment
77/// plus a hyphen fits.
78fn break_at_soft_hyphen(resolver: &dyn FontResolver, style: &TextStyle, word: &str, max_width: f32) -> Option<(String, String)> {
79    if !word.contains(SOFT_HYPHEN) {
80        return None;
81    }
82    let segments: Vec<&str> = word.split(SOFT_HYPHEN).collect();
83    for split_at in (1..segments.len()).rev() {
84        let candidate = format!("{}-", segments[..split_at].concat());
85        if styled_width_pt(resolver, style, &candidate) <= max_width {
86            let rest = segments[split_at..].join("\u{00AD}");
87            return Some((candidate, rest));
88        }
89    }
90    None
91}
92
93/// Starts a fresh line with `word` (which may still carry soft hyphens):
94/// if it fits `max_width` whole, it becomes the line's only content so
95/// far (soft hyphens stripped, unused); otherwise it's broken — at a
96/// soft hyphen if one lets a prefix fit, falling back to a character-level
97/// hard break otherwise — with all but the last piece pushed straight
98/// into `lines` and the last piece (or, for a soft-hyphen break, the
99/// requeued remainder) becoming the new line-in-progress. Shared by both
100/// places `wrap_text` begins a line (the very first word of a paragraph,
101/// and the word right after a line-full break).
102fn start_line(
103    resolver: &dyn FontResolver,
104    style: &TextStyle,
105    word: &str,
106    max_width: f32,
107    lines: &mut Vec<String>,
108    queue: &mut VecDeque<String>,
109) -> String {
110    let stripped = strip_soft_hyphens(word);
111    let w = styled_width_pt(resolver, style, &stripped);
112    if w <= max_width {
113        return stripped;
114    }
115    if let Some((prefix, rest)) = break_at_soft_hyphen(resolver, style, word, max_width) {
116        lines.push(prefix);
117        queue.push_front(rest);
118        return String::new();
119    }
120    let mut pieces = hard_break_word(resolver, style, &stripped, max_width);
121    // `hard_break_word` always returns at least one piece (it pushes
122    // `current` unconditionally when `pieces` would otherwise be empty),
123    // so popping the last one off can never actually hit the default.
124    let last = pieces.pop().expect("hard_break_word always returns at least one piece");
125    lines.extend(pieces);
126    last
127}
128
129/// Wraps `text` to `max_width` points. Explicit `\n` in the source text
130/// start a new paragraph/line unconditionally.
131pub fn wrap_text(resolver: &dyn FontResolver, style: &TextStyle, text: &str, max_width: f32) -> Vec<String> {
132    wrap_text_marking_paragraph_ends(resolver, style, text, max_width).0
133}
134
135/// `wrap_text`, plus a same-length `bool` per line: `true` for the last
136/// line of its paragraph (the one a `Justify` renderer must leave
137/// left-aligned, not stretched), `false` for every other line. A
138/// paragraph is a `\n`-separated segment of `text`, same boundary
139/// `wrap_text` already breaks on.
140pub fn wrap_text_marking_paragraph_ends(
141    resolver: &dyn FontResolver,
142    style: &TextStyle,
143    text: &str,
144    max_width: f32,
145) -> (Vec<String>, Vec<bool>) {
146    let max_width = max_width.max(0.0);
147    let mut lines = Vec::new();
148    let mut paragraph_end = Vec::new();
149    for paragraph in text.split('\n') {
150        let mut queue: VecDeque<String> = paragraph.split(' ').filter(|w| !w.is_empty()).map(str::to_string).collect();
151        if queue.is_empty() {
152            lines.push(String::new());
153        } else {
154            let mut current = String::new();
155            while let Some(word) = queue.pop_front() {
156                if current.is_empty() {
157                    current = start_line(resolver, style, &word, max_width, &mut lines, &mut queue);
158                    continue;
159                }
160                let stripped = strip_soft_hyphens(&word);
161                let candidate = format!("{current} {stripped}");
162                if styled_width_pt(resolver, style, &candidate) <= max_width {
163                    current = candidate;
164                    continue;
165                }
166                // Doesn't fit appended whole — try breaking `word` at a
167                // soft hyphen to fill the current line's remaining space
168                // instead of deferring it whole to the next line (the
169                // narrow-column "große Löcher" case issue #13 is about).
170                let space_w = styled_width_pt(resolver, style, " ");
171                let remaining = (max_width - styled_width_pt(resolver, style, &current) - space_w).max(0.0);
172                if let Some((prefix, rest)) = break_at_soft_hyphen(resolver, style, &word, remaining) {
173                    lines.push(format!("{current} {prefix}"));
174                    current = String::new();
175                    queue.push_front(rest);
176                } else {
177                    lines.push(std::mem::take(&mut current));
178                    queue.push_front(word);
179                }
180            }
181            lines.push(current);
182        }
183        // Every line just pushed for this paragraph (including any
184        // hard-break pieces `start_line` pushed directly) defaults to
185        // `false`; only the last one — the paragraph's actual last line —
186        // flips to `true`.
187        paragraph_end.resize(lines.len(), false);
188        if let Some(last) = paragraph_end.last_mut() {
189            *last = true;
190        }
191    }
192    (lines, paragraph_end)
193}
194
195// ---------------------------------------------------------------------
196// Inline spans (`Text::rich(..)`, issue #11): the same greedy
197// word-boundary wrapping as `wrap_text` above, generalized to a sequence
198// of independently-styled words instead of one style for the whole
199// paragraph. No `\n` paragraph-break support here (unlike `wrap_text`) —
200// rich text is scoped to a single paragraph in V1; plain `Text` remains
201// the way to get multi-paragraph text.
202// ---------------------------------------------------------------------
203
204/// One word plus the style it should be drawn in — the atomic unit
205/// `wrap_spans` arranges into `RichLine`s.
206#[derive(Clone, Debug)]
207pub struct StyledWord {
208    pub text: String,
209    pub style: TextStyle,
210}
211
212/// One wrapped line of a `Text::rich(..)` paragraph. `height` is
213/// `size * line_height` of the line's *tallest* word (not a fixed,
214/// whole-paragraph value like plain `Text`'s `line_height_pt`);
215/// `ascent_pt` is that same tallest word's ascent, in points — every word
216/// on the line shares this one baseline reference regardless of its own
217/// size, which is what keeps mixed sizes visually aligned on one baseline
218/// instead of each hanging from its own.
219#[derive(Clone, Debug)]
220pub struct RichLine {
221    pub words: Vec<StyledWord>,
222    pub height: f32,
223    pub ascent_pt: f32,
224}
225
226/// Splits a single (already-styled) word into pieces that each fit
227/// `max_width`, character by character — `hard_break_word`'s generalization
228/// to an arbitrary style instead of a shared paragraph `TextStyle`.
229fn hard_break_styled_word(resolver: &dyn FontResolver, style: &TextStyle, word: &str, max_width: f32) -> Vec<String> {
230    let mut pieces = Vec::new();
231    let mut current = String::new();
232    for ch in word.chars() {
233        let mut candidate = current.clone();
234        candidate.push(ch);
235        if text_width_pt(resolver, style.font, style.size, &candidate) > max_width && !current.is_empty() {
236            pieces.push(std::mem::take(&mut current));
237        }
238        current.push(ch);
239    }
240    if !current.is_empty() || pieces.is_empty() {
241        pieces.push(current);
242    }
243    pieces
244}
245
246pub fn wrap_spans(resolver: &dyn FontResolver, spans: &[Span], max_width: f32) -> Vec<RichLine> {
247    let max_width = max_width.max(0.0);
248
249    let mut tokens: Vec<(String, TextStyle)> = Vec::new();
250    for span in spans {
251        for word in span.text.split(' ').filter(|w| !w.is_empty()) {
252            tokens.push((word.to_string(), span.style));
253        }
254    }
255
256    let mut lines: Vec<Vec<(String, TextStyle)>> = Vec::new();
257    let mut current: Vec<(String, TextStyle)> = Vec::new();
258    let mut current_width = 0.0f32;
259
260    for (word, style) in tokens {
261        let word_width = text_width_pt(resolver, style.font, style.size, &word);
262        let gap = if current.is_empty() {
263            0.0
264        } else {
265            text_width_pt(resolver, style.font, style.size, " ")
266        };
267
268        if !current.is_empty() && current_width + gap + word_width > max_width {
269            lines.push(std::mem::take(&mut current));
270            current_width = 0.0;
271        }
272
273        if word_width > max_width && current.is_empty() {
274            let pieces = hard_break_styled_word(resolver, &style, &word, max_width);
275            let last_idx = pieces.len().saturating_sub(1);
276            for (i, piece) in pieces.into_iter().enumerate() {
277                if i == last_idx {
278                    current_width = text_width_pt(resolver, style.font, style.size, &piece);
279                    current.push((piece, style));
280                } else {
281                    lines.push(vec![(piece, style)]);
282                }
283            }
284            continue;
285        }
286
287        let gap = if current.is_empty() {
288            0.0
289        } else {
290            text_width_pt(resolver, style.font, style.size, " ")
291        };
292        current_width += gap + word_width;
293        current.push((word, style));
294    }
295    if !current.is_empty() || lines.is_empty() {
296        lines.push(current);
297    }
298
299    let fallback_style = spans.first().map(|s| s.style).unwrap_or_default();
300    lines
301        .into_iter()
302        .map(|words| {
303            let (height, ascent_pt) = words
304                .iter()
305                .map(|(_, style)| *style)
306                .fold(None, |acc: Option<(f32, f32)>, style| {
307                    let m = resolver.metrics(style.font);
308                    let line_h = style.size * style.line_height;
309                    let ascent = m.ascent() / 1000.0 * style.size;
310                    Some(match acc {
311                        Some((h, a)) => (h.max(line_h), a.max(ascent)),
312                        None => (line_h, ascent),
313                    })
314                })
315                .unwrap_or_else(|| {
316                    let m = resolver.metrics(fallback_style.font);
317                    (
318                        fallback_style.size * fallback_style.line_height,
319                        m.ascent() / 1000.0 * fallback_style.size,
320                    )
321                });
322            RichLine {
323                words: words.into_iter().map(|(text, style)| StyledWord { text, style }).collect(),
324                height,
325                ascent_pt,
326            }
327        })
328        .collect()
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    struct FixedMetrics;
336    impl crate::font_resolver::FontMetrics for FixedMetrics {
337        fn advance(&self, ch: char) -> f32 {
338            if ch == ' ' {
339                300.0
340            } else {
341                600.0
342            }
343        }
344        fn ascent(&self) -> f32 {
345            800.0
346        }
347        fn descent(&self) -> f32 {
348            -200.0
349        }
350    }
351    struct FixedResolver;
352    impl FontResolver for FixedResolver {
353        fn metrics(&self, _key: FontKey) -> &dyn crate::font_resolver::FontMetrics {
354            &FixedMetrics
355        }
356    }
357
358    #[test]
359    fn wraps_on_word_boundaries() {
360        let style = TextStyle {
361            size: 10.0,
362            ..Default::default()
363        };
364        // Each char = 6pt at size 10 (600/1000*10). "AAAA BBBB" at width 30
365        // -> "AAAA" is 24pt, fits; adding " BBBB" would be way over.
366        let lines = wrap_text(&FixedResolver, &style, "AAAA BBBB", 30.0);
367        assert_eq!(lines, vec!["AAAA".to_string(), "BBBB".to_string()]);
368    }
369
370    #[test]
371    fn hard_breaks_a_single_too_wide_token() {
372        let style = TextStyle {
373            size: 10.0,
374            ..Default::default()
375        };
376        // A single 10-char token, each char 6pt, max width 18pt -> 3 chars/line.
377        let lines = wrap_text(&FixedResolver, &style, "ABCDEFGHIJ", 18.0);
378        assert_eq!(lines, vec!["ABC", "DEF", "GHI", "J"]);
379    }
380
381    #[test]
382    fn respects_explicit_newlines() {
383        let style = TextStyle::default();
384        let lines = wrap_text(&FixedResolver, &style, "a\nb", 1000.0);
385        assert_eq!(lines, vec!["a".to_string(), "b".to_string()]);
386    }
387
388    #[test]
389    fn soft_hyphen_breaks_a_word_and_renders_a_visible_hyphen() {
390        let style = TextStyle {
391            size: 10.0,
392            ..Default::default()
393        };
394        // "AAAABBBB" is 48pt whole; "AAAA-" is exactly 30pt, fits max_width.
395        let lines = wrap_text(&FixedResolver, &style, "AAAA\u{AD}BBBB", 30.0);
396        assert_eq!(lines, vec!["AAAA-".to_string(), "BBBB".to_string()]);
397    }
398
399    #[test]
400    fn unused_soft_hyphen_disappears_from_the_output() {
401        let style = TextStyle::default();
402        // Plenty of width: the word never needs to break, so its soft
403        // hyphen must not survive into the wrapped line.
404        let lines = wrap_text(&FixedResolver, &style, "AB\u{AD}CD", 1000.0);
405        assert_eq!(lines, vec!["ABCD".to_string()]);
406    }
407
408    #[test]
409    fn soft_hyphen_fills_the_current_line_instead_of_moving_the_whole_word_down() {
410        let style = TextStyle {
411            size: 10.0,
412            ..Default::default()
413        };
414        // "X" (6pt) + " " (3pt) + "AAAABBBB" (48pt) = 57pt, over max_width
415        // 39; but "X AAAA-" is exactly 39pt, so the hyphenated prefix
416        // stays on the first line instead of a ragged gap.
417        let lines = wrap_text(&FixedResolver, &style, "X AAAA\u{AD}BBBB", 39.0);
418        assert_eq!(lines, vec!["X AAAA-".to_string(), "BBBB".to_string()]);
419    }
420
421    #[test]
422    fn marks_only_the_last_line_of_each_paragraph() {
423        let style = TextStyle {
424            size: 10.0,
425            ..Default::default()
426        };
427        // Two paragraphs: "AAAA BBBB" wraps to 2 lines at width 30, "CCCC"
428        // fits on one line by itself.
429        let (lines, paragraph_end) = wrap_text_marking_paragraph_ends(&FixedResolver, &style, "AAAA BBBB\nCCCC", 30.0);
430        assert_eq!(lines, vec!["AAAA".to_string(), "BBBB".to_string(), "CCCC".to_string()]);
431        assert_eq!(paragraph_end, vec![false, true, true]);
432    }
433
434    #[test]
435    fn empty_paragraph_counts_as_its_own_last_line() {
436        let style = TextStyle::default();
437        let (lines, paragraph_end) = wrap_text_marking_paragraph_ends(&FixedResolver, &style, "a\n\nb", 1000.0);
438        assert_eq!(lines, vec!["a".to_string(), String::new(), "b".to_string()]);
439        assert_eq!(paragraph_end, vec![true, true, true]);
440    }
441
442    fn line_words(line: &RichLine) -> Vec<&str> {
443        line.words.iter().map(|w| w.text.as_str()).collect()
444    }
445
446    #[test]
447    fn wrap_spans_breaks_across_span_boundaries() {
448        let style = TextStyle {
449            size: 10.0,
450            ..Default::default()
451        };
452        // Same width math as `wraps_on_word_boundaries`, just spread
453        // across two spans instead of one string.
454        let spans = vec![Span::new("AAAA", style), Span::new(" BBBB", style)];
455        let lines = wrap_spans(&FixedResolver, &spans, 30.0);
456        assert_eq!(lines.len(), 2);
457        assert_eq!(line_words(&lines[0]), vec!["AAAA"]);
458        assert_eq!(line_words(&lines[1]), vec!["BBBB"]);
459    }
460
461    #[test]
462    fn wrap_spans_hard_breaks_a_single_too_wide_word_mid_span() {
463        let style = TextStyle {
464            size: 10.0,
465            ..Default::default()
466        };
467        let spans = vec![Span::new("ABCDEFGHIJ", style)];
468        let lines = wrap_spans(&FixedResolver, &spans, 18.0);
469        assert!(lines.len() > 1, "a word wider than max_width must hard-break onto multiple lines");
470        assert_eq!(line_words(&lines[0]), vec!["ABC"]);
471    }
472
473    #[test]
474    fn wrap_spans_line_height_and_ascent_come_from_the_tallest_word() {
475        let small = TextStyle {
476            size: 10.0,
477            line_height: 1.0,
478            ..Default::default()
479        };
480        let big = TextStyle {
481            size: 20.0,
482            line_height: 1.0,
483            ..Default::default()
484        };
485        let spans = vec![Span::new("a", small), Span::new(" B", big)];
486        let lines = wrap_spans(&FixedResolver, &spans, 1000.0);
487        assert_eq!(lines.len(), 1, "both words fit on one line");
488        assert_eq!(
489            lines[0].height, 20.0,
490            "line height must come from the larger word, not the first one"
491        );
492        // FixedMetrics.ascent() == 800 (1000-upm units) -> 800/1000*20 = 16pt at size 20.
493        assert_eq!(lines[0].ascent_pt, 16.0);
494    }
495}