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