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