Skip to main content

forme/text/
mod.rs

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