1use crate::font_resolver::FontResolver;
11use lightweight_pdf_core::{FontKey, Span, Text, TextStyle};
12use std::borrow::Cow;
13use std::collections::VecDeque;
14
15const SOFT_HYPHEN: char = '\u{00AD}';
18
19pub 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
38fn styled_width_pt(resolver: &dyn FontResolver, style: &TextStyle, text: &str) -> f32 {
41 text_width_pt(resolver, style.font, style.size, text)
42}
43
44fn 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
65fn strip_soft_hyphens(word: &str) -> String {
69 word.chars().filter(|&c| c != SOFT_HYPHEN).collect()
70}
71
72fn 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
93fn 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 let last = pieces.pop().expect("hard_break_word always returns at least one piece");
125 lines.extend(pieces);
126 last
127}
128
129pub 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
135pub 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 let space_w = styled_width_pt(resolver, style, " ");
171 let remaining = (max_width - styled_width_pt(resolver, style, ¤t) - 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 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#[derive(Clone, Debug)]
207pub struct StyledWord {
208 pub text: String,
209 pub style: TextStyle,
210}
211
212#[derive(Clone, Debug)]
220pub struct RichLine {
221 pub words: Vec<StyledWord>,
222 pub height: f32,
223 pub ascent_pt: f32,
224}
225
226fn 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 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 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 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 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 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 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 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 assert_eq!(lines[0].ascent_pt, 16.0);
494 }
495}