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 mut items = knuth_plass::build_items(
1237 &chars,
1238 &char_widths,
1239 hyphen_width,
1240 hyphens,
1241 &break_opps,
1242 lang,
1243 );
1244 if !justify {
1245 knuth_plass::disallow_shrink(&mut items);
1246 }
1247 knuth_plass::hang_trailing_letter_spacing(&mut items, |_| letter_spacing);
1248 let config = knuth_plass::Config {
1249 line_width: max_width,
1250 ..Default::default()
1251 };
1252
1253 if let Some(solutions) = knuth_plass::find_breaks(&items, &config) {
1254 knuth_plass::reconstruct_lines(
1255 &solutions,
1256 &items,
1257 &chars,
1258 &char_widths,
1259 max_width,
1260 justify,
1261 )
1262 } else {
1263 self.break_into_lines(
1265 font_context,
1266 text,
1267 max_width,
1268 font_size,
1269 font_family,
1270 font_weight,
1271 font_style,
1272 letter_spacing,
1273 word_spacing,
1274 hyphens,
1275 lang,
1276 )
1277 }
1278 }
1279
1280 pub fn break_runs_into_lines_optimal(
1284 &self,
1285 font_context: &FontContext,
1286 chars: &[StyledChar],
1287 max_width: f64,
1288 hyphens: Hyphens,
1289 lang: Option<&str>,
1290 justify: bool,
1291 ) -> Vec<RunBrokenLine> {
1292 if chars.is_empty() {
1293 return vec![RunBrokenLine {
1294 chars: vec![],
1295 char_positions: vec![],
1296 width: 0.0,
1297 }];
1298 }
1299
1300 let char_widths = self.measure_styled_chars(font_context, chars);
1301
1302 let hyphen_width = if !chars.is_empty() {
1304 let sc = &chars[0];
1305 let italic = matches!(sc.font_style, FontStyle::Italic | FontStyle::Oblique);
1306 font_context.char_width('-', &sc.font_family, sc.font_weight, italic, sc.font_size)
1307 + sc.letter_spacing
1308 } else {
1309 0.0
1310 };
1311
1312 let plain_text: String = chars.iter().map(|sc| sc.ch).collect();
1313 let break_opps = compute_break_opportunities(&plain_text);
1314
1315 let plain_chars: Vec<char> = chars.iter().map(|sc| sc.ch).collect();
1317 let has_mandatory = break_opps
1318 .iter()
1319 .any(|o| matches!(o, Some(BreakOpportunity::Mandatory)));
1320
1321 if has_mandatory {
1322 let mut all_lines = Vec::new();
1323 let mut seg_start = 0;
1324
1325 for (i, opp) in break_opps.iter().enumerate() {
1326 if let Some(BreakOpportunity::Mandatory) = opp {
1327 let end = if i > 0
1328 && (plain_chars[i - 1] == '\n'
1329 || plain_chars[i - 1] == '\r'
1330 || plain_chars[i - 1] == '\u{2028}'
1331 || plain_chars[i - 1] == '\u{2029}')
1332 {
1333 i - 1
1334 } else {
1335 i
1336 };
1337 let seg_chars: Vec<StyledChar> = chars[seg_start..end]
1338 .iter()
1339 .filter(|sc| {
1340 sc.ch != '\n'
1341 && sc.ch != '\r'
1342 && sc.ch != '\u{2028}'
1343 && sc.ch != '\u{2029}'
1344 })
1345 .cloned()
1346 .collect();
1347 let seg_lines = self.break_runs_into_lines_optimal(
1348 font_context,
1349 &seg_chars,
1350 max_width,
1351 hyphens,
1352 lang,
1353 justify,
1354 );
1355 all_lines.extend(seg_lines);
1356 seg_start = i;
1357 }
1358 }
1359 let seg_chars: Vec<StyledChar> = chars[seg_start..]
1361 .iter()
1362 .filter(|sc| {
1363 sc.ch != '\n' && sc.ch != '\r' && sc.ch != '\u{2028}' && sc.ch != '\u{2029}'
1364 })
1365 .cloned()
1366 .collect();
1367 if !seg_chars.is_empty() {
1368 let seg_lines = self.break_runs_into_lines_optimal(
1369 font_context,
1370 &seg_chars,
1371 max_width,
1372 hyphens,
1373 lang,
1374 justify,
1375 );
1376 all_lines.extend(seg_lines);
1377 }
1378 return all_lines;
1379 }
1380
1381 let mut items = knuth_plass::build_items_styled(
1382 chars,
1383 &char_widths,
1384 hyphen_width,
1385 hyphens,
1386 &break_opps,
1387 lang,
1388 );
1389 if !justify {
1390 knuth_plass::disallow_shrink(&mut items);
1391 }
1392 knuth_plass::hang_trailing_letter_spacing(&mut items, |i| chars[i].letter_spacing);
1393 let config = knuth_plass::Config {
1394 line_width: max_width,
1395 ..Default::default()
1396 };
1397
1398 if let Some(solutions) = knuth_plass::find_breaks(&items, &config) {
1399 knuth_plass::reconstruct_run_lines(
1400 &solutions,
1401 &items,
1402 chars,
1403 &char_widths,
1404 max_width,
1405 justify,
1406 )
1407 } else {
1408 self.break_runs_into_lines(font_context, chars, max_width, hyphens, lang)
1410 }
1411 }
1412
1413 #[allow(clippy::too_many_arguments)]
1415 pub fn truncate_with_ellipsis(
1416 &self,
1417 font_context: &FontContext,
1418 mut lines: Vec<BrokenLine>,
1419 max_width: f64,
1420 font_size: f64,
1421 font_family: &str,
1422 font_weight: u32,
1423 font_style: FontStyle,
1424 letter_spacing: f64,
1425 word_spacing: f64,
1426 ) -> Vec<BrokenLine> {
1427 if lines.is_empty() {
1428 return lines;
1429 }
1430
1431 let mut all_chars: Vec<char> = Vec::new();
1434 for line in &lines {
1435 all_chars.extend(&line.chars);
1436 }
1437 lines.truncate(1);
1438
1439 let ellipsis = '\u{2026}'; let italic = matches!(font_style, FontStyle::Italic | FontStyle::Oblique);
1441 let ellipsis_width =
1442 font_context.char_width(ellipsis, font_family, font_weight, italic, font_size)
1443 + letter_spacing;
1444
1445 let char_widths = self.measure_chars(
1447 font_context,
1448 &all_chars.iter().collect::<String>(),
1449 font_size,
1450 font_family,
1451 font_weight,
1452 font_style,
1453 letter_spacing,
1454 word_spacing,
1455 );
1456
1457 let total_width: f64 = char_widths.iter().sum();
1458 if total_width <= max_width {
1459 let mut x = 0.0;
1461 let positions: Vec<f64> = char_widths
1462 .iter()
1463 .map(|w| {
1464 let pos = x;
1465 x += w;
1466 pos
1467 })
1468 .collect();
1469 lines[0] = BrokenLine {
1470 chars: all_chars.clone(),
1471 text: all_chars.iter().collect(),
1472 char_positions: positions,
1473 width: total_width,
1474 };
1475 return lines;
1476 }
1477
1478 let target_width = max_width - ellipsis_width;
1480 let mut width = 0.0;
1481 let mut keep = 0;
1482 for (i, &cw) in char_widths.iter().enumerate() {
1483 if width + cw > target_width {
1484 break;
1485 }
1486 width += cw;
1487 keep = i + 1;
1488 }
1489
1490 while keep > 0 && all_chars[keep - 1].is_whitespace() {
1492 keep -= 1;
1493 }
1494
1495 let mut truncated_chars: Vec<char> = all_chars[..keep].to_vec();
1496 truncated_chars.push(ellipsis);
1497
1498 let mut x = 0.0;
1499 let mut positions: Vec<f64> = char_widths[..keep]
1500 .iter()
1501 .map(|w| {
1502 let pos = x;
1503 x += w;
1504 pos
1505 })
1506 .collect();
1507 positions.push(x);
1508 let final_width = x + ellipsis_width;
1509
1510 lines[0] = BrokenLine {
1511 text: truncated_chars.iter().collect(),
1512 chars: truncated_chars,
1513 char_positions: positions,
1514 width: final_width,
1515 };
1516 lines
1517 }
1518
1519 #[allow(clippy::too_many_arguments)]
1521 pub fn truncate_clip(
1522 &self,
1523 font_context: &FontContext,
1524 mut lines: Vec<BrokenLine>,
1525 max_width: f64,
1526 font_size: f64,
1527 font_family: &str,
1528 font_weight: u32,
1529 font_style: FontStyle,
1530 letter_spacing: f64,
1531 word_spacing: f64,
1532 ) -> Vec<BrokenLine> {
1533 if lines.is_empty() {
1534 return lines;
1535 }
1536
1537 let mut all_chars: Vec<char> = Vec::new();
1538 for line in &lines {
1539 all_chars.extend(&line.chars);
1540 }
1541 lines.truncate(1);
1542
1543 let char_widths = self.measure_chars(
1544 font_context,
1545 &all_chars.iter().collect::<String>(),
1546 font_size,
1547 font_family,
1548 font_weight,
1549 font_style,
1550 letter_spacing,
1551 word_spacing,
1552 );
1553
1554 let total_width: f64 = char_widths.iter().sum();
1555 if total_width <= max_width {
1556 let mut x = 0.0;
1557 let positions: Vec<f64> = char_widths
1558 .iter()
1559 .map(|w| {
1560 let pos = x;
1561 x += w;
1562 pos
1563 })
1564 .collect();
1565 lines[0] = BrokenLine {
1566 chars: all_chars.clone(),
1567 text: all_chars.iter().collect(),
1568 char_positions: positions,
1569 width: total_width,
1570 };
1571 return lines;
1572 }
1573
1574 let mut width = 0.0;
1575 let mut keep = 0;
1576 for (i, &cw) in char_widths.iter().enumerate() {
1577 if width + cw > max_width {
1578 break;
1579 }
1580 width += cw;
1581 keep = i + 1;
1582 }
1583
1584 let truncated_chars: Vec<char> = all_chars[..keep].to_vec();
1585 let mut x = 0.0;
1586 let positions: Vec<f64> = char_widths[..keep]
1587 .iter()
1588 .map(|w| {
1589 let pos = x;
1590 x += w;
1591 pos
1592 })
1593 .collect();
1594
1595 lines[0] = BrokenLine {
1596 text: truncated_chars.iter().collect(),
1597 chars: truncated_chars,
1598 char_positions: positions,
1599 width,
1600 };
1601 lines
1602 }
1603
1604 pub fn truncate_runs_with_ellipsis(
1606 &self,
1607 font_context: &FontContext,
1608 mut lines: Vec<RunBrokenLine>,
1609 max_width: f64,
1610 ) -> Vec<RunBrokenLine> {
1611 if lines.is_empty() {
1612 return lines;
1613 }
1614
1615 let mut all_chars: Vec<StyledChar> = Vec::new();
1617 for line in &lines {
1618 all_chars.extend(line.chars.iter().cloned());
1619 }
1620 lines.truncate(1);
1621
1622 let char_widths = self.measure_styled_chars(font_context, &all_chars);
1623 let total_width: f64 = char_widths.iter().sum();
1624
1625 if total_width <= max_width {
1626 let mut x = 0.0;
1627 let positions: Vec<f64> = char_widths
1628 .iter()
1629 .map(|w| {
1630 let pos = x;
1631 x += w;
1632 pos
1633 })
1634 .collect();
1635 lines[0] = RunBrokenLine {
1636 chars: all_chars,
1637 char_positions: positions,
1638 width: total_width,
1639 };
1640 return lines;
1641 }
1642
1643 let last_style = all_chars.last().unwrap();
1645 let italic = matches!(
1646 last_style.font_style,
1647 FontStyle::Italic | FontStyle::Oblique
1648 );
1649 let ellipsis_width = font_context.char_width(
1650 '\u{2026}',
1651 &last_style.font_family,
1652 last_style.font_weight,
1653 italic,
1654 last_style.font_size,
1655 ) + last_style.letter_spacing;
1656
1657 let target_width = max_width - ellipsis_width;
1658 let mut width = 0.0;
1659 let mut keep = 0;
1660 for (i, &cw) in char_widths.iter().enumerate() {
1661 if width + cw > target_width {
1662 break;
1663 }
1664 width += cw;
1665 keep = i + 1;
1666 }
1667
1668 while keep > 0 && all_chars[keep - 1].ch.is_whitespace() {
1669 keep -= 1;
1670 }
1671
1672 let mut truncated: Vec<StyledChar> = all_chars[..keep].to_vec();
1673 let ellipsis_style = if keep > 0 {
1675 all_chars[keep - 1].clone()
1676 } else {
1677 last_style.clone()
1678 };
1679 truncated.push(StyledChar {
1680 ch: '\u{2026}',
1681 ..ellipsis_style
1682 });
1683
1684 let mut x = 0.0;
1685 let mut positions: Vec<f64> = char_widths[..keep]
1686 .iter()
1687 .map(|w| {
1688 let pos = x;
1689 x += w;
1690 pos
1691 })
1692 .collect();
1693 positions.push(x);
1694
1695 lines[0] = RunBrokenLine {
1696 chars: truncated,
1697 char_positions: positions,
1698 width: x + ellipsis_width,
1699 };
1700 lines
1701 }
1702
1703 pub fn truncate_runs_clip(
1705 &self,
1706 font_context: &FontContext,
1707 mut lines: Vec<RunBrokenLine>,
1708 max_width: f64,
1709 ) -> Vec<RunBrokenLine> {
1710 if lines.is_empty() {
1711 return lines;
1712 }
1713
1714 let mut all_chars: Vec<StyledChar> = Vec::new();
1715 for line in &lines {
1716 all_chars.extend(line.chars.iter().cloned());
1717 }
1718 lines.truncate(1);
1719
1720 let char_widths = self.measure_styled_chars(font_context, &all_chars);
1721 let total_width: f64 = char_widths.iter().sum();
1722
1723 if total_width <= max_width {
1724 let mut x = 0.0;
1725 let positions: Vec<f64> = char_widths
1726 .iter()
1727 .map(|w| {
1728 let pos = x;
1729 x += w;
1730 pos
1731 })
1732 .collect();
1733 lines[0] = RunBrokenLine {
1734 chars: all_chars,
1735 char_positions: positions,
1736 width: total_width,
1737 };
1738 return lines;
1739 }
1740
1741 let mut width = 0.0;
1742 let mut keep = 0;
1743 for (i, &cw) in char_widths.iter().enumerate() {
1744 if width + cw > max_width {
1745 break;
1746 }
1747 width += cw;
1748 keep = i + 1;
1749 }
1750
1751 let truncated: Vec<StyledChar> = all_chars[..keep].to_vec();
1752 let mut x = 0.0;
1753 let positions: Vec<f64> = char_widths[..keep]
1754 .iter()
1755 .map(|w| {
1756 let pos = x;
1757 x += w;
1758 pos
1759 })
1760 .collect();
1761
1762 lines[0] = RunBrokenLine {
1763 chars: truncated,
1764 char_positions: positions,
1765 width,
1766 };
1767 lines
1768 }
1769}
1770
1771#[cfg(test)]
1772mod tests {
1773 use super::*;
1774
1775 fn ctx() -> FontContext {
1776 FontContext::new()
1777 }
1778
1779 #[test]
1780 fn test_single_line() {
1781 let tl = TextLayout::new();
1782 let fc = ctx();
1783 let lines = tl.break_into_lines(
1784 &fc,
1785 "Hello",
1786 200.0,
1787 12.0,
1788 "Helvetica",
1789 400,
1790 FontStyle::Normal,
1791 0.0,
1792 0.0,
1793 Hyphens::Manual,
1794 None,
1795 );
1796 assert_eq!(lines.len(), 1);
1797 assert_eq!(lines[0].text, "Hello");
1798 }
1799
1800 #[test]
1801 fn test_line_break_at_space() {
1802 let tl = TextLayout::new();
1803 let fc = ctx();
1804 let lines = tl.break_into_lines(
1805 &fc,
1806 "Hello World",
1807 40.0,
1808 12.0,
1809 "Helvetica",
1810 400,
1811 FontStyle::Normal,
1812 0.0,
1813 0.0,
1814 Hyphens::Manual,
1815 None,
1816 );
1817 assert!(lines.len() >= 2);
1818 }
1819
1820 #[test]
1821 fn test_explicit_newline() {
1822 let tl = TextLayout::new();
1823 let fc = ctx();
1824 let lines = tl.break_into_lines(
1825 &fc,
1826 "Hello\nWorld",
1827 200.0,
1828 12.0,
1829 "Helvetica",
1830 400,
1831 FontStyle::Normal,
1832 0.0,
1833 0.0,
1834 Hyphens::Manual,
1835 None,
1836 );
1837 assert_eq!(lines.len(), 2);
1838 assert_eq!(lines[0].text, "Hello");
1839 assert_eq!(lines[1].text, "World");
1840 }
1841
1842 #[test]
1843 fn test_empty_string() {
1844 let tl = TextLayout::new();
1845 let fc = ctx();
1846 let lines = tl.break_into_lines(
1847 &fc,
1848 "",
1849 200.0,
1850 12.0,
1851 "Helvetica",
1852 400,
1853 FontStyle::Normal,
1854 0.0,
1855 0.0,
1856 Hyphens::Manual,
1857 None,
1858 );
1859 assert_eq!(lines.len(), 1);
1860 assert_eq!(lines[0].width, 0.0);
1861 }
1862
1863 #[test]
1864 fn test_bold_text_wider() {
1865 let tl = TextLayout::new();
1866 let fc = ctx();
1867 let regular = tl.measure_width(
1868 &fc,
1869 "ABCDEFG",
1870 32.0,
1871 "Helvetica",
1872 400,
1873 FontStyle::Normal,
1874 0.0,
1875 0.0,
1876 );
1877 let bold = tl.measure_width(
1878 &fc,
1879 "ABCDEFG",
1880 32.0,
1881 "Helvetica",
1882 700,
1883 FontStyle::Normal,
1884 0.0,
1885 0.0,
1886 );
1887 assert!(
1888 bold > regular,
1889 "Bold text should be wider: bold={bold}, regular={regular}"
1890 );
1891 }
1892
1893 #[test]
1894 fn test_hyphenation_auto_breaks_long_word() {
1895 let tl = TextLayout::new();
1896 let fc = ctx();
1897 let lines = tl.break_into_lines(
1899 &fc,
1900 "extraordinary",
1901 50.0, 12.0,
1903 "Helvetica",
1904 400,
1905 FontStyle::Normal,
1906 0.0,
1907 0.0,
1908 Hyphens::Auto,
1909 None,
1910 );
1911 assert!(
1913 lines.len() >= 2,
1914 "Auto hyphenation should break 'extraordinary' into multiple lines, got {}",
1915 lines.len()
1916 );
1917 assert!(
1919 lines[0].text.ends_with('-'),
1920 "First line should end with hyphen, got: '{}'",
1921 lines[0].text
1922 );
1923 }
1924
1925 #[test]
1926 fn test_hyphenation_none_forces_break() {
1927 let tl = TextLayout::new();
1928 let fc = ctx();
1929 let lines = tl.break_into_lines(
1930 &fc,
1931 "extraordinary",
1932 50.0,
1933 12.0,
1934 "Helvetica",
1935 400,
1936 FontStyle::Normal,
1937 0.0,
1938 0.0,
1939 Hyphens::None,
1940 None,
1941 );
1942 assert!(lines.len() >= 2);
1944 assert!(
1946 !lines[0].text.ends_with('-'),
1947 "hyphens:none should not insert hyphens, got: '{}'",
1948 lines[0].text
1949 );
1950 }
1951
1952 #[test]
1953 fn test_hyphenation_manual_uses_soft_hyphens() {
1954 let tl = TextLayout::new();
1955 let fc = ctx();
1956 let lines = tl.break_into_lines(
1958 &fc,
1959 "extra\u{00AD}ordinary",
1960 40.0, 12.0,
1962 "Helvetica",
1963 400,
1964 FontStyle::Normal,
1965 0.0,
1966 0.0,
1967 Hyphens::Manual,
1968 None,
1969 );
1970 assert!(
1971 lines.len() >= 2,
1972 "Should break at soft hyphen, got {} lines",
1973 lines.len()
1974 );
1975 assert!(
1977 lines[0].text.ends_with('-'),
1978 "Should render visible hyphen at soft-hyphen break, got: '{}'",
1979 lines[0].text
1980 );
1981 for line in &lines {
1983 assert!(
1984 !line.text.contains('\u{00AD}'),
1985 "Soft hyphens should be filtered from output"
1986 );
1987 }
1988 }
1989
1990 #[test]
1991 fn test_hyphenation_prefers_space_over_hyphen() {
1992 let tl = TextLayout::new();
1993 let fc = ctx();
1994 let lines = tl.break_into_lines(
1996 &fc,
1997 "Hello extraordinary",
1998 60.0,
1999 12.0,
2000 "Helvetica",
2001 400,
2002 FontStyle::Normal,
2003 0.0,
2004 0.0,
2005 Hyphens::Auto,
2006 None,
2007 );
2008 assert!(lines.len() >= 2);
2009 assert!(
2011 lines[0].text.starts_with("Hello"),
2012 "Should break at space first, got: '{}'",
2013 lines[0].text
2014 );
2015 }
2016
2017 #[test]
2018 fn test_min_content_width_with_hyphenation() {
2019 let tl = TextLayout::new();
2020 let fc = ctx();
2021 let auto_width = tl.measure_widest_word(
2022 &fc,
2023 "extraordinary",
2024 12.0,
2025 "Helvetica",
2026 400,
2027 FontStyle::Normal,
2028 0.0,
2029 0.0,
2030 Hyphens::Auto,
2031 None,
2032 );
2033 let manual_width = tl.measure_widest_word(
2034 &fc,
2035 "extraordinary",
2036 12.0,
2037 "Helvetica",
2038 400,
2039 FontStyle::Normal,
2040 0.0,
2041 0.0,
2042 Hyphens::Manual,
2043 None,
2044 );
2045 assert!(
2046 auto_width < manual_width,
2047 "Auto hyphenation min-content ({auto_width}) should be less than manual ({manual_width})"
2048 );
2049 }
2050
2051 #[test]
2052 fn test_cjk_break_opportunities() {
2053 let opps = compute_break_opportunities("\u{4F60}\u{597D}\u{4E16}\u{754C}"); let allowed_count = opps
2057 .iter()
2058 .filter(|o| matches!(o, Some(BreakOpportunity::Allowed)))
2059 .count();
2060 assert!(
2061 allowed_count >= 2,
2062 "Should have at least 2 break opportunities between 4 CJK chars, got {}",
2063 allowed_count
2064 );
2065 }
2066
2067 #[test]
2068 fn test_hyphenation_german() {
2069 let tl = TextLayout::new();
2070 let fc = ctx();
2071 let lines = tl.break_into_lines(
2073 &fc,
2074 "Donaudampfschifffahrt",
2075 60.0,
2076 12.0,
2077 "Helvetica",
2078 400,
2079 FontStyle::Normal,
2080 0.0,
2081 0.0,
2082 Hyphens::Auto,
2083 Some("de"),
2084 );
2085 assert!(
2086 lines.len() >= 2,
2087 "German word should hyphenate with lang='de', got {} lines",
2088 lines.len()
2089 );
2090 assert!(
2091 lines[0].text.ends_with('-'),
2092 "First line should end with hyphen, got: '{}'",
2093 lines[0].text
2094 );
2095 }
2096
2097 #[test]
2098 fn test_hyphenation_unsupported_lang() {
2099 let lang = resolve_hypher_lang(Some("xx-unknown"));
2101 assert!(lang.is_none(), "Unsupported language should return None");
2102 }
2103
2104 #[test]
2105 fn test_resolve_hypher_lang_mapping() {
2106 assert!(matches!(
2107 resolve_hypher_lang(None),
2108 Some(hypher::Lang::English)
2109 ));
2110 assert!(matches!(
2111 resolve_hypher_lang(Some("en")),
2112 Some(hypher::Lang::English)
2113 ));
2114 assert!(matches!(
2115 resolve_hypher_lang(Some("en-US")),
2116 Some(hypher::Lang::English)
2117 ));
2118 assert!(matches!(
2119 resolve_hypher_lang(Some("de")),
2120 Some(hypher::Lang::German)
2121 ));
2122 assert!(matches!(
2123 resolve_hypher_lang(Some("fr")),
2124 Some(hypher::Lang::French)
2125 ));
2126 assert!(matches!(
2127 resolve_hypher_lang(Some("es")),
2128 Some(hypher::Lang::Spanish)
2129 ));
2130 assert!(matches!(
2131 resolve_hypher_lang(Some("nb")),
2132 Some(hypher::Lang::Norwegian)
2133 ));
2134 assert!(matches!(
2135 resolve_hypher_lang(Some("nn")),
2136 Some(hypher::Lang::Norwegian)
2137 ));
2138 assert!(resolve_hypher_lang(Some("zz")).is_none());
2139 }
2140
2141 #[test]
2142 fn test_knuth_plass_fallback_to_greedy() {
2143 let tl = TextLayout::new();
2144 let fc = ctx();
2145 let lines = tl.break_into_lines_optimal(
2147 &fc,
2148 "Hello World",
2149 1.0, 12.0,
2151 "Helvetica",
2152 400,
2153 FontStyle::Normal,
2154 0.0,
2155 0.0,
2156 Hyphens::Manual,
2157 None,
2158 false,
2159 );
2160 assert!(
2161 !lines.is_empty(),
2162 "Should still produce lines via greedy fallback"
2163 );
2164 }
2165
2166 #[test]
2167 fn test_min_content_width_without_hyphenation() {
2168 let tl = TextLayout::new();
2169 let fc = ctx();
2170 let manual_width = tl.measure_widest_word(
2171 &fc,
2172 "extraordinary",
2173 12.0,
2174 "Helvetica",
2175 400,
2176 FontStyle::Normal,
2177 0.0,
2178 0.0,
2179 Hyphens::Manual,
2180 None,
2181 );
2182 let full_width = tl.measure_width(
2183 &fc,
2184 "extraordinary",
2185 12.0,
2186 "Helvetica",
2187 400,
2188 FontStyle::Normal,
2189 0.0,
2190 0.0,
2191 );
2192 assert!(
2193 (manual_width - full_width).abs() < 0.01,
2194 "Manual min-content ({manual_width}) should equal full word width ({full_width})"
2195 );
2196 }
2197
2198 #[test]
2199 fn test_truncate_ellipsis_narrow() {
2200 let tl = TextLayout::new();
2201 let fc = ctx();
2202 let lines = tl.break_into_lines(
2203 &fc,
2204 "Hello World this is a long text",
2205 200.0,
2206 12.0,
2207 "Helvetica",
2208 400,
2209 FontStyle::Normal,
2210 0.0,
2211 0.0,
2212 Hyphens::Manual,
2213 None,
2214 );
2215 assert!(!lines.is_empty());
2217
2218 let truncated = tl.truncate_with_ellipsis(
2219 &fc,
2220 lines,
2221 60.0, 12.0,
2223 "Helvetica",
2224 400,
2225 FontStyle::Normal,
2226 0.0,
2227 0.0,
2228 );
2229 assert_eq!(truncated.len(), 1, "Should be single line");
2230 assert!(
2231 truncated[0].text.ends_with('\u{2026}'),
2232 "Should end with ellipsis: {:?}",
2233 truncated[0].text
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_truncate_ellipsis_fits() {
2243 let tl = TextLayout::new();
2244 let fc = ctx();
2245 let lines = tl.break_into_lines(
2246 &fc,
2247 "Hi",
2248 200.0,
2249 12.0,
2250 "Helvetica",
2251 400,
2252 FontStyle::Normal,
2253 0.0,
2254 0.0,
2255 Hyphens::Manual,
2256 None,
2257 );
2258 let truncated = tl.truncate_with_ellipsis(
2259 &fc,
2260 lines,
2261 200.0,
2262 12.0,
2263 "Helvetica",
2264 400,
2265 FontStyle::Normal,
2266 0.0,
2267 0.0,
2268 );
2269 assert_eq!(truncated.len(), 1);
2270 assert_eq!(
2271 truncated[0].text, "Hi",
2272 "Short text should not get ellipsis"
2273 );
2274 }
2275
2276 #[test]
2277 fn test_truncate_clip() {
2278 let tl = TextLayout::new();
2279 let fc = ctx();
2280 let lines = tl.break_into_lines(
2281 &fc,
2282 "Hello World this is a long text",
2283 200.0,
2284 12.0,
2285 "Helvetica",
2286 400,
2287 FontStyle::Normal,
2288 0.0,
2289 0.0,
2290 Hyphens::Manual,
2291 None,
2292 );
2293 let truncated = tl.truncate_clip(
2294 &fc,
2295 lines,
2296 60.0,
2297 12.0,
2298 "Helvetica",
2299 400,
2300 FontStyle::Normal,
2301 0.0,
2302 0.0,
2303 );
2304 assert_eq!(truncated.len(), 1, "Should be single line");
2305 assert!(
2306 !truncated[0].text.contains('\u{2026}'),
2307 "Clip should not have ellipsis"
2308 );
2309 assert!(
2310 truncated[0].width <= 60.0 + 0.1,
2311 "Should fit within max_width"
2312 );
2313 }
2314
2315 const RAGGED_TEXT: &str = "Payroll, (585) 274-0180 ext. 402. Keep this statement; it is required to support your annual tax return. Address changes must be filed on form F-04 before the period cut-off. Reports submitted more than sixty days after the expense date require an officer's signature.";
2320
2321 #[test]
2322 fn test_optimal_ragged_lines_never_exceed_max_width() {
2323 let tl = TextLayout::new();
2324 let fc = ctx();
2325 for width in [236.25, 200.0, 180.0, 150.0, 120.0] {
2326 let lines = tl.break_into_lines_optimal(
2327 &fc,
2328 RAGGED_TEXT,
2329 width,
2330 6.375,
2331 "Helvetica",
2332 400,
2333 FontStyle::Normal,
2334 0.0,
2335 0.0,
2336 Hyphens::Manual,
2337 Some("en"),
2338 false,
2339 );
2340 for l in &lines {
2341 assert!(
2342 l.width <= width + 0.01,
2343 "width {width}: line {:?} is {:.2}pt, wider than the box",
2344 l.text,
2345 l.width
2346 );
2347 }
2348 }
2349 }
2350
2351 #[test]
2356 fn test_optimal_trailing_letter_spacing_does_not_decide_fit() {
2357 let tl = TextLayout::new();
2358 let fc = ctx();
2359 let lines = tl.break_into_lines_optimal(
2360 &fc,
2361 "PRIYA N. RAGHUNATHAN · QUALITY SYSTEMS MANAGER",
2362 119.45,
2363 5.25,
2364 "Helvetica",
2365 700,
2366 FontStyle::Normal,
2367 0.8925,
2368 0.0,
2369 Hyphens::Manual,
2370 Some("en"),
2371 false,
2372 );
2373 assert_eq!(lines[0].text.trim_end(), "PRIYA N. RAGHUNATHAN · QUALITY");
2374 }
2375
2376 #[test]
2377 fn test_optimal_ragged_run_lines_never_exceed_max_width() {
2378 let tl = TextLayout::new();
2379 let fc = ctx();
2380 let chars: Vec<StyledChar> = RAGGED_TEXT
2381 .chars()
2382 .map(|ch| StyledChar {
2383 ch,
2384 font_family: "Helvetica".to_string(),
2385 font_size: 6.375,
2386 font_weight: 400,
2387 font_style: FontStyle::Normal,
2388 color: Color::BLACK,
2389 href: None,
2390 text_decoration: TextDecoration::None,
2391 letter_spacing: 0.0,
2392 word_spacing: 0.0,
2393 })
2394 .collect();
2395 for width in [236.25, 200.0, 180.0, 150.0, 120.0] {
2396 let lines = tl.break_runs_into_lines_optimal(
2397 &fc,
2398 &chars,
2399 width,
2400 Hyphens::Manual,
2401 Some("en"),
2402 false,
2403 );
2404 for l in &lines {
2405 let text: String = l.chars.iter().map(|c| c.ch).collect();
2406 assert!(
2407 l.width <= width + 0.01,
2408 "width {width}: line {text:?} is {:.2}pt, wider than the box",
2409 l.width
2410 );
2411 }
2412 }
2413 }
2414
2415 #[test]
2416 fn test_greedy_vs_optimal_produce_different_breaks() {
2417 let tl = TextLayout::new();
2420 let fc = ctx();
2421 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.";
2422
2423 let mut found_divergence = false;
2424 let mut divergence_info = String::new();
2425
2426 for width in [
2428 100.0, 120.0, 140.0, 150.0, 160.0, 170.0, 180.0, 184.0, 200.0,
2429 ] {
2430 let greedy = tl.break_into_lines(
2431 &fc,
2432 text,
2433 width,
2434 10.0,
2435 "Helvetica",
2436 400,
2437 FontStyle::Normal,
2438 0.0,
2439 0.0,
2440 Hyphens::Auto,
2441 Some("en"),
2442 );
2443 let optimal = tl.break_into_lines_optimal(
2444 &fc,
2445 text,
2446 width,
2447 10.0,
2448 "Helvetica",
2449 400,
2450 FontStyle::Normal,
2451 0.0,
2452 0.0,
2453 Hyphens::Auto,
2454 Some("en"),
2455 false,
2456 );
2457
2458 let greedy_texts: Vec<&str> = greedy.iter().map(|l| l.text.as_str()).collect();
2459 let optimal_texts: Vec<&str> = optimal.iter().map(|l| l.text.as_str()).collect();
2460
2461 if greedy_texts != optimal_texts {
2462 found_divergence = true;
2463 divergence_info = format!(
2464 "font_size=10, width={}: greedy={} lines, optimal={} lines\nGreedy:\n{}\nOptimal:\n{}",
2465 width, greedy.len(), optimal.len(),
2466 greedy_texts.iter().enumerate().map(|(i, t)| format!(" {}: {:?}", i, t)).collect::<Vec<_>>().join("\n"),
2467 optimal_texts.iter().enumerate().map(|(i, t)| format!(" {}: {:?}", i, t)).collect::<Vec<_>>().join("\n"),
2468 );
2469 break;
2470 }
2471 }
2472
2473 assert!(
2474 found_divergence,
2475 "Greedy and optimal should produce different line breaks at some width with 10pt font."
2476 );
2477 eprintln!("Found divergence: {}", divergence_info);
2478 }
2479
2480 #[test]
2481 fn test_line_widths_do_not_exceed_available_width() {
2482 let tl = TextLayout::new();
2483 let fc = ctx();
2484 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.";
2485 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.";
2486
2487 let max_width = 229.0;
2488 let font_size = 8.0;
2489
2490 for (label, text, lang) in [("French", french, "fr"), ("German", german, "de")] {
2491 let greedy = tl.break_into_lines(
2493 &fc,
2494 text,
2495 max_width,
2496 font_size,
2497 "Helvetica",
2498 400,
2499 FontStyle::Normal,
2500 0.0,
2501 0.0,
2502 Hyphens::Auto,
2503 Some(lang),
2504 );
2505 let optimal = tl.break_into_lines_optimal(
2506 &fc,
2507 text,
2508 max_width,
2509 font_size,
2510 "Helvetica",
2511 400,
2512 FontStyle::Normal,
2513 0.0,
2514 0.0,
2515 Hyphens::Auto,
2516 Some(lang),
2517 true,
2518 );
2519
2520 for (algo, lines) in [("greedy", &greedy), ("optimal", &optimal)] {
2521 for (i, line) in lines.iter().enumerate() {
2522 assert!(
2524 line.width <= max_width + 0.01,
2525 "{} {} line {} width exceeds: {:.4} > {:.4} (text: {:?})",
2526 label,
2527 algo,
2528 i,
2529 line.width,
2530 max_width,
2531 line.text,
2532 );
2533
2534 if !line.chars.is_empty() {
2536 let last_idx = line.chars.len() - 1;
2537 let last_pos = line.char_positions.get(last_idx).copied().unwrap_or(0.0);
2538 let last_char = line.chars[last_idx];
2539 let last_advance =
2540 fc.char_width(last_char, "Helvetica", 400, false, font_size);
2541 let rendered_width = (last_pos + last_advance).max(line.width * 0.5);
2542 eprintln!(
2543 "{} {} line {}: width={:.4}, rendered={:.4}, max={:.4}, last_char={:?}, text={:?}",
2544 label, algo, i, line.width, rendered_width, max_width, last_char, line.text,
2545 );
2546 }
2547 }
2548 }
2549 }
2550 }
2551}