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