1pub mod bidi;
8pub mod knuth_plass;
9pub mod shaping;
10
11use crate::font::FontContext;
12use crate::style::{Color, FontStyle, Hyphens, TextDecoration};
13use unicode_linebreak::{linebreaks, BreakOpportunity};
14
15#[derive(Debug, Clone)]
17pub struct BrokenLine {
18 pub chars: Vec<char>,
20 pub text: String,
22 pub char_positions: Vec<f64>,
24 pub width: f64,
26}
27
28#[derive(Debug, Clone)]
30pub struct StyledChar {
31 pub ch: char,
32 pub font_family: String,
33 pub font_size: f64,
34 pub font_weight: u32,
35 pub font_style: FontStyle,
36 pub color: Color,
37 pub href: Option<String>,
38 pub text_decoration: TextDecoration,
39 pub letter_spacing: f64,
40}
41
42#[derive(Debug, Clone)]
44pub struct RunBrokenLine {
45 pub chars: Vec<StyledChar>,
46 pub char_positions: Vec<f64>,
47 pub width: f64,
48}
49
50fn compute_break_opportunities(text: &str) -> Vec<Option<BreakOpportunity>> {
56 let char_count = text.chars().count();
57 let mut result = vec![None; char_count];
58
59 let byte_to_char: Vec<usize> = {
63 let mut map = vec![0usize; text.len() + 1];
64 let mut char_idx = 0;
65 for (byte_idx, _) in text.char_indices() {
66 map[byte_idx] = char_idx;
67 char_idx += 1;
68 }
69 map[text.len()] = char_idx;
70 map
71 };
72
73 for (byte_offset, opp) in linebreaks(text) {
74 let char_idx = byte_to_char[byte_offset];
75 if char_idx < char_count {
76 result[char_idx] = Some(opp);
77 }
78 }
80
81 result
82}
83
84fn resolve_hypher_lang(lang: Option<&str>) -> Option<hypher::Lang> {
90 let tag = match lang {
91 Some(t) => t,
92 None => return Some(hypher::Lang::English),
93 };
94 let primary = tag.split('-').next().unwrap_or(tag).to_lowercase();
95 match primary.as_str() {
96 "af" => Some(hypher::Lang::Afrikaans),
97 "sq" => Some(hypher::Lang::Albanian),
98 "be" => Some(hypher::Lang::Belarusian),
99 "bg" => Some(hypher::Lang::Bulgarian),
100 "ca" => Some(hypher::Lang::Catalan),
101 "hr" => Some(hypher::Lang::Croatian),
102 "cs" => Some(hypher::Lang::Czech),
103 "da" => Some(hypher::Lang::Danish),
104 "nl" => Some(hypher::Lang::Dutch),
105 "en" => Some(hypher::Lang::English),
106 "et" => Some(hypher::Lang::Estonian),
107 "fi" => Some(hypher::Lang::Finnish),
108 "fr" => Some(hypher::Lang::French),
109 "ka" => Some(hypher::Lang::Georgian),
110 "de" => Some(hypher::Lang::German),
111 "el" => Some(hypher::Lang::Greek),
112 "hu" => Some(hypher::Lang::Hungarian),
113 "is" => Some(hypher::Lang::Icelandic),
114 "it" => Some(hypher::Lang::Italian),
115 "ku" => Some(hypher::Lang::Kurmanji),
116 "la" => Some(hypher::Lang::Latin),
117 "lt" => Some(hypher::Lang::Lithuanian),
118 "mn" => Some(hypher::Lang::Mongolian),
119 "nb" | "nn" | "no" => Some(hypher::Lang::Norwegian),
120 "pl" => Some(hypher::Lang::Polish),
121 "pt" => Some(hypher::Lang::Portuguese),
122 "ru" => Some(hypher::Lang::Russian),
123 "sr" => Some(hypher::Lang::Serbian),
124 "sk" => Some(hypher::Lang::Slovak),
125 "sl" => Some(hypher::Lang::Slovenian),
126 "es" => Some(hypher::Lang::Spanish),
127 "sv" => Some(hypher::Lang::Swedish),
128 "tr" => Some(hypher::Lang::Turkish),
129 "tk" => Some(hypher::Lang::Turkmen),
130 "uk" => Some(hypher::Lang::Ukrainian),
131 _ => None,
132 }
133}
134
135pub struct TextLayout;
136
137impl Default for TextLayout {
138 fn default() -> Self {
139 Self::new()
140 }
141}
142
143impl TextLayout {
144 pub fn new() -> Self {
145 Self
146 }
147
148 #[allow(clippy::too_many_arguments)]
155 pub fn break_into_lines(
156 &self,
157 font_context: &FontContext,
158 text: &str,
159 max_width: f64,
160 font_size: f64,
161 font_family: &str,
162 font_weight: u32,
163 font_style: FontStyle,
164 letter_spacing: f64,
165 hyphens: Hyphens,
166 lang: Option<&str>,
167 ) -> Vec<BrokenLine> {
168 if text.is_empty() {
169 return vec![BrokenLine {
170 chars: vec![],
171 text: String::new(),
172 char_positions: vec![],
173 width: 0.0,
174 }];
175 }
176
177 let char_widths = self.measure_chars(
178 font_context,
179 text,
180 font_size,
181 font_family,
182 font_weight,
183 font_style,
184 letter_spacing,
185 );
186
187 let hyphen_width = font_context.char_width(
188 '-',
189 font_family,
190 font_weight,
191 matches!(font_style, FontStyle::Italic | FontStyle::Oblique),
192 font_size,
193 ) + letter_spacing;
194
195 let mut lines = Vec::new();
196 let mut line_start = 0;
197 let mut line_width = 0.0;
198 let mut last_break_point = None;
199 let mut _last_break_width = 0.0;
200
201 let chars: Vec<char> = text.chars().collect();
202 let break_opps = compute_break_opportunities(text);
203
204 for (i, &ch) in chars.iter().enumerate() {
205 let char_width = char_widths[i];
206
207 if i > 0 {
211 if let Some(opp) = break_opps[i] {
212 match opp {
213 BreakOpportunity::Mandatory => {
214 let end = if chars[i - 1] == '\n'
216 || chars[i - 1] == '\r'
217 || chars[i - 1] == '\u{2028}'
218 || chars[i - 1] == '\u{2029}'
219 {
220 i - 1
221 } else {
222 i
223 };
224 let line_chars = self.filter_soft_hyphens(&chars[line_start..end]);
225 let line_widths = self.filter_soft_hyphen_widths(
226 &chars[line_start..end],
227 &char_widths[line_start..end],
228 );
229 lines.push(self.make_line(&line_chars, &line_widths));
230 line_start = i;
231 line_width = 0.0;
232 last_break_point = None;
233 }
235 BreakOpportunity::Allowed => {
236 last_break_point = Some(i - 1);
238 _last_break_width = line_width;
239 }
240 }
241 }
242 }
243
244 if ch == '\u{00AD}' && hyphens != Hyphens::None {
246 last_break_point = Some(i);
247 _last_break_width = line_width;
248 }
249
250 if ch == '\u{00AD}' {
252 continue;
253 }
254
255 if ch == '\n' || ch == '\r' || ch == '\u{2028}' || ch == '\u{2029}' {
257 continue;
258 }
259
260 if line_width + char_width > max_width && line_start < i {
261 if let Some(bp) = last_break_point {
263 if bp >= line_start {
264 if chars[bp] == '\u{00AD}' {
265 let mut line_chars = self.filter_soft_hyphens(&chars[line_start..bp]);
267 let mut line_widths = self.filter_soft_hyphen_widths(
268 &chars[line_start..bp],
269 &char_widths[line_start..bp],
270 );
271 line_chars.push('-');
272 line_widths.push(hyphen_width);
273 lines.push(self.make_line(&line_chars, &line_widths));
274 } else {
275 let break_at = bp + 1;
277 let line_chars = self.filter_soft_hyphens(&chars[line_start..break_at]);
278 let line_widths = self.filter_soft_hyphen_widths(
279 &chars[line_start..break_at],
280 &char_widths[line_start..break_at],
281 );
282 lines.push(self.make_line(&line_chars, &line_widths));
283 }
284
285 line_start = bp + 1;
286 line_width = chars[line_start..=i]
288 .iter()
289 .zip(char_widths[line_start..=i].iter())
290 .filter(|(c, _)| **c != '\u{00AD}')
291 .map(|(_, w)| w)
292 .sum();
293 last_break_point = None;
294 continue;
295 }
296 }
297
298 if hyphens == Hyphens::Auto {
300 if let Some((hyphen_line_chars, hyphen_line_widths, new_start)) = self
301 .try_hyphenate_word(
302 &chars,
303 &char_widths,
304 line_start,
305 i,
306 line_width,
307 max_width,
308 hyphen_width,
309 lang,
310 )
311 {
312 lines.push(self.make_line(&hyphen_line_chars, &hyphen_line_widths));
313 line_start = new_start;
314 line_width = chars[line_start..=i]
315 .iter()
316 .zip(char_widths[line_start..=i].iter())
317 .filter(|(c, _)| **c != '\u{00AD}')
318 .map(|(_, w)| w)
319 .sum();
320 last_break_point = None;
321 continue;
322 }
323 }
324
325 let line_chars = self.filter_soft_hyphens(&chars[line_start..i]);
327 let line_widths = self
328 .filter_soft_hyphen_widths(&chars[line_start..i], &char_widths[line_start..i]);
329 lines.push(self.make_line(&line_chars, &line_widths));
330 line_start = i;
331 line_width = char_width;
332 last_break_point = None;
333 continue;
334 }
335
336 line_width += char_width;
337 }
338
339 if line_start < chars.len() {
341 let line_chars = self.filter_soft_hyphens(&chars[line_start..]);
342 let line_widths =
343 self.filter_soft_hyphen_widths(&chars[line_start..], &char_widths[line_start..]);
344 lines.push(self.make_line(&line_chars, &line_widths));
345 }
346
347 lines
348 }
349
350 fn make_line(&self, chars: &[char], widths: &[f64]) -> BrokenLine {
352 let mut positions = Vec::with_capacity(chars.len());
353 let mut x = 0.0;
354 for &w in widths {
355 positions.push(x);
356 x += w;
357 }
358
359 let mut effective_width = x;
361 let mut i = chars.len();
362 while i > 0 && chars[i - 1] == ' ' {
363 i -= 1;
364 effective_width -= widths[i];
365 }
366
367 BrokenLine {
368 text: chars.iter().collect(),
369 chars: chars.to_vec(),
370 char_positions: positions,
371 width: effective_width,
372 }
373 }
374
375 fn filter_soft_hyphens(&self, chars: &[char]) -> Vec<char> {
377 chars.iter().copied().filter(|c| *c != '\u{00AD}').collect()
378 }
379
380 fn filter_soft_hyphen_widths(&self, chars: &[char], widths: &[f64]) -> Vec<f64> {
382 chars
383 .iter()
384 .zip(widths.iter())
385 .filter(|(c, _)| **c != '\u{00AD}')
386 .map(|(_, w)| *w)
387 .collect()
388 }
389
390 #[allow(clippy::too_many_arguments)]
398 fn try_hyphenate_word(
399 &self,
400 chars: &[char],
401 char_widths: &[f64],
402 line_start: usize,
403 overflow_at: usize,
404 _line_width: f64,
405 max_width: f64,
406 hyphen_width: f64,
407 lang: Option<&str>,
408 ) -> Option<(Vec<char>, Vec<f64>, usize)> {
409 let mut word_start = overflow_at;
411 while word_start > line_start && !chars[word_start - 1].is_whitespace() {
412 word_start -= 1;
413 }
414
415 let word_end = overflow_at; if word_end <= word_start {
418 return None;
419 }
420
421 let word: String = chars[word_start..word_end].iter().collect();
422 let hypher_lang = resolve_hypher_lang(lang)?;
423 let syllables = hypher::hyphenate(&word, hypher_lang);
424
425 let syllables: Vec<&str> = syllables.collect();
426 if syllables.len() < 2 {
427 return None;
428 }
429
430 let prefix_width: f64 = chars[line_start..word_start]
432 .iter()
433 .zip(char_widths[line_start..word_start].iter())
434 .filter(|(c, _)| **c != '\u{00AD}')
435 .map(|(_, w)| w)
436 .sum();
437
438 let mut best_break: Option<usize> = None; let mut syllable_offset = word_start;
441 for (si, syllable) in syllables.iter().enumerate() {
442 if si == syllables.len() - 1 {
443 break; }
445 syllable_offset += syllable.chars().count();
446
447 let word_part_width: f64 = chars[word_start..syllable_offset]
449 .iter()
450 .zip(char_widths[word_start..syllable_offset].iter())
451 .filter(|(c, _)| **c != '\u{00AD}')
452 .map(|(_, w)| w)
453 .sum();
454
455 if prefix_width + word_part_width + hyphen_width <= max_width {
456 best_break = Some(syllable_offset);
457 }
458 }
459
460 let break_at = best_break?;
461
462 let mut line_chars = self.filter_soft_hyphens(&chars[line_start..break_at]);
463 let mut line_widths = self.filter_soft_hyphen_widths(
464 &chars[line_start..break_at],
465 &char_widths[line_start..break_at],
466 );
467 line_chars.push('-');
468 line_widths.push(hyphen_width);
469
470 Some((line_chars, line_widths, break_at))
471 }
472
473 #[allow(clippy::too_many_arguments)]
479 fn measure_chars(
480 &self,
481 font_context: &FontContext,
482 text: &str,
483 font_size: f64,
484 font_family: &str,
485 font_weight: u32,
486 font_style: FontStyle,
487 letter_spacing: f64,
488 ) -> Vec<f64> {
489 let italic = matches!(font_style, FontStyle::Italic | FontStyle::Oblique);
490 let chars: Vec<char> = text.chars().collect();
491
492 let has_bidi = !bidi::is_pure_ltr(text, crate::style::Direction::Auto);
494 let bidi_runs = if has_bidi {
495 bidi::analyze_bidi(text, crate::style::Direction::Auto)
496 } else {
497 vec![]
498 };
499
500 if !font_family.contains(',') {
502 if let Some(font_data) = font_context.font_data(font_family, font_weight, italic) {
503 let units_per_em = font_context.units_per_em(font_family, font_weight, italic);
504
505 if has_bidi {
506 let mut widths = vec![0.0_f64; chars.len()];
508 for bidi_run in &bidi_runs {
509 let run_text: String = chars[bidi_run.char_start..bidi_run.char_end]
510 .iter()
511 .collect();
512 if let Some(shaped) = shaping::shape_text_with_direction(
513 &run_text,
514 font_data,
515 bidi_run.is_rtl,
516 ) {
517 let num_chars = bidi_run.char_end - bidi_run.char_start;
518 let cluster_w = shaping::cluster_widths(
519 &shaped,
520 num_chars,
521 units_per_em,
522 font_size,
523 letter_spacing,
524 );
525 for (j, w) in cluster_w.into_iter().enumerate() {
526 widths[bidi_run.char_start + j] = w;
527 }
528 } else {
529 for i in bidi_run.char_start..bidi_run.char_end {
530 widths[i] = font_context.char_width(
531 chars[i],
532 font_family,
533 font_weight,
534 italic,
535 font_size,
536 ) + letter_spacing;
537 }
538 }
539 }
540 return widths;
541 }
542
543 if let Some(shaped) = shaping::shape_text(text, font_data) {
544 let num_chars = chars.len();
545 return shaping::cluster_widths(
546 &shaped,
547 num_chars,
548 units_per_em,
549 font_size,
550 letter_spacing,
551 );
552 }
553 }
554
555 return text
556 .chars()
557 .map(|ch| {
558 font_context.char_width(ch, font_family, font_weight, italic, font_size)
559 + letter_spacing
560 })
561 .collect();
562 }
563
564 chars
571 .iter()
572 .map(|&ch| {
573 font_context.char_width(ch, font_family, font_weight, italic, font_size)
574 + letter_spacing
575 })
576 .collect()
577 }
578
579 #[allow(clippy::too_many_arguments)]
582 pub fn shape_text(
583 &self,
584 font_context: &FontContext,
585 text: &str,
586 font_family: &str,
587 font_weight: u32,
588 font_style: FontStyle,
589 ) -> Option<Vec<shaping::ShapedGlyph>> {
590 let italic = matches!(font_style, FontStyle::Italic | FontStyle::Oblique);
591 let font_data = font_context.font_data(font_family, font_weight, italic)?;
592 shaping::shape_text(text, font_data)
593 }
594
595 fn measure_styled_chars(&self, font_context: &FontContext, chars: &[StyledChar]) -> Vec<f64> {
599 if chars.is_empty() {
600 return vec![];
601 }
602
603 let mut widths = vec![0.0_f64; chars.len()];
604 let mut i = 0;
605
606 while i < chars.len() {
607 let sc = &chars[i];
608 let italic = matches!(sc.font_style, FontStyle::Italic | FontStyle::Oblique);
609
610 if let Some(font_data) = font_context.font_data(&sc.font_family, sc.font_weight, italic)
612 {
613 let run_start = i;
615 let mut run_end = i + 1;
616 while run_end < chars.len() {
617 let next = &chars[run_end];
618 let next_italic =
619 matches!(next.font_style, FontStyle::Italic | FontStyle::Oblique);
620 if next.font_family == sc.font_family
621 && next.font_weight == sc.font_weight
622 && next_italic == italic
623 && (next.font_size - sc.font_size).abs() < 0.001
624 {
625 run_end += 1;
626 } else {
627 break;
628 }
629 }
630
631 let run_text: String = chars[run_start..run_end].iter().map(|c| c.ch).collect();
633 if let Some(shaped) = shaping::shape_text(&run_text, font_data) {
634 let num_chars = run_end - run_start;
635 let units_per_em =
636 font_context.units_per_em(&sc.font_family, sc.font_weight, italic);
637 let cluster_w = shaping::cluster_widths(
638 &shaped,
639 num_chars,
640 units_per_em,
641 sc.font_size,
642 sc.letter_spacing,
643 );
644 for (j, w) in cluster_w.into_iter().enumerate() {
645 widths[run_start + j] = w;
646 }
647 i = run_end;
648 continue;
649 }
650 }
651
652 widths[i] = font_context.char_width(
654 sc.ch,
655 &sc.font_family,
656 sc.font_weight,
657 italic,
658 sc.font_size,
659 ) + sc.letter_spacing;
660 i += 1;
661 }
662
663 widths
664 }
665
666 pub fn break_runs_into_lines(
668 &self,
669 font_context: &FontContext,
670 chars: &[StyledChar],
671 max_width: f64,
672 hyphens: Hyphens,
673 lang: Option<&str>,
674 ) -> Vec<RunBrokenLine> {
675 if chars.is_empty() {
676 return vec![RunBrokenLine {
677 chars: vec![],
678 char_positions: vec![],
679 width: 0.0,
680 }];
681 }
682
683 let char_widths = self.measure_styled_chars(font_context, chars);
685
686 let mut lines = Vec::new();
687 let mut line_start = 0;
688 let mut line_width = 0.0;
689 let mut last_break_point: Option<usize> = None;
690
691 let plain_text: String = chars.iter().map(|sc| sc.ch).collect();
693 let break_opps = compute_break_opportunities(&plain_text);
694
695 for (i, sc) in chars.iter().enumerate() {
696 let char_width = char_widths[i];
697
698 if i > 0 {
700 if let Some(opp) = break_opps[i] {
701 match opp {
702 BreakOpportunity::Mandatory => {
703 let end = if chars[i - 1].ch == '\n'
704 || chars[i - 1].ch == '\r'
705 || chars[i - 1].ch == '\u{2028}'
706 || chars[i - 1].ch == '\u{2029}'
707 {
708 i - 1
709 } else {
710 i
711 };
712 let filtered = self.filter_soft_hyphens_runs(&chars[line_start..end]);
713 let filtered_widths = self.filter_soft_hyphen_widths_runs(
714 &chars[line_start..end],
715 &char_widths[line_start..end],
716 );
717 lines.push(self.make_run_line(&filtered, &filtered_widths));
718 line_start = i;
719 line_width = 0.0;
720 last_break_point = None;
721 }
722 BreakOpportunity::Allowed => {
723 last_break_point = Some(i - 1);
724 }
725 }
726 }
727 }
728
729 if sc.ch == '\u{00AD}' && hyphens != Hyphens::None {
731 last_break_point = Some(i);
732 }
733
734 if sc.ch == '\u{00AD}' {
736 continue;
737 }
738
739 if sc.ch == '\n' || sc.ch == '\r' || sc.ch == '\u{2028}' || sc.ch == '\u{2029}' {
741 continue;
742 }
743
744 if line_width + char_width > max_width && line_start < i {
745 if let Some(bp) = last_break_point {
746 if bp >= line_start {
747 if chars[bp].ch == '\u{00AD}' {
748 let mut filtered =
750 self.filter_soft_hyphens_runs(&chars[line_start..bp]);
751 let mut filtered_widths = self.filter_soft_hyphen_widths_runs(
752 &chars[line_start..bp],
753 &char_widths[line_start..bp],
754 );
755 let hyphen_style = if bp > 0 {
757 chars[bp - 1].clone()
758 } else {
759 chars[bp].clone()
760 };
761 let italic = matches!(
762 hyphen_style.font_style,
763 FontStyle::Italic | FontStyle::Oblique
764 );
765 let hw = font_context.char_width(
766 '-',
767 &hyphen_style.font_family,
768 hyphen_style.font_weight,
769 italic,
770 hyphen_style.font_size,
771 ) + hyphen_style.letter_spacing;
772 let mut hyphen_sc = hyphen_style;
773 hyphen_sc.ch = '-';
774 filtered.push(hyphen_sc);
775 filtered_widths.push(hw);
776 lines.push(self.make_run_line(&filtered, &filtered_widths));
777 } else {
778 let break_at = bp + 1;
780 let filtered =
781 self.filter_soft_hyphens_runs(&chars[line_start..break_at]);
782 let filtered_widths = self.filter_soft_hyphen_widths_runs(
783 &chars[line_start..break_at],
784 &char_widths[line_start..break_at],
785 );
786 lines.push(self.make_run_line(&filtered, &filtered_widths));
787 }
788
789 line_start = bp + 1;
790 line_width = chars[line_start..=i]
791 .iter()
792 .zip(char_widths[line_start..=i].iter())
793 .filter(|(sc, _)| sc.ch != '\u{00AD}')
794 .map(|(_, w)| w)
795 .sum();
796 last_break_point = None;
797 continue;
798 }
799 }
800
801 if hyphens == Hyphens::Auto {
803 let plain_chars: Vec<char> = chars.iter().map(|sc| sc.ch).collect();
804 let italic = if !chars.is_empty() {
805 matches!(
806 chars[line_start].font_style,
807 FontStyle::Italic | FontStyle::Oblique
808 )
809 } else {
810 false
811 };
812 let hyphen_width = if !chars.is_empty() {
813 font_context.char_width(
814 '-',
815 &chars[line_start].font_family,
816 chars[line_start].font_weight,
817 italic,
818 chars[line_start].font_size,
819 ) + chars[line_start].letter_spacing
820 } else {
821 0.0
822 };
823
824 if let Some((_, _, new_start)) = self.try_hyphenate_word(
825 &plain_chars,
826 &char_widths,
827 line_start,
828 i,
829 line_width,
830 max_width,
831 hyphen_width,
832 lang,
833 ) {
834 let mut filtered =
836 self.filter_soft_hyphens_runs(&chars[line_start..new_start]);
837 let mut filtered_widths = self.filter_soft_hyphen_widths_runs(
838 &chars[line_start..new_start],
839 &char_widths[line_start..new_start],
840 );
841 let hyphen_style_ref = if new_start > 0 {
842 &chars[new_start - 1]
843 } else {
844 &chars[0]
845 };
846 let mut hyphen_sc = hyphen_style_ref.clone();
847 hyphen_sc.ch = '-';
848 filtered.push(hyphen_sc);
849 filtered_widths.push(hyphen_width);
850 lines.push(self.make_run_line(&filtered, &filtered_widths));
851
852 line_start = new_start;
853 line_width = chars[line_start..=i]
854 .iter()
855 .zip(char_widths[line_start..=i].iter())
856 .filter(|(sc, _)| sc.ch != '\u{00AD}')
857 .map(|(_, w)| w)
858 .sum();
859 last_break_point = None;
860 continue;
861 }
862 }
863
864 let filtered = self.filter_soft_hyphens_runs(&chars[line_start..i]);
865 let filtered_widths = self.filter_soft_hyphen_widths_runs(
866 &chars[line_start..i],
867 &char_widths[line_start..i],
868 );
869 lines.push(self.make_run_line(&filtered, &filtered_widths));
870 line_start = i;
871 line_width = char_width;
872 last_break_point = None;
873 continue;
874 }
875
876 line_width += char_width;
877 }
878
879 if line_start < chars.len() {
880 let filtered = self.filter_soft_hyphens_runs(&chars[line_start..]);
881 let filtered_widths = self
882 .filter_soft_hyphen_widths_runs(&chars[line_start..], &char_widths[line_start..]);
883 lines.push(self.make_run_line(&filtered, &filtered_widths));
884 }
885
886 lines
887 }
888
889 fn filter_soft_hyphens_runs(&self, chars: &[StyledChar]) -> Vec<StyledChar> {
891 chars
892 .iter()
893 .filter(|sc| sc.ch != '\u{00AD}')
894 .cloned()
895 .collect()
896 }
897
898 fn filter_soft_hyphen_widths_runs(&self, chars: &[StyledChar], widths: &[f64]) -> Vec<f64> {
900 chars
901 .iter()
902 .zip(widths.iter())
903 .filter(|(sc, _)| sc.ch != '\u{00AD}')
904 .map(|(_, w)| *w)
905 .collect()
906 }
907
908 fn make_run_line(&self, chars: &[StyledChar], widths: &[f64]) -> RunBrokenLine {
909 let mut positions = Vec::with_capacity(chars.len());
910 let mut x = 0.0;
911 for &w in widths {
912 positions.push(x);
913 x += w;
914 }
915
916 let mut effective_width = x;
918 let mut i = chars.len();
919 while i > 0 && chars[i - 1].ch == ' ' {
920 i -= 1;
921 effective_width -= widths[i];
922 }
923
924 RunBrokenLine {
925 chars: chars.to_vec(),
926 char_positions: positions,
927 width: effective_width,
928 }
929 }
930
931 #[allow(clippy::too_many_arguments)]
936 pub fn measure_widest_word(
937 &self,
938 font_context: &FontContext,
939 text: &str,
940 font_size: f64,
941 font_family: &str,
942 font_weight: u32,
943 font_style: FontStyle,
944 letter_spacing: f64,
945 hyphens: Hyphens,
946 lang: Option<&str>,
947 ) -> f64 {
948 if hyphens == Hyphens::Auto {
949 if let Some(hypher_lang) = resolve_hypher_lang(lang) {
950 return text
952 .split_whitespace()
953 .flat_map(|word| {
954 let syllables = hypher::hyphenate(word, hypher_lang);
955 syllables
956 .into_iter()
957 .map(|s| {
958 self.measure_width(
959 font_context,
960 s,
961 font_size,
962 font_family,
963 font_weight,
964 font_style,
965 letter_spacing,
966 )
967 })
968 .collect::<Vec<_>>()
969 })
970 .fold(0.0f64, f64::max);
971 }
972 }
974 text.split_whitespace()
975 .map(|word| {
976 self.measure_width(
977 font_context,
978 word,
979 font_size,
980 font_family,
981 font_weight,
982 font_style,
983 letter_spacing,
984 )
985 })
986 .fold(0.0f64, f64::max)
987 }
988
989 #[allow(clippy::too_many_arguments)]
991 pub fn measure_width(
992 &self,
993 font_context: &FontContext,
994 text: &str,
995 font_size: f64,
996 font_family: &str,
997 font_weight: u32,
998 font_style: FontStyle,
999 letter_spacing: f64,
1000 ) -> f64 {
1001 self.measure_chars(
1002 font_context,
1003 text,
1004 font_size,
1005 font_family,
1006 font_weight,
1007 font_style,
1008 letter_spacing,
1009 )
1010 .iter()
1011 .sum()
1012 }
1013
1014 #[allow(clippy::too_many_arguments)]
1018 pub fn break_into_lines_optimal(
1019 &self,
1020 font_context: &FontContext,
1021 text: &str,
1022 max_width: f64,
1023 font_size: f64,
1024 font_family: &str,
1025 font_weight: u32,
1026 font_style: FontStyle,
1027 letter_spacing: f64,
1028 hyphens: Hyphens,
1029 lang: Option<&str>,
1030 justify: bool,
1031 ) -> Vec<BrokenLine> {
1032 if text.is_empty() {
1033 return vec![BrokenLine {
1034 chars: vec![],
1035 text: String::new(),
1036 char_positions: vec![],
1037 width: 0.0,
1038 }];
1039 }
1040
1041 let char_widths = self.measure_chars(
1042 font_context,
1043 text,
1044 font_size,
1045 font_family,
1046 font_weight,
1047 font_style,
1048 letter_spacing,
1049 );
1050
1051 let hyphen_width = font_context.char_width(
1052 '-',
1053 font_family,
1054 font_weight,
1055 matches!(font_style, FontStyle::Italic | FontStyle::Oblique),
1056 font_size,
1057 ) + letter_spacing;
1058
1059 let chars: Vec<char> = text.chars().collect();
1060 let break_opps = compute_break_opportunities(text);
1061
1062 let mut segments = Vec::new();
1064 let mut seg_start = 0;
1065 for (i, opp) in break_opps.iter().enumerate() {
1066 if let Some(BreakOpportunity::Mandatory) = opp {
1067 let end = if i > 0
1070 && (chars[i - 1] == '\n'
1071 || chars[i - 1] == '\r'
1072 || chars[i - 1] == '\u{2028}'
1073 || chars[i - 1] == '\u{2029}')
1074 {
1075 i - 1
1076 } else {
1077 i
1078 };
1079 segments.push(seg_start..end);
1080 seg_start = i;
1081 }
1082 }
1083 segments.push(seg_start..chars.len());
1084
1085 if segments.len() > 1 {
1086 let mut all_lines = Vec::new();
1088 for seg in &segments {
1089 if seg.is_empty() {
1090 all_lines.push(BrokenLine {
1091 chars: vec![],
1092 text: String::new(),
1093 char_positions: vec![],
1094 width: 0.0,
1095 });
1096 continue;
1097 }
1098 let seg_chars: Vec<char> = chars[seg.clone()]
1099 .iter()
1100 .copied()
1101 .filter(|c| *c != '\n' && *c != '\r' && *c != '\u{2028}' && *c != '\u{2029}')
1102 .collect();
1103 if seg_chars.is_empty() {
1104 continue;
1105 }
1106 let seg_text: String = seg_chars.iter().collect();
1107 let seg_lines = self.break_into_lines_optimal(
1108 font_context,
1109 &seg_text,
1110 max_width,
1111 font_size,
1112 font_family,
1113 font_weight,
1114 font_style,
1115 letter_spacing,
1116 hyphens,
1117 lang,
1118 justify,
1119 );
1120 all_lines.extend(seg_lines);
1121 }
1122 return all_lines;
1123 }
1124
1125 let items = knuth_plass::build_items(
1127 &chars,
1128 &char_widths,
1129 hyphen_width,
1130 hyphens,
1131 &break_opps,
1132 lang,
1133 );
1134 let config = knuth_plass::Config {
1135 line_width: max_width,
1136 ..Default::default()
1137 };
1138
1139 if let Some(solutions) = knuth_plass::find_breaks(&items, &config) {
1140 knuth_plass::reconstruct_lines(
1141 &solutions,
1142 &items,
1143 &chars,
1144 &char_widths,
1145 max_width,
1146 justify,
1147 )
1148 } else {
1149 self.break_into_lines(
1151 font_context,
1152 text,
1153 max_width,
1154 font_size,
1155 font_family,
1156 font_weight,
1157 font_style,
1158 letter_spacing,
1159 hyphens,
1160 lang,
1161 )
1162 }
1163 }
1164
1165 pub fn break_runs_into_lines_optimal(
1169 &self,
1170 font_context: &FontContext,
1171 chars: &[StyledChar],
1172 max_width: f64,
1173 hyphens: Hyphens,
1174 lang: Option<&str>,
1175 justify: bool,
1176 ) -> Vec<RunBrokenLine> {
1177 if chars.is_empty() {
1178 return vec![RunBrokenLine {
1179 chars: vec![],
1180 char_positions: vec![],
1181 width: 0.0,
1182 }];
1183 }
1184
1185 let char_widths = self.measure_styled_chars(font_context, chars);
1186
1187 let hyphen_width = if !chars.is_empty() {
1189 let sc = &chars[0];
1190 let italic = matches!(sc.font_style, FontStyle::Italic | FontStyle::Oblique);
1191 font_context.char_width('-', &sc.font_family, sc.font_weight, italic, sc.font_size)
1192 + sc.letter_spacing
1193 } else {
1194 0.0
1195 };
1196
1197 let plain_text: String = chars.iter().map(|sc| sc.ch).collect();
1198 let break_opps = compute_break_opportunities(&plain_text);
1199
1200 let plain_chars: Vec<char> = chars.iter().map(|sc| sc.ch).collect();
1202 let has_mandatory = break_opps
1203 .iter()
1204 .any(|o| matches!(o, Some(BreakOpportunity::Mandatory)));
1205
1206 if has_mandatory {
1207 let mut all_lines = Vec::new();
1208 let mut seg_start = 0;
1209
1210 for (i, opp) in break_opps.iter().enumerate() {
1211 if let Some(BreakOpportunity::Mandatory) = opp {
1212 let end = if i > 0
1213 && (plain_chars[i - 1] == '\n'
1214 || plain_chars[i - 1] == '\r'
1215 || plain_chars[i - 1] == '\u{2028}'
1216 || plain_chars[i - 1] == '\u{2029}')
1217 {
1218 i - 1
1219 } else {
1220 i
1221 };
1222 let seg_chars: Vec<StyledChar> = chars[seg_start..end]
1223 .iter()
1224 .filter(|sc| {
1225 sc.ch != '\n'
1226 && sc.ch != '\r'
1227 && sc.ch != '\u{2028}'
1228 && sc.ch != '\u{2029}'
1229 })
1230 .cloned()
1231 .collect();
1232 let seg_lines = self.break_runs_into_lines_optimal(
1233 font_context,
1234 &seg_chars,
1235 max_width,
1236 hyphens,
1237 lang,
1238 justify,
1239 );
1240 all_lines.extend(seg_lines);
1241 seg_start = i;
1242 }
1243 }
1244 let seg_chars: Vec<StyledChar> = chars[seg_start..]
1246 .iter()
1247 .filter(|sc| {
1248 sc.ch != '\n' && sc.ch != '\r' && sc.ch != '\u{2028}' && sc.ch != '\u{2029}'
1249 })
1250 .cloned()
1251 .collect();
1252 if !seg_chars.is_empty() {
1253 let seg_lines = self.break_runs_into_lines_optimal(
1254 font_context,
1255 &seg_chars,
1256 max_width,
1257 hyphens,
1258 lang,
1259 justify,
1260 );
1261 all_lines.extend(seg_lines);
1262 }
1263 return all_lines;
1264 }
1265
1266 let items = knuth_plass::build_items_styled(
1267 chars,
1268 &char_widths,
1269 hyphen_width,
1270 hyphens,
1271 &break_opps,
1272 lang,
1273 );
1274 let config = knuth_plass::Config {
1275 line_width: max_width,
1276 ..Default::default()
1277 };
1278
1279 if let Some(solutions) = knuth_plass::find_breaks(&items, &config) {
1280 knuth_plass::reconstruct_run_lines(
1281 &solutions,
1282 &items,
1283 chars,
1284 &char_widths,
1285 max_width,
1286 justify,
1287 )
1288 } else {
1289 self.break_runs_into_lines(font_context, chars, max_width, hyphens, lang)
1291 }
1292 }
1293
1294 #[allow(clippy::too_many_arguments)]
1296 pub fn truncate_with_ellipsis(
1297 &self,
1298 font_context: &FontContext,
1299 mut lines: Vec<BrokenLine>,
1300 max_width: f64,
1301 font_size: f64,
1302 font_family: &str,
1303 font_weight: u32,
1304 font_style: FontStyle,
1305 letter_spacing: f64,
1306 ) -> Vec<BrokenLine> {
1307 if lines.is_empty() {
1308 return lines;
1309 }
1310
1311 let mut all_chars: Vec<char> = Vec::new();
1314 for line in &lines {
1315 all_chars.extend(&line.chars);
1316 }
1317 lines.truncate(1);
1318
1319 let ellipsis = '\u{2026}'; let italic = matches!(font_style, FontStyle::Italic | FontStyle::Oblique);
1321 let ellipsis_width =
1322 font_context.char_width(ellipsis, font_family, font_weight, italic, font_size)
1323 + letter_spacing;
1324
1325 let char_widths = self.measure_chars(
1327 font_context,
1328 &all_chars.iter().collect::<String>(),
1329 font_size,
1330 font_family,
1331 font_weight,
1332 font_style,
1333 letter_spacing,
1334 );
1335
1336 let total_width: f64 = char_widths.iter().sum();
1337 if total_width <= max_width {
1338 let mut x = 0.0;
1340 let positions: Vec<f64> = char_widths
1341 .iter()
1342 .map(|w| {
1343 let pos = x;
1344 x += w;
1345 pos
1346 })
1347 .collect();
1348 lines[0] = BrokenLine {
1349 chars: all_chars.clone(),
1350 text: all_chars.iter().collect(),
1351 char_positions: positions,
1352 width: total_width,
1353 };
1354 return lines;
1355 }
1356
1357 let target_width = max_width - ellipsis_width;
1359 let mut width = 0.0;
1360 let mut keep = 0;
1361 for (i, &cw) in char_widths.iter().enumerate() {
1362 if width + cw > target_width {
1363 break;
1364 }
1365 width += cw;
1366 keep = i + 1;
1367 }
1368
1369 while keep > 0 && all_chars[keep - 1].is_whitespace() {
1371 keep -= 1;
1372 }
1373
1374 let mut truncated_chars: Vec<char> = all_chars[..keep].to_vec();
1375 truncated_chars.push(ellipsis);
1376
1377 let mut x = 0.0;
1378 let mut positions: Vec<f64> = char_widths[..keep]
1379 .iter()
1380 .map(|w| {
1381 let pos = x;
1382 x += w;
1383 pos
1384 })
1385 .collect();
1386 positions.push(x);
1387 let final_width = x + ellipsis_width;
1388
1389 lines[0] = BrokenLine {
1390 text: truncated_chars.iter().collect(),
1391 chars: truncated_chars,
1392 char_positions: positions,
1393 width: final_width,
1394 };
1395 lines
1396 }
1397
1398 #[allow(clippy::too_many_arguments)]
1400 pub fn truncate_clip(
1401 &self,
1402 font_context: &FontContext,
1403 mut lines: Vec<BrokenLine>,
1404 max_width: f64,
1405 font_size: f64,
1406 font_family: &str,
1407 font_weight: u32,
1408 font_style: FontStyle,
1409 letter_spacing: f64,
1410 ) -> Vec<BrokenLine> {
1411 if lines.is_empty() {
1412 return lines;
1413 }
1414
1415 let mut all_chars: Vec<char> = Vec::new();
1416 for line in &lines {
1417 all_chars.extend(&line.chars);
1418 }
1419 lines.truncate(1);
1420
1421 let char_widths = self.measure_chars(
1422 font_context,
1423 &all_chars.iter().collect::<String>(),
1424 font_size,
1425 font_family,
1426 font_weight,
1427 font_style,
1428 letter_spacing,
1429 );
1430
1431 let total_width: f64 = char_widths.iter().sum();
1432 if total_width <= max_width {
1433 let mut x = 0.0;
1434 let positions: Vec<f64> = char_widths
1435 .iter()
1436 .map(|w| {
1437 let pos = x;
1438 x += w;
1439 pos
1440 })
1441 .collect();
1442 lines[0] = BrokenLine {
1443 chars: all_chars.clone(),
1444 text: all_chars.iter().collect(),
1445 char_positions: positions,
1446 width: total_width,
1447 };
1448 return lines;
1449 }
1450
1451 let mut width = 0.0;
1452 let mut keep = 0;
1453 for (i, &cw) in char_widths.iter().enumerate() {
1454 if width + cw > max_width {
1455 break;
1456 }
1457 width += cw;
1458 keep = i + 1;
1459 }
1460
1461 let truncated_chars: Vec<char> = all_chars[..keep].to_vec();
1462 let mut x = 0.0;
1463 let positions: Vec<f64> = char_widths[..keep]
1464 .iter()
1465 .map(|w| {
1466 let pos = x;
1467 x += w;
1468 pos
1469 })
1470 .collect();
1471
1472 lines[0] = BrokenLine {
1473 text: truncated_chars.iter().collect(),
1474 chars: truncated_chars,
1475 char_positions: positions,
1476 width,
1477 };
1478 lines
1479 }
1480
1481 pub fn truncate_runs_with_ellipsis(
1483 &self,
1484 font_context: &FontContext,
1485 mut lines: Vec<RunBrokenLine>,
1486 max_width: f64,
1487 ) -> Vec<RunBrokenLine> {
1488 if lines.is_empty() {
1489 return lines;
1490 }
1491
1492 let mut all_chars: Vec<StyledChar> = Vec::new();
1494 for line in &lines {
1495 all_chars.extend(line.chars.iter().cloned());
1496 }
1497 lines.truncate(1);
1498
1499 let char_widths = self.measure_styled_chars(font_context, &all_chars);
1500 let total_width: f64 = char_widths.iter().sum();
1501
1502 if total_width <= max_width {
1503 let mut x = 0.0;
1504 let positions: Vec<f64> = char_widths
1505 .iter()
1506 .map(|w| {
1507 let pos = x;
1508 x += w;
1509 pos
1510 })
1511 .collect();
1512 lines[0] = RunBrokenLine {
1513 chars: all_chars,
1514 char_positions: positions,
1515 width: total_width,
1516 };
1517 return lines;
1518 }
1519
1520 let last_style = all_chars.last().unwrap();
1522 let italic = matches!(
1523 last_style.font_style,
1524 FontStyle::Italic | FontStyle::Oblique
1525 );
1526 let ellipsis_width = font_context.char_width(
1527 '\u{2026}',
1528 &last_style.font_family,
1529 last_style.font_weight,
1530 italic,
1531 last_style.font_size,
1532 ) + last_style.letter_spacing;
1533
1534 let target_width = max_width - ellipsis_width;
1535 let mut width = 0.0;
1536 let mut keep = 0;
1537 for (i, &cw) in char_widths.iter().enumerate() {
1538 if width + cw > target_width {
1539 break;
1540 }
1541 width += cw;
1542 keep = i + 1;
1543 }
1544
1545 while keep > 0 && all_chars[keep - 1].ch.is_whitespace() {
1546 keep -= 1;
1547 }
1548
1549 let mut truncated: Vec<StyledChar> = all_chars[..keep].to_vec();
1550 let ellipsis_style = if keep > 0 {
1552 all_chars[keep - 1].clone()
1553 } else {
1554 last_style.clone()
1555 };
1556 truncated.push(StyledChar {
1557 ch: '\u{2026}',
1558 ..ellipsis_style
1559 });
1560
1561 let mut x = 0.0;
1562 let mut positions: Vec<f64> = char_widths[..keep]
1563 .iter()
1564 .map(|w| {
1565 let pos = x;
1566 x += w;
1567 pos
1568 })
1569 .collect();
1570 positions.push(x);
1571
1572 lines[0] = RunBrokenLine {
1573 chars: truncated,
1574 char_positions: positions,
1575 width: x + ellipsis_width,
1576 };
1577 lines
1578 }
1579
1580 pub fn truncate_runs_clip(
1582 &self,
1583 font_context: &FontContext,
1584 mut lines: Vec<RunBrokenLine>,
1585 max_width: f64,
1586 ) -> Vec<RunBrokenLine> {
1587 if lines.is_empty() {
1588 return lines;
1589 }
1590
1591 let mut all_chars: Vec<StyledChar> = Vec::new();
1592 for line in &lines {
1593 all_chars.extend(line.chars.iter().cloned());
1594 }
1595 lines.truncate(1);
1596
1597 let char_widths = self.measure_styled_chars(font_context, &all_chars);
1598 let total_width: f64 = char_widths.iter().sum();
1599
1600 if total_width <= max_width {
1601 let mut x = 0.0;
1602 let positions: Vec<f64> = char_widths
1603 .iter()
1604 .map(|w| {
1605 let pos = x;
1606 x += w;
1607 pos
1608 })
1609 .collect();
1610 lines[0] = RunBrokenLine {
1611 chars: all_chars,
1612 char_positions: positions,
1613 width: total_width,
1614 };
1615 return lines;
1616 }
1617
1618 let mut width = 0.0;
1619 let mut keep = 0;
1620 for (i, &cw) in char_widths.iter().enumerate() {
1621 if width + cw > max_width {
1622 break;
1623 }
1624 width += cw;
1625 keep = i + 1;
1626 }
1627
1628 let truncated: Vec<StyledChar> = all_chars[..keep].to_vec();
1629 let mut x = 0.0;
1630 let positions: Vec<f64> = char_widths[..keep]
1631 .iter()
1632 .map(|w| {
1633 let pos = x;
1634 x += w;
1635 pos
1636 })
1637 .collect();
1638
1639 lines[0] = RunBrokenLine {
1640 chars: truncated,
1641 char_positions: positions,
1642 width,
1643 };
1644 lines
1645 }
1646}
1647
1648#[cfg(test)]
1649mod tests {
1650 use super::*;
1651
1652 fn ctx() -> FontContext {
1653 FontContext::new()
1654 }
1655
1656 #[test]
1657 fn test_single_line() {
1658 let tl = TextLayout::new();
1659 let fc = ctx();
1660 let lines = tl.break_into_lines(
1661 &fc,
1662 "Hello",
1663 200.0,
1664 12.0,
1665 "Helvetica",
1666 400,
1667 FontStyle::Normal,
1668 0.0,
1669 Hyphens::Manual,
1670 None,
1671 );
1672 assert_eq!(lines.len(), 1);
1673 assert_eq!(lines[0].text, "Hello");
1674 }
1675
1676 #[test]
1677 fn test_line_break_at_space() {
1678 let tl = TextLayout::new();
1679 let fc = ctx();
1680 let lines = tl.break_into_lines(
1681 &fc,
1682 "Hello World",
1683 40.0,
1684 12.0,
1685 "Helvetica",
1686 400,
1687 FontStyle::Normal,
1688 0.0,
1689 Hyphens::Manual,
1690 None,
1691 );
1692 assert!(lines.len() >= 2);
1693 }
1694
1695 #[test]
1696 fn test_explicit_newline() {
1697 let tl = TextLayout::new();
1698 let fc = ctx();
1699 let lines = tl.break_into_lines(
1700 &fc,
1701 "Hello\nWorld",
1702 200.0,
1703 12.0,
1704 "Helvetica",
1705 400,
1706 FontStyle::Normal,
1707 0.0,
1708 Hyphens::Manual,
1709 None,
1710 );
1711 assert_eq!(lines.len(), 2);
1712 assert_eq!(lines[0].text, "Hello");
1713 assert_eq!(lines[1].text, "World");
1714 }
1715
1716 #[test]
1717 fn test_empty_string() {
1718 let tl = TextLayout::new();
1719 let fc = ctx();
1720 let lines = tl.break_into_lines(
1721 &fc,
1722 "",
1723 200.0,
1724 12.0,
1725 "Helvetica",
1726 400,
1727 FontStyle::Normal,
1728 0.0,
1729 Hyphens::Manual,
1730 None,
1731 );
1732 assert_eq!(lines.len(), 1);
1733 assert_eq!(lines[0].width, 0.0);
1734 }
1735
1736 #[test]
1737 fn test_bold_text_wider() {
1738 let tl = TextLayout::new();
1739 let fc = ctx();
1740 let regular = tl.measure_width(
1741 &fc,
1742 "ABCDEFG",
1743 32.0,
1744 "Helvetica",
1745 400,
1746 FontStyle::Normal,
1747 0.0,
1748 );
1749 let bold = tl.measure_width(
1750 &fc,
1751 "ABCDEFG",
1752 32.0,
1753 "Helvetica",
1754 700,
1755 FontStyle::Normal,
1756 0.0,
1757 );
1758 assert!(
1759 bold > regular,
1760 "Bold text should be wider: bold={bold}, regular={regular}"
1761 );
1762 }
1763
1764 #[test]
1765 fn test_hyphenation_auto_breaks_long_word() {
1766 let tl = TextLayout::new();
1767 let fc = ctx();
1768 let lines = tl.break_into_lines(
1770 &fc,
1771 "extraordinary",
1772 50.0, 12.0,
1774 "Helvetica",
1775 400,
1776 FontStyle::Normal,
1777 0.0,
1778 Hyphens::Auto,
1779 None,
1780 );
1781 assert!(
1783 lines.len() >= 2,
1784 "Auto hyphenation should break 'extraordinary' into multiple lines, got {}",
1785 lines.len()
1786 );
1787 assert!(
1789 lines[0].text.ends_with('-'),
1790 "First line should end with hyphen, got: '{}'",
1791 lines[0].text
1792 );
1793 }
1794
1795 #[test]
1796 fn test_hyphenation_none_forces_break() {
1797 let tl = TextLayout::new();
1798 let fc = ctx();
1799 let lines = tl.break_into_lines(
1800 &fc,
1801 "extraordinary",
1802 50.0,
1803 12.0,
1804 "Helvetica",
1805 400,
1806 FontStyle::Normal,
1807 0.0,
1808 Hyphens::None,
1809 None,
1810 );
1811 assert!(lines.len() >= 2);
1813 assert!(
1815 !lines[0].text.ends_with('-'),
1816 "hyphens:none should not insert hyphens, got: '{}'",
1817 lines[0].text
1818 );
1819 }
1820
1821 #[test]
1822 fn test_hyphenation_manual_uses_soft_hyphens() {
1823 let tl = TextLayout::new();
1824 let fc = ctx();
1825 let lines = tl.break_into_lines(
1827 &fc,
1828 "extra\u{00AD}ordinary",
1829 40.0, 12.0,
1831 "Helvetica",
1832 400,
1833 FontStyle::Normal,
1834 0.0,
1835 Hyphens::Manual,
1836 None,
1837 );
1838 assert!(
1839 lines.len() >= 2,
1840 "Should break at soft hyphen, got {} lines",
1841 lines.len()
1842 );
1843 assert!(
1845 lines[0].text.ends_with('-'),
1846 "Should render visible hyphen at soft-hyphen break, got: '{}'",
1847 lines[0].text
1848 );
1849 for line in &lines {
1851 assert!(
1852 !line.text.contains('\u{00AD}'),
1853 "Soft hyphens should be filtered from output"
1854 );
1855 }
1856 }
1857
1858 #[test]
1859 fn test_hyphenation_prefers_space_over_hyphen() {
1860 let tl = TextLayout::new();
1861 let fc = ctx();
1862 let lines = tl.break_into_lines(
1864 &fc,
1865 "Hello extraordinary",
1866 60.0,
1867 12.0,
1868 "Helvetica",
1869 400,
1870 FontStyle::Normal,
1871 0.0,
1872 Hyphens::Auto,
1873 None,
1874 );
1875 assert!(lines.len() >= 2);
1876 assert!(
1878 lines[0].text.starts_with("Hello"),
1879 "Should break at space first, got: '{}'",
1880 lines[0].text
1881 );
1882 }
1883
1884 #[test]
1885 fn test_min_content_width_with_hyphenation() {
1886 let tl = TextLayout::new();
1887 let fc = ctx();
1888 let auto_width = tl.measure_widest_word(
1889 &fc,
1890 "extraordinary",
1891 12.0,
1892 "Helvetica",
1893 400,
1894 FontStyle::Normal,
1895 0.0,
1896 Hyphens::Auto,
1897 None,
1898 );
1899 let manual_width = tl.measure_widest_word(
1900 &fc,
1901 "extraordinary",
1902 12.0,
1903 "Helvetica",
1904 400,
1905 FontStyle::Normal,
1906 0.0,
1907 Hyphens::Manual,
1908 None,
1909 );
1910 assert!(
1911 auto_width < manual_width,
1912 "Auto hyphenation min-content ({auto_width}) should be less than manual ({manual_width})"
1913 );
1914 }
1915
1916 #[test]
1917 fn test_cjk_break_opportunities() {
1918 let opps = compute_break_opportunities("\u{4F60}\u{597D}\u{4E16}\u{754C}"); let allowed_count = opps
1922 .iter()
1923 .filter(|o| matches!(o, Some(BreakOpportunity::Allowed)))
1924 .count();
1925 assert!(
1926 allowed_count >= 2,
1927 "Should have at least 2 break opportunities between 4 CJK chars, got {}",
1928 allowed_count
1929 );
1930 }
1931
1932 #[test]
1933 fn test_hyphenation_german() {
1934 let tl = TextLayout::new();
1935 let fc = ctx();
1936 let lines = tl.break_into_lines(
1938 &fc,
1939 "Donaudampfschifffahrt",
1940 60.0,
1941 12.0,
1942 "Helvetica",
1943 400,
1944 FontStyle::Normal,
1945 0.0,
1946 Hyphens::Auto,
1947 Some("de"),
1948 );
1949 assert!(
1950 lines.len() >= 2,
1951 "German word should hyphenate with lang='de', got {} lines",
1952 lines.len()
1953 );
1954 assert!(
1955 lines[0].text.ends_with('-'),
1956 "First line should end with hyphen, got: '{}'",
1957 lines[0].text
1958 );
1959 }
1960
1961 #[test]
1962 fn test_hyphenation_unsupported_lang() {
1963 let lang = resolve_hypher_lang(Some("xx-unknown"));
1965 assert!(lang.is_none(), "Unsupported language should return None");
1966 }
1967
1968 #[test]
1969 fn test_resolve_hypher_lang_mapping() {
1970 assert!(matches!(
1971 resolve_hypher_lang(None),
1972 Some(hypher::Lang::English)
1973 ));
1974 assert!(matches!(
1975 resolve_hypher_lang(Some("en")),
1976 Some(hypher::Lang::English)
1977 ));
1978 assert!(matches!(
1979 resolve_hypher_lang(Some("en-US")),
1980 Some(hypher::Lang::English)
1981 ));
1982 assert!(matches!(
1983 resolve_hypher_lang(Some("de")),
1984 Some(hypher::Lang::German)
1985 ));
1986 assert!(matches!(
1987 resolve_hypher_lang(Some("fr")),
1988 Some(hypher::Lang::French)
1989 ));
1990 assert!(matches!(
1991 resolve_hypher_lang(Some("es")),
1992 Some(hypher::Lang::Spanish)
1993 ));
1994 assert!(matches!(
1995 resolve_hypher_lang(Some("nb")),
1996 Some(hypher::Lang::Norwegian)
1997 ));
1998 assert!(matches!(
1999 resolve_hypher_lang(Some("nn")),
2000 Some(hypher::Lang::Norwegian)
2001 ));
2002 assert!(resolve_hypher_lang(Some("zz")).is_none());
2003 }
2004
2005 #[test]
2006 fn test_knuth_plass_fallback_to_greedy() {
2007 let tl = TextLayout::new();
2008 let fc = ctx();
2009 let lines = tl.break_into_lines_optimal(
2011 &fc,
2012 "Hello World",
2013 1.0, 12.0,
2015 "Helvetica",
2016 400,
2017 FontStyle::Normal,
2018 0.0,
2019 Hyphens::Manual,
2020 None,
2021 false,
2022 );
2023 assert!(
2024 !lines.is_empty(),
2025 "Should still produce lines via greedy fallback"
2026 );
2027 }
2028
2029 #[test]
2030 fn test_min_content_width_without_hyphenation() {
2031 let tl = TextLayout::new();
2032 let fc = ctx();
2033 let manual_width = tl.measure_widest_word(
2034 &fc,
2035 "extraordinary",
2036 12.0,
2037 "Helvetica",
2038 400,
2039 FontStyle::Normal,
2040 0.0,
2041 Hyphens::Manual,
2042 None,
2043 );
2044 let full_width = tl.measure_width(
2045 &fc,
2046 "extraordinary",
2047 12.0,
2048 "Helvetica",
2049 400,
2050 FontStyle::Normal,
2051 0.0,
2052 );
2053 assert!(
2054 (manual_width - full_width).abs() < 0.01,
2055 "Manual min-content ({manual_width}) should equal full word width ({full_width})"
2056 );
2057 }
2058
2059 #[test]
2060 fn test_truncate_ellipsis_narrow() {
2061 let tl = TextLayout::new();
2062 let fc = ctx();
2063 let lines = tl.break_into_lines(
2064 &fc,
2065 "Hello World this is a long text",
2066 200.0,
2067 12.0,
2068 "Helvetica",
2069 400,
2070 FontStyle::Normal,
2071 0.0,
2072 Hyphens::Manual,
2073 None,
2074 );
2075 assert!(lines.len() >= 1);
2077
2078 let truncated = tl.truncate_with_ellipsis(
2079 &fc,
2080 lines,
2081 60.0, 12.0,
2083 "Helvetica",
2084 400,
2085 FontStyle::Normal,
2086 0.0,
2087 );
2088 assert_eq!(truncated.len(), 1, "Should be single line");
2089 assert!(
2090 truncated[0].text.ends_with('\u{2026}'),
2091 "Should end with ellipsis: {:?}",
2092 truncated[0].text
2093 );
2094 assert!(
2095 truncated[0].width <= 60.0 + 0.1,
2096 "Should fit within max_width"
2097 );
2098 }
2099
2100 #[test]
2101 fn test_truncate_ellipsis_fits() {
2102 let tl = TextLayout::new();
2103 let fc = ctx();
2104 let lines = tl.break_into_lines(
2105 &fc,
2106 "Hi",
2107 200.0,
2108 12.0,
2109 "Helvetica",
2110 400,
2111 FontStyle::Normal,
2112 0.0,
2113 Hyphens::Manual,
2114 None,
2115 );
2116 let truncated = tl.truncate_with_ellipsis(
2117 &fc,
2118 lines,
2119 200.0,
2120 12.0,
2121 "Helvetica",
2122 400,
2123 FontStyle::Normal,
2124 0.0,
2125 );
2126 assert_eq!(truncated.len(), 1);
2127 assert_eq!(
2128 truncated[0].text, "Hi",
2129 "Short text should not get ellipsis"
2130 );
2131 }
2132
2133 #[test]
2134 fn test_truncate_clip() {
2135 let tl = TextLayout::new();
2136 let fc = ctx();
2137 let lines = tl.break_into_lines(
2138 &fc,
2139 "Hello World this is a long text",
2140 200.0,
2141 12.0,
2142 "Helvetica",
2143 400,
2144 FontStyle::Normal,
2145 0.0,
2146 Hyphens::Manual,
2147 None,
2148 );
2149 let truncated = tl.truncate_clip(
2150 &fc,
2151 lines,
2152 60.0,
2153 12.0,
2154 "Helvetica",
2155 400,
2156 FontStyle::Normal,
2157 0.0,
2158 );
2159 assert_eq!(truncated.len(), 1, "Should be single line");
2160 assert!(
2161 !truncated[0].text.contains('\u{2026}'),
2162 "Clip should not have ellipsis"
2163 );
2164 assert!(
2165 truncated[0].width <= 60.0 + 0.1,
2166 "Should fit within max_width"
2167 );
2168 }
2169
2170 #[test]
2171 fn test_greedy_vs_optimal_produce_different_breaks() {
2172 let tl = TextLayout::new();
2175 let fc = ctx();
2176 let text = "The extraordinary effectiveness of mathematics in the natural sciences is something bordering on the mysterious. There is no rational explanation for it. It is not at all natural that laws of nature exist, much less that man is able to discover them. The miracle of the appropriateness of the language of mathematics for the formulation of the laws of physics is a wonderful gift which we neither understand nor deserve.";
2177
2178 let mut found_divergence = false;
2179 let mut divergence_info = String::new();
2180
2181 for width in [
2183 100.0, 120.0, 140.0, 150.0, 160.0, 170.0, 180.0, 184.0, 200.0,
2184 ] {
2185 let greedy = tl.break_into_lines(
2186 &fc,
2187 text,
2188 width,
2189 10.0,
2190 "Helvetica",
2191 400,
2192 FontStyle::Normal,
2193 0.0,
2194 Hyphens::Auto,
2195 Some("en"),
2196 );
2197 let optimal = tl.break_into_lines_optimal(
2198 &fc,
2199 text,
2200 width,
2201 10.0,
2202 "Helvetica",
2203 400,
2204 FontStyle::Normal,
2205 0.0,
2206 Hyphens::Auto,
2207 Some("en"),
2208 false,
2209 );
2210
2211 let greedy_texts: Vec<&str> = greedy.iter().map(|l| l.text.as_str()).collect();
2212 let optimal_texts: Vec<&str> = optimal.iter().map(|l| l.text.as_str()).collect();
2213
2214 if greedy_texts != optimal_texts {
2215 found_divergence = true;
2216 divergence_info = format!(
2217 "font_size=10, width={}: greedy={} lines, optimal={} lines\nGreedy:\n{}\nOptimal:\n{}",
2218 width, greedy.len(), optimal.len(),
2219 greedy_texts.iter().enumerate().map(|(i, t)| format!(" {}: {:?}", i, t)).collect::<Vec<_>>().join("\n"),
2220 optimal_texts.iter().enumerate().map(|(i, t)| format!(" {}: {:?}", i, t)).collect::<Vec<_>>().join("\n"),
2221 );
2222 break;
2223 }
2224 }
2225
2226 assert!(
2227 found_divergence,
2228 "Greedy and optimal should produce different line breaks at some width with 10pt font."
2229 );
2230 eprintln!("Found divergence: {}", divergence_info);
2231 }
2232
2233 #[test]
2234 fn test_line_widths_do_not_exceed_available_width() {
2235 let tl = TextLayout::new();
2236 let fc = ctx();
2237 let french = "Le chiffre d'affaires consolide a atteint douze virgule un millions de dollars, soit une augmentation de vingt-trois pour cent par rapport a l'exercice precedent. L'expansion dans trois nouveaux marches a contribue a une croissance trimestrielle de trente et un pour cent des nouvelles acquisitions de clients.";
2238 let german = "Das vierte Quartal verzeichnete ein starkes Umsatzwachstum in allen Regionen. Die Kundenbindungsrate blieb mit vierundneunzig Prozent auf einem hervorragenden Niveau, was die kontinuierlichen Investitionen in Produktqualitat und Kundenbetreuung widerspiegelt.";
2239
2240 let max_width = 229.0;
2241 let font_size = 8.0;
2242
2243 for (label, text, lang) in [("French", french, "fr"), ("German", german, "de")] {
2244 let greedy = tl.break_into_lines(
2246 &fc,
2247 text,
2248 max_width,
2249 font_size,
2250 "Helvetica",
2251 400,
2252 FontStyle::Normal,
2253 0.0,
2254 Hyphens::Auto,
2255 Some(lang),
2256 );
2257 let optimal = tl.break_into_lines_optimal(
2258 &fc,
2259 text,
2260 max_width,
2261 font_size,
2262 "Helvetica",
2263 400,
2264 FontStyle::Normal,
2265 0.0,
2266 Hyphens::Auto,
2267 Some(lang),
2268 true,
2269 );
2270
2271 for (algo, lines) in [("greedy", &greedy), ("optimal", &optimal)] {
2272 for (i, line) in lines.iter().enumerate() {
2273 assert!(
2275 line.width <= max_width + 0.01,
2276 "{} {} line {} width exceeds: {:.4} > {:.4} (text: {:?})",
2277 label,
2278 algo,
2279 i,
2280 line.width,
2281 max_width,
2282 line.text,
2283 );
2284
2285 if !line.chars.is_empty() {
2287 let last_idx = line.chars.len() - 1;
2288 let last_pos = line.char_positions.get(last_idx).copied().unwrap_or(0.0);
2289 let last_char = line.chars[last_idx];
2290 let last_advance =
2291 fc.char_width(last_char, "Helvetica", 400, false, font_size);
2292 let rendered_width = (last_pos + last_advance).max(line.width * 0.5);
2293 eprintln!(
2294 "{} {} line {}: width={:.4}, rendered={:.4}, max={:.4}, last_char={:?}, text={:?}",
2295 label, algo, i, line.width, rendered_width, max_width, last_char, line.text,
2296 );
2297 }
2298 }
2299 }
2300 }
2301 }
2302}