Skip to main content

forme/text/
mod.rs

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