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