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