Skip to main content

forme/text/
mod.rs

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