Skip to main content

text_typeset/layout/
paragraph.rs

1use std::collections::HashSet;
2use std::ops::Range;
3use std::sync::OnceLock;
4
5use icu_segmenter::LineSegmenter;
6use icu_segmenter::options::LineBreakOptions;
7
8use crate::layout::line::{LayoutLine, PositionedRun, RunDecorations};
9use crate::shaping::run::{ShapedGlyph, ShapedRun};
10use crate::shaping::shaper::{FontMetricsPx, TextDirection};
11
12/// How [`break_into_lines`] should treat the order of the runs it is given.
13///
14/// The two layout paths differ here and getting it wrong reverses text
15/// twice. The single-line path runs the bidi algorithm itself and shapes
16/// in display order, so its runs must be left alone; the block path hands
17/// over logical-order runs tagged with embedding levels and needs each
18/// line reordered once the breaks are known.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum RunOrder {
21    /// Runs already arrive in visual order — do not reorder them.
22    AlreadyVisual,
23    /// Runs are in logical order; reorder each line per UAX #9 rule L2,
24    /// and resolve `Start`/`End` alignment against this base direction.
25    Logical(TextDirection),
26}
27
28/// Whether a break opportunity *must* be taken (LB4/LB5 hard line
29/// break) or *may* be taken (regular UAX #14 break opportunity).
30///
31/// `icu_segmenter::LineSegmenter` doesn't distinguish the two — it just
32/// emits byte offsets — so we classify each emitted offset ourselves by
33/// looking at the line-break property of the preceding code point.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35enum BreakOpportunity {
36    Allowed,
37    Mandatory,
38}
39
40/// Shared compiled-data line segmenter. `LineSegmenter::new_auto`
41/// returns a `LineSegmenterBorrowed<'static>` (Copy, statically-baked
42/// CLDR data), but constructing it still touches the option-parsing
43/// path — cache once to keep `break_into_lines` allocation-free.
44fn line_segmenter() -> icu_segmenter::LineSegmenterBorrowed<'static> {
45    static CELL: OnceLock<icu_segmenter::LineSegmenterBorrowed<'static>> = OnceLock::new();
46    *CELL.get_or_init(|| LineSegmenter::new_auto(LineBreakOptions::default()))
47}
48
49/// UAX #14 LB4/LB5: a break at `byte_offset` is mandatory iff the
50/// immediately-preceding code point is one of the hard line break
51/// characters (LF, CR, NEL, VT, FF, LS, PS). The CR+LF sequence is
52/// handled implicitly: the segmenter emits a single break after the
53/// LF, and the char before that offset is `\n` — mandatory.
54fn is_mandatory_break_at(text: &str, byte_offset: usize) -> bool {
55    if byte_offset == 0 {
56        return false;
57    }
58    let preceding = &text[..byte_offset];
59    matches!(
60        preceding.chars().next_back(),
61        Some('\n' | '\r' | '\u{0085}' | '\u{000B}' | '\u{000C}' | '\u{2028}' | '\u{2029}')
62    )
63}
64
65/// Enumerate UAX #14 break opportunities in `text` and classify each
66/// as `Allowed` or `Mandatory`. Replaces the previous
67/// `unicode_linebreak::linebreaks(text)` call site one-to-one.
68fn enumerate_breaks(text: &str) -> Vec<(usize, BreakOpportunity)> {
69    line_segmenter()
70        .segment_str(text)
71        .map(|byte_offset| {
72            let kind = if is_mandatory_break_at(text, byte_offset) {
73                BreakOpportunity::Mandatory
74            } else {
75                BreakOpportunity::Allowed
76            };
77            (byte_offset, kind)
78        })
79        .collect()
80}
81
82/// Byte offsets in `text` where a hyphenated line break may occur and a
83/// hyphen glyph should be rendered: Knuth-Liang dictionary points inside
84/// words (in `lang_code`, an ISO 639-1 code) plus soft-hyphen (U+00AD)
85/// positions.
86///
87/// Returned offsets are "break before this byte" positions, matching the
88/// UAX #14 offsets from [`enumerate_breaks`]. Soft hyphens are honored
89/// regardless of language; dictionary breaks apply only when the
90/// language's patterns are compiled in (`hypher::Lang::from_iso` resolves
91/// it) — otherwise it gracefully degrades to soft-hyphen-only.
92fn hyphenation_breaks(text: &str, lang_code: [u8; 2]) -> Vec<usize> {
93    use hypher::hyphenate;
94
95    let mut offsets = Vec::new();
96
97    // Soft hyphens: break after the U+00AD so the hyphen renders at line end.
98    for (idx, ch) in text.char_indices() {
99        if ch == '\u{00AD}' {
100            offsets.push(idx + ch.len_utf8());
101        }
102    }
103
104    // Dictionary hyphenation, word by word — only if the language resolves.
105    if let Some(lang) = hypher::Lang::from_iso(lang_code) {
106        let mut word_start: Option<usize> = None;
107        let flush = |start: usize, end: usize, offsets: &mut Vec<usize>| {
108            let word = &text[start..end];
109            // Skip trivially short words; `hyphenate` would yield no interior
110            // breaks anyway, and this avoids the call overhead.
111            if word.chars().count() < 5 {
112                return;
113            }
114            let mut pos = start;
115            let mut syllables = hyphenate(word, lang).peekable();
116            while let Some(syl) = syllables.next() {
117                pos += syl.len();
118                // A break sits between syllables, not after the last one.
119                if syllables.peek().is_some() {
120                    offsets.push(pos);
121                }
122            }
123        };
124        for (idx, ch) in text.char_indices() {
125            if ch.is_alphabetic() {
126                word_start.get_or_insert(idx);
127            } else if let Some(start) = word_start.take() {
128                flush(start, idx, &mut offsets);
129            }
130        }
131        if let Some(start) = word_start.take() {
132            flush(start, text.len(), &mut offsets);
133        }
134    }
135
136    offsets.sort_unstable();
137    offsets.dedup();
138    offsets
139}
140
141/// Map hyphenation byte offsets to glyph indices (first glyph whose
142/// cluster is `>=` the offset), mirroring [`map_breaks_to_glyph_indices`].
143fn map_offsets_to_glyph_indices(flat: &[FlatGlyph], offsets: &[usize]) -> HashSet<usize> {
144    let mut set = HashSet::new();
145    let mut cursor = 0usize;
146    for &byte_offset in offsets {
147        while cursor < flat.len() && (flat[cursor].cluster as usize) < byte_offset {
148            cursor += 1;
149        }
150        set.insert(cursor.min(flat.len()));
151    }
152    set
153}
154
155/// Append a rendered hyphen glyph to the end of a line that broke at a
156/// hyphenation point, accounting for its advance in the run and line
157/// widths. The hyphen inherits the last glyph's cluster so caret/hit
158/// math treats it as part of the final character.
159fn append_hyphen(line: &mut LayoutLine, hyphen: &ShapedGlyph) {
160    if let Some(run) = line.runs.last_mut() {
161        let mut g = hyphen.clone();
162        g.cluster = run
163            .shaped_run
164            .glyphs
165            .last()
166            .map(|gl| gl.cluster)
167            .unwrap_or(0);
168        run.shaped_run.glyphs.push(g);
169        run.shaped_run.advance_width += hyphen.x_advance;
170        line.width += hyphen.x_advance;
171    }
172}
173
174/// Convert a byte offset within a UTF-8 string to a char offset.
175///
176/// Clamps to `text.len()` and rounds down to the nearest char boundary
177/// if `byte_offset` lands inside a multi-byte character. HarfBuzz can
178/// emit cluster values that don't coincide with UTF-8 char boundaries
179/// (ligature splits, fallback shaping), so callers must never assume
180/// cluster values are well-aligned.
181fn byte_offset_to_char_offset(text: &str, byte_offset: usize) -> usize {
182    let mut off = byte_offset.min(text.len());
183    while off > 0 && !text.is_char_boundary(off) {
184        off -= 1;
185    }
186    text[..off].chars().count()
187}
188
189/// Everything line wrapping needs to hyphenate: the pre-shaped hyphen
190/// glyph to append at a break and the ISO 639-1 language for the
191/// dictionary. Passed as `Some` only when hyphenation is enabled.
192pub struct Hyphenator {
193    /// Hyphen (`-`) glyph in the run's font, appended at hyphenated breaks.
194    pub glyph: ShapedGlyph,
195    /// ISO 639-1 language code for the Knuth-Liang dictionary.
196    pub language: [u8; 2],
197}
198
199/// Text alignment within a line.
200///
201/// `Left`/`Right` are absolute; `Start`/`End` are relative to the
202/// paragraph's base direction and are what an *unset* alignment should
203/// use. Keeping both lets a writer who explicitly chose "flush left" keep
204/// that in an RTL paragraph, while a paragraph nobody has aligned simply
205/// follows its own direction.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
207pub enum Alignment {
208    /// Leading edge: left in an LTR paragraph, right in an RTL one.
209    #[default]
210    Start,
211    /// Trailing edge: right in an LTR paragraph, left in an RTL one.
212    End,
213    Left,
214    Right,
215    Center,
216    Justify,
217}
218
219impl Alignment {
220    /// Resolve the direction-relative variants against a base direction.
221    ///
222    /// Always returns an absolute alignment, so line layout never has to
223    /// think about direction again.
224    pub fn resolve_for(self, base: TextDirection) -> Alignment {
225        let rtl = base == TextDirection::RightToLeft;
226        match self {
227            Alignment::Start if rtl => Alignment::Right,
228            Alignment::Start => Alignment::Left,
229            Alignment::End if rtl => Alignment::Left,
230            Alignment::End => Alignment::Right,
231            absolute => absolute,
232        }
233    }
234}
235
236/// Put a line's runs into visual order per UAX #9 rule L2, then re-lay
237/// their x positions left to right.
238///
239/// Line breaking is a logical operation and runs arrive in logical order,
240/// so this is where a mixed-direction line finally becomes visual. It has
241/// to run per line rather than per paragraph: reordering a paragraph
242/// before breaking it would let a wrap point fall in the middle of an
243/// already-reversed span and scramble every line after it.
244///
245/// The runs keep their glyphs untouched — harfrust already emitted those
246/// in visual order within each run. Only the runs' relative order and
247/// their x origins change.
248fn reorder_line_visually(line: &mut LayoutLine) {
249    if line.runs.len() < 2 {
250        return;
251    }
252    // Cheap scan before any allocation: with nothing right-to-left on
253    // the line there is nothing for rule L2 to reverse, and that is
254    // every line of an ordinary Latin document. Building the level and
255    // permutation vectors first would allocate twice per line on every
256    // relayout just to conclude the same thing.
257    if line.runs.iter().all(|r| r.shaped_run.bidi_level % 2 == 0) {
258        return;
259    }
260
261    let levels: Vec<u8> = line.runs.iter().map(|r| r.shaped_run.bidi_level).collect();
262
263    let order = crate::shaping::shaper::visual_order(&levels);
264    if order.iter().copied().eq(0..order.len()) {
265        return; // already visual — the common all-LTR case
266    }
267
268    // The line's left edge, which `build_line` set to the first-line
269    // indent. Re-laying from here keeps the indent intact.
270    let origin = line.runs.iter().map(|r| r.x).fold(f32::INFINITY, f32::min);
271
272    let mut reordered: Vec<crate::layout::line::PositionedRun> = Vec::with_capacity(order.len());
273    let mut taken: Vec<Option<crate::layout::line::PositionedRun>> =
274        line.runs.drain(..).map(Some).collect();
275    for logical_idx in order {
276        if let Some(run) = taken[logical_idx].take() {
277            reordered.push(run);
278        }
279    }
280
281    let mut x = origin;
282    for run in &mut reordered {
283        run.x = x;
284        x += run.shaped_run.advance_width;
285    }
286    line.runs = reordered;
287}
288
289/// Break shaped runs into lines that fit within `available_width`.
290///
291/// Strategy: shape-first-then-break.
292/// 1. The caller has already shaped the full paragraph into one or more ShapedRuns.
293/// 2. We use unicode-linebreak to find break opportunities in the original text.
294/// 3. We map break positions to glyph boundaries via cluster values.
295/// 4. Greedy line wrapping: accumulate glyph advances, break at the last
296///    allowed opportunity before exceeding the width.
297/// 5. Apply alignment per line.
298#[allow(clippy::too_many_arguments)]
299pub fn break_into_lines(
300    runs: Vec<ShapedRun>,
301    text: &str,
302    available_width: f32,
303    alignment: Alignment,
304    first_line_indent: f32,
305    metrics: &FontMetricsPx,
306    hyphenator: Option<Hyphenator>,
307    run_order: RunOrder,
308) -> Vec<LayoutLine> {
309    if runs.is_empty() || text.is_empty() {
310        // Empty paragraph: produce one empty line for the block to have height
311        return vec![make_empty_line(metrics, 0..0)];
312    }
313
314    // Flatten all glyphs into a single sequence with their run association
315    let flat = flatten_runs(&runs);
316    if flat.is_empty() {
317        return vec![make_empty_line(metrics, 0..0)];
318    }
319
320    // Get UAX #14 break opportunities (byte offsets in text), each
321    // classified as Allowed or Mandatory.
322    let breaks: Vec<(usize, BreakOpportunity)> = enumerate_breaks(text);
323
324    // Build sets of allowed and mandatory break positions (glyph indices)
325    let (break_points, mandatory_breaks) = map_breaks_to_glyph_indices(&flat, &breaks);
326
327    // Hyphenation break candidates (glyph indices). A break here needs a
328    // trailing hyphen glyph and must reserve its advance in the fit check.
329    let hyphen_points = if let Some(h) = &hyphenator {
330        map_offsets_to_glyph_indices(&flat, &hyphenation_breaks(text, h.language))
331    } else {
332        HashSet::new()
333    };
334    let hyphen_adv = hyphenator
335        .as_ref()
336        .map(|h| h.glyph.x_advance)
337        .unwrap_or(0.0);
338
339    // Greedy line wrapping
340    let mut lines = Vec::new();
341    let mut line_start_glyph = 0usize;
342    let mut line_width = 0.0f32;
343    // Last break opportunity within the current line: (glyph index, whether
344    // breaking there renders a hyphen).
345    let mut last_break: Option<(usize, bool)> = None;
346    // First line may be indented; subsequent lines use full width
347    let mut effective_width = available_width - first_line_indent;
348
349    for i in 0..flat.len() {
350        let glyph_advance = flat[i].x_advance;
351        line_width += glyph_advance;
352
353        // Check for mandatory break — O(1) HashSet lookup
354        let is_mandatory = mandatory_breaks.contains(&(i + 1));
355
356        let exceeds_width = line_width > effective_width && line_start_glyph < i;
357
358        if is_mandatory || exceeds_width {
359            let (break_at, needs_hyphen) = if is_mandatory {
360                (i + 1, false)
361            } else if let Some((bp, hy)) = last_break {
362                if bp > line_start_glyph {
363                    (bp, hy)
364                } else {
365                    (i + 1, false) // emergency break -no opportunity found
366                }
367            } else {
368                (i + 1, false) // emergency break -no break opportunities at all
369            };
370
371            let indent = if lines.is_empty() {
372                first_line_indent
373            } else {
374                0.0
375            };
376            let mut line = build_line(
377                &runs,
378                &flat,
379                line_start_glyph,
380                break_at,
381                metrics,
382                indent,
383                text,
384            );
385            if needs_hyphen && let Some(h) = &hyphenator {
386                append_hyphen(&mut line, &h.glyph);
387            }
388            lines.push(line);
389
390            line_start_glyph = break_at;
391            // Subsequent lines use full available width
392            effective_width = available_width;
393            // Re-accumulate width for glyphs already scanned past the break
394            line_width = 0.0;
395            for j in break_at..=i {
396                if j < flat.len() {
397                    line_width += flat[j].x_advance;
398                }
399            }
400            last_break = None;
401        }
402
403        // Update the break opportunity AFTER the width check so that a break
404        // discovered at this glyph does not clobber the previous one when the
405        // width is already exceeded. Hyphenation points are checked first so
406        // a soft hyphen (which is also a UAX #14 opportunity) renders its
407        // hyphen; they only count when the hyphen itself still fits.
408        let at = i + 1;
409        if hyphen_points.contains(&at) && line_width + hyphen_adv <= effective_width {
410            last_break = Some((at, true));
411        } else if break_points.contains(&at) {
412            last_break = Some((at, false));
413        }
414    }
415
416    // Remaining glyphs form the last line
417    if line_start_glyph < flat.len() {
418        let line = build_line(
419            &runs,
420            &flat,
421            line_start_glyph,
422            flat.len(),
423            metrics,
424            if lines.is_empty() {
425                first_line_indent
426            } else {
427                0.0
428            },
429            text,
430        );
431        lines.push(line);
432    }
433
434    // Put each line's runs into visual order before anything reads their
435    // x positions. Alignment shifts every run by the same amount so it
436    // does not care about order, but `justify_line` re-lays runs
437    // sequentially from the vector and would otherwise justify a
438    // mixed-direction line in logical order.
439    let base_direction = match run_order {
440        RunOrder::Logical(base) => {
441            for line in &mut lines {
442                reorder_line_visually(line);
443            }
444            base
445        }
446        // Already visual: reordering here would reverse the caller's work.
447        RunOrder::AlreadyVisual => TextDirection::LeftToRight,
448    };
449
450    // Apply alignment. An unset alignment follows the paragraph's base
451    // direction, so an RTL paragraph right-aligns without the host having
452    // to translate direction into alignment itself.
453    let alignment = alignment.resolve_for(base_direction);
454    let rtl_paragraph = base_direction == TextDirection::RightToLeft;
455    let effective_width = available_width;
456    let last_idx = lines.len().saturating_sub(1);
457    for (i, line) in lines.iter_mut().enumerate() {
458        let indent = if i == 0 { first_line_indent } else { 0.0 };
459        // A first-line indent insets from the paragraph's *leading* edge,
460        // which in an RTL paragraph is the right one. `build_line` always
461        // insets from the left, so undo that here and let the narrowed
462        // `line_avail` below carry the inset over to the right instead.
463        if rtl_paragraph && indent != 0.0 {
464            for run in &mut line.runs {
465                run.x -= indent;
466            }
467        }
468        let line_avail = effective_width - indent;
469        match alignment {
470            // `resolve_for` maps these onto Left/Right, so they cannot
471            // reach here — but fail soft rather than panicking in a
472            // layout pass that runs on every keystroke.
473            Alignment::Start | Alignment::End | Alignment::Left => {
474                // Runs already sit at the indent, except in an RTL
475                // paragraph where the block above moved them to 0 so the
476                // inset could go to the trailing edge. An explicit Left
477                // means the writer wants flush-left text, and the indent
478                // still belongs on the paragraph's leading edge, so put
479                // it back.
480                if rtl_paragraph && indent != 0.0 {
481                    for run in &mut line.runs {
482                        run.x += indent;
483                    }
484                }
485            }
486            Alignment::Right => {
487                let shift = (line_avail - line.width).max(0.0);
488                for run in &mut line.runs {
489                    run.x += shift;
490                }
491            }
492            Alignment::Center => {
493                let shift = ((line_avail - line.width) / 2.0).max(0.0);
494                for run in &mut line.runs {
495                    run.x += shift;
496                }
497            }
498            Alignment::Justify => {
499                // Don't justify the last line
500                if i < last_idx && line.width > 0.0 {
501                    justify_line(line, line_avail, text);
502                }
503            }
504        }
505    }
506
507    if lines.is_empty() {
508        lines.push(make_empty_line(metrics, 0..0));
509    }
510
511    // Convert glyph cluster values from byte offsets to char offsets.
512    // This must happen AFTER alignment because justify_line needs byte
513    // offsets to find space characters in the original text.
514    for line in &mut lines {
515        for run in &mut line.runs {
516            for glyph in &mut run.shaped_run.glyphs {
517                glyph.cluster = byte_offset_to_char_offset(text, glyph.cluster as usize) as u32;
518            }
519        }
520    }
521
522    lines
523}
524
525/// A flattened glyph with enough info to map back to runs.
526struct FlatGlyph {
527    x_advance: f32,
528    cluster: u32,
529    run_index: usize,
530    glyph_index_in_run: usize,
531}
532
533fn flatten_runs(runs: &[ShapedRun]) -> Vec<FlatGlyph> {
534    let mut flat = Vec::new();
535    for (run_idx, run) in runs.iter().enumerate() {
536        // Offset cluster values from fragment-text space to block-text space.
537        // rustybuzz assigns clusters as byte offsets within the fragment text (0-based),
538        // but unicode-linebreak returns byte offsets in the full block text.
539        let cluster_offset = run.text_range.start as u32;
540        for (glyph_idx, glyph) in run.glyphs.iter().enumerate() {
541            flat.push(FlatGlyph {
542                x_advance: glyph.x_advance,
543                cluster: glyph.cluster + cluster_offset,
544                run_index: run_idx,
545                glyph_index_in_run: glyph_idx,
546            });
547        }
548    }
549    flat
550}
551
552/// Map unicode-linebreak byte offsets to glyph indices using a merged walk.
553/// Both `flat` (by cluster) and `breaks` (by byte offset) are sorted,
554/// so a single O(b + m) pass replaces the previous O(b × m) approach.
555///
556/// Returns (break_points: HashSet<glyph_idx>, mandatory_breaks: HashSet<glyph_idx>).
557fn map_breaks_to_glyph_indices(
558    flat: &[FlatGlyph],
559    breaks: &[(usize, BreakOpportunity)],
560) -> (HashSet<usize>, HashSet<usize>) {
561    let mut break_points = HashSet::new();
562    let mut mandatory_breaks = HashSet::new();
563    let mut glyph_cursor = 0usize;
564
565    for &(byte_offset, opportunity) in breaks {
566        // Advance glyph cursor to the first glyph whose cluster >= byte_offset
567        while glyph_cursor < flat.len() && (flat[glyph_cursor].cluster as usize) < byte_offset {
568            glyph_cursor += 1;
569        }
570        let glyph_idx = if glyph_cursor < flat.len() {
571            glyph_cursor
572        } else {
573            flat.len()
574        };
575        break_points.insert(glyph_idx);
576        if opportunity == BreakOpportunity::Mandatory {
577            mandatory_breaks.insert(glyph_idx);
578        }
579    }
580
581    (break_points, mandatory_breaks)
582}
583
584/// Build a LayoutLine from a glyph range within the flat sequence.
585fn build_line(
586    runs: &[ShapedRun],
587    flat: &[FlatGlyph],
588    start: usize,
589    end: usize,
590    metrics: &FontMetricsPx,
591    indent: f32,
592    text: &str,
593) -> LayoutLine {
594    // Group consecutive glyphs by run_index to reconstruct PositionedRuns
595    let mut positioned_runs = Vec::new();
596    let mut x = indent;
597    let mut current_run_idx: Option<usize> = None;
598    let mut run_glyph_start = 0usize;
599
600    for i in start..end {
601        let fg = &flat[i];
602        if current_run_idx != Some(fg.run_index) {
603            // Emit previous run segment if any
604            if let Some(prev_run_idx) = current_run_idx {
605                // End of previous run: use the last glyph we saw from that run
606                let prev_end = if i > start {
607                    flat[i - 1].glyph_index_in_run + 1
608                } else {
609                    run_glyph_start
610                };
611                let sub_run = extract_sub_run(runs, prev_run_idx, run_glyph_start, prev_end);
612                if let Some((pr, advance)) = sub_run {
613                    positioned_runs.push(PositionedRun {
614                        decorations: RunDecorations {
615                            underline_style: pr.underline_style,
616                            overline: pr.overline,
617                            strikeout: pr.strikeout,
618                            is_link: pr.is_link,
619                            foreground_color: pr.foreground_color,
620                            underline_color: pr.underline_color,
621                            background_color: pr.background_color,
622                            anchor_href: pr.anchor_href.clone(),
623                            tooltip: pr.tooltip.clone(),
624                            vertical_alignment: pr.vertical_alignment,
625                        },
626                        shaped_run: pr,
627                        x,
628                    });
629                    x += advance;
630                }
631            }
632            current_run_idx = Some(fg.run_index);
633            run_glyph_start = fg.glyph_index_in_run;
634        }
635    }
636
637    // Emit final run segment
638    if let Some(run_idx) = current_run_idx {
639        let end_in_run = if end < flat.len() && flat[end].run_index == run_idx {
640            flat[end].glyph_index_in_run
641        } else if end > start {
642            flat[end - 1].glyph_index_in_run + 1
643        } else {
644            run_glyph_start
645        };
646        let sub_run = extract_sub_run(runs, run_idx, run_glyph_start, end_in_run);
647        if let Some((pr, advance)) = sub_run {
648            positioned_runs.push(PositionedRun {
649                decorations: RunDecorations {
650                    underline_style: pr.underline_style,
651                    overline: pr.overline,
652                    strikeout: pr.strikeout,
653                    is_link: pr.is_link,
654                    foreground_color: pr.foreground_color,
655                    underline_color: pr.underline_color,
656                    background_color: pr.background_color,
657                    anchor_href: pr.anchor_href.clone(),
658                    tooltip: pr.tooltip.clone(),
659                    vertical_alignment: pr.vertical_alignment,
660                },
661                shaped_run: pr,
662                x,
663            });
664            x += advance;
665        }
666    }
667
668    let width = x - indent;
669
670    // Compute char range from cluster values.
671    // Clusters from rustybuzz are byte offsets — convert to char offsets
672    // so that positions match text-document's character-based coordinates.
673    //
674    // Glyphs are in *visual* order, so for an RTL run `flat[start]` holds
675    // the largest cluster and `flat[end-1]` the smallest. Take the min/max
676    // over the line's glyphs instead of trusting the array ends, so the
677    // logical range is correct for both directions.
678    let byte_start = flat[start..end.min(flat.len())]
679        .iter()
680        .map(|g| g.cluster as usize)
681        .min()
682        .unwrap_or(0);
683    let byte_end = if end >= flat.len() {
684        // Last glyph reaches the end of the input text. Always snap to the
685        // full length: a trailing ligature glyph may cover several source
686        // chars, so `max_cluster + 1` would be wrong.
687        text.len()
688    } else {
689        // Next visual glyph's cluster bounds an LTR line exactly; for a
690        // wrapped RTL line take whichever is larger so the logical end
691        // still covers the line's highest cluster.
692        let line_max = flat[start..end]
693            .iter()
694            .map(|g| g.cluster as usize)
695            .max()
696            .unwrap_or(0);
697        (flat[end].cluster as usize).max(line_max)
698    };
699    let char_start = byte_offset_to_char_offset(text, byte_start);
700    let char_end = byte_offset_to_char_offset(text, byte_end);
701
702    // Expand line height for inline images taller than the font ascent
703    let mut ascent = metrics.ascent;
704    for run in &positioned_runs {
705        if run.shaped_run.image_name.is_some() && run.shaped_run.image_height > ascent {
706            ascent = run.shaped_run.image_height;
707        }
708    }
709    let line_height = ascent + metrics.descent + metrics.leading;
710
711    LayoutLine {
712        runs: positioned_runs,
713        y: 0.0, // will be set by the caller (block layout)
714        ascent,
715        descent: metrics.descent,
716        leading: metrics.leading,
717        width,
718        char_range: char_start..char_end,
719        line_height,
720    }
721}
722
723/// Extract a sub-run (slice of glyphs) from a ShapedRun.
724/// Cluster values are offset to block-text space (adding text_range.start).
725fn extract_sub_run(
726    runs: &[ShapedRun],
727    run_index: usize,
728    glyph_start: usize,
729    glyph_end: usize,
730) -> Option<(ShapedRun, f32)> {
731    let run = &runs[run_index];
732    let end = glyph_end.min(run.glyphs.len());
733    if glyph_start >= end {
734        return None;
735    }
736    let cluster_offset = run.text_range.start as u32;
737    let mut sub_glyphs = run.glyphs[glyph_start..end].to_vec();
738    // Offset cluster values from fragment-local to block-text space
739    for g in &mut sub_glyphs {
740        g.cluster += cluster_offset;
741    }
742    let advance: f32 = sub_glyphs.iter().map(|g| g.x_advance).sum();
743
744    let sub_run = ShapedRun {
745        font_face_id: run.font_face_id,
746        size_px: run.size_px,
747        weight: run.weight,
748        glyphs: sub_glyphs,
749        advance_width: advance,
750        text_range: run.text_range.clone(),
751        direction: run.direction,
752        bidi_level: run.bidi_level,
753        underline_style: run.underline_style,
754        overline: run.overline,
755        strikeout: run.strikeout,
756        is_link: run.is_link,
757        foreground_color: run.foreground_color,
758        underline_color: run.underline_color,
759        background_color: run.background_color,
760        anchor_href: run.anchor_href.clone(),
761        tooltip: run.tooltip.clone(),
762        vertical_alignment: run.vertical_alignment,
763        image_name: run.image_name.clone(),
764        image_height: run.image_height,
765    };
766    Some((sub_run, advance))
767}
768
769fn make_empty_line(metrics: &FontMetricsPx, char_range: Range<usize>) -> LayoutLine {
770    LayoutLine {
771        runs: Vec::new(),
772        y: 0.0,
773        ascent: metrics.ascent,
774        descent: metrics.descent,
775        leading: metrics.leading,
776        width: 0.0,
777        char_range,
778        line_height: metrics.ascent + metrics.descent + metrics.leading,
779    }
780}
781
782/// Distribute extra space among word gaps for justification.
783///
784/// Finds space glyphs (cluster mapping to ' ') across all runs and
785/// increases their x_advance proportionally. Then recomputes run x positions.
786fn justify_line(line: &mut LayoutLine, target_width: f32, text: &str) {
787    let extra = target_width - line.width;
788    if extra <= 0.0 {
789        return;
790    }
791
792    // Count space glyphs across all runs
793    let mut space_count = 0usize;
794    for run in &line.runs {
795        for glyph in &run.shaped_run.glyphs {
796            let byte_offset = glyph.cluster as usize;
797            if let Some(ch) = text.get(byte_offset..).and_then(|s| s.chars().next())
798                && ch == ' '
799            {
800                space_count += 1;
801            }
802        }
803    }
804
805    if space_count == 0 {
806        return;
807    }
808
809    let extra_per_space = extra / space_count as f32;
810
811    // Increase x_advance of space glyphs
812    for run in &mut line.runs {
813        for glyph in &mut run.shaped_run.glyphs {
814            let byte_offset = glyph.cluster as usize;
815            if let Some(ch) = text.get(byte_offset..).and_then(|s| s.chars().next())
816                && ch == ' '
817            {
818                glyph.x_advance += extra_per_space;
819            }
820        }
821        // Recompute run advance width
822        run.shaped_run.advance_width = run.shaped_run.glyphs.iter().map(|g| g.x_advance).sum();
823    }
824
825    // Recompute run x positions (runs follow each other)
826    let first_x = line.runs.first().map(|r| r.x).unwrap_or(0.0);
827    let mut x = first_x;
828    for run in &mut line.runs {
829        run.x = x;
830        x += run.shaped_run.advance_width;
831    }
832
833    line.width = target_width;
834}