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