lightweight_pdf_layout/
text.rs1use 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
13fn styled_width_pt(resolver: &dyn FontResolver, style: &TextStyle, text: &str) -> f32 {
16 text_width_pt(resolver, style.font, style.size, text)
17}
18
19fn 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
40fn 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 let last = pieces.pop().expect("hard_break_word always returns at least one piece");
56 lines.extend(pieces);
57 last
58}
59
60pub 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 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 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}