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