Skip to main content

oxml_layout/
line.rs

1//! Line breaking: converts inline items into laid-out lines.
2//!
3//! Uses a greedy algorithm with unicode-linebreak for break opportunities.
4
5use crate::error::{LayoutError, Result};
6use crate::font::{FontManager, MultilingualTextSegment, TextDirection, explicit_direction_levels};
7use crate::output::{
8    Color, FieldKind, FieldSource, FontId, GroupElement, MediaId, SourceSpan, StructureId,
9};
10
11/// A tab stop positioned in typographic points.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct TabStop {
14    pub pos_pt: f64,
15    pub align: TabAlign,
16    pub leader: Option<TabLeader>,
17}
18
19/// Paragraph alignment.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Align {
22    Start,
23    Center,
24    End,
25    Justify,
26    Distribute,
27}
28
29/// Alignment relative to a tab stop.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum TabAlign {
32    Left,
33    Center,
34    Right,
35    Decimal,
36    Bar,
37}
38
39/// Leader style used to fill a tab gap.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum TabLeader {
42    None,
43    Dot,
44    Hyphen,
45    Underscore,
46    Heavy,
47    MiddleDot,
48}
49
50/// Underline style applied to a text segment.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Underline {
53    Single,
54    Words,
55    Double,
56    Thick,
57    Dotted,
58    Dash,
59    DotDash,
60    DotDotDash,
61    Wave,
62}
63
64/// Line height rule.
65#[derive(Debug, Clone, Copy, PartialEq)]
66pub enum LineSpacing {
67    Single,
68    /// A multiple of the largest text point size on the line.
69    Multiple(f64),
70    Exact(f64),
71    AtLeast(f64),
72}
73
74/// An inline item to be placed on a line.
75#[derive(Debug, Clone)]
76#[non_exhaustive]
77pub enum InlineItem {
78    /// A shaped text segment.
79    Text(TextSegment),
80    /// A validated script, font, and bidi-level text span.
81    MultilingualText(MultilingualTextSegment),
82    /// A shaped text segment eligible for language-aware automatic hyphenation.
83    HyphenatedText {
84        segment: TextSegment,
85        language: String,
86    },
87    /// A tab character.
88    Tab,
89    /// A forced line break.
90    LineBreak,
91    /// A forced page break.
92    PageBreak,
93    /// A forced column break.
94    ColumnBreak,
95    /// An inline image.
96    Image {
97        width: f64,
98        height: f64,
99        media_id: MediaId,
100    },
101    /// A backend-neutral group with child-local coordinates.
102    Group {
103        width: f64,
104        height: f64,
105        /// Distance from the group top to its text baseline. `None` keeps the
106        /// established top-aligned drawing behavior.
107        baseline: Option<f64>,
108        group: GroupElement,
109    },
110    /// An informative drawing carried to a semantic output container.
111    Figure {
112        item: Box<InlineItem>,
113        alternate_text: String,
114        structure_id: Option<StructureId>,
115    },
116    /// A numbering marker (rendered before the first line).
117    Marker(TextSegment),
118}
119
120/// Which stream a note reference belongs to.
121///
122/// A reference carries only a number in the markup, and the two streams
123/// number independently, so a document can hold a footnote and an endnote
124/// that share a number. Without the stream the two are indistinguishable and
125/// one silently shadows the other.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
127pub enum NoteStream {
128    /// Rendered at the foot of the page carrying the reference.
129    Footnote,
130    /// Rendered at the end of the document.
131    Endnote,
132}
133
134/// A reference to one note, unique across both streams.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
136pub struct NoteRef {
137    pub stream: NoteStream,
138    pub id: i32,
139}
140
141/// A shaped text segment with associated formatting.
142#[derive(Debug, Clone)]
143pub struct TextSegment {
144    pub text: String,
145    /// Requested character direction before paragraph-wide bidi resolution.
146    pub direction: TextDirection,
147    /// Exact source range for this segment, when directly attributable.
148    pub source: Option<SourceSpan>,
149    pub font_id: FontId,
150    pub font_size: f64,
151    pub glyph_ids: Vec<u16>,
152    pub advances: Vec<f64>,
153    pub width: f64,
154    pub ascent: f64,
155    pub descent: f64,
156    /// Additional font leading included in the natural line advance.
157    pub line_gap: f64,
158    pub color: Color,
159    pub bold: bool,
160    pub italic: bool,
161    /// Underline style (None = no underline).
162    pub underline: Option<Underline>,
163    /// Single strikethrough.
164    pub strike: bool,
165    /// Double strikethrough.
166    pub dstrike: bool,
167    /// Highlight/background color for the run.
168    pub highlight: Option<Color>,
169    /// Baseline offset in points (positive = raise, negative = lower).
170    pub baseline_offset: f64,
171    /// Hyperlink URL if this segment is inside a hyperlink.
172    pub hyperlink_url: Option<String>,
173    /// If this segment is a field placeholder, the kind of field.
174    pub field_kind: Option<FieldKind>,
175    /// Source field of a page-number placeholder, when attributable.
176    pub field_source: Option<FieldSource>,
177    /// If this segment is a note reference marker, which note it points at.
178    pub note: Option<NoteRef>,
179}
180
181/// A single item positioned on a line.
182#[derive(Debug, Clone)]
183#[non_exhaustive]
184pub enum LineItem {
185    Text(TextSegment),
186    /// A validated script, font, and bidi-level text span.
187    MultilingualText(MultilingualTextSegment),
188    Tab {
189        width: f64,
190        /// Pre-shaped leader text to fill the tab gap (e.g., dots, hyphens).
191        leader: Option<TextSegment>,
192        /// The stop's alignment. Text after a right, centre or decimal stop
193        /// is aligned on it up to the next tab or the end of the line.
194        align: TabAlign,
195        /// The width the tab would take if the text after it could start
196        /// before it. It is below `width` only when that text is too wide to
197        /// align on its stop.
198        gap: f64,
199    },
200    Image {
201        width: f64,
202        height: f64,
203        media_id: MediaId,
204    },
205    Group {
206        width: f64,
207        height: f64,
208        /// Distance from the group top to its text baseline.
209        baseline: Option<f64>,
210        group: GroupElement,
211    },
212    /// An informative drawing carried to a semantic output container.
213    Figure {
214        item: Box<LineItem>,
215        alternate_text: String,
216        structure_id: Option<StructureId>,
217    },
218    Marker(TextSegment),
219}
220
221impl LineItem {
222    pub fn width(&self) -> f64 {
223        match self {
224            LineItem::Text(seg) => seg.width,
225            LineItem::MultilingualText(seg) => seg.width(),
226            LineItem::Tab { width, .. } => *width,
227            LineItem::Image { width, .. } => *width,
228            LineItem::Group { width, .. } => *width,
229            LineItem::Figure { item, .. } => item.width(),
230            LineItem::Marker(seg) => seg.width,
231        }
232    }
233}
234
235/// The kind of explicit break that ended a laid-out line.
236#[non_exhaustive]
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub enum ForcedBreakKind {
239    Line,
240    Page,
241    Column,
242}
243
244/// A laid-out line within a paragraph.
245#[derive(Debug, Clone)]
246pub struct LayoutLine {
247    pub items: Vec<LineItem>,
248    /// Total content width of the line.
249    ///
250    /// Spaces that end a line hang past its end, so they can take this past
251    /// `available_width`. See [`LayoutLine::hanging_space_counts`].
252    pub width: f64,
253    /// Maximum ascent on this line (above baseline).
254    pub ascent: f64,
255    /// Maximum descent on this line (below baseline).
256    pub descent: f64,
257    /// Effective leading needed to preserve the tallest run's natural advance.
258    pub line_gap: f64,
259    /// Total line height.
260    pub height: f64,
261    /// Left indent for this line.
262    pub indent_left: f64,
263    /// Available width this line was laid out against.
264    pub available_width: f64,
265    /// Whether this is the last line of the paragraph.
266    pub is_last: bool,
267    /// The explicit break that ended this line, when present.
268    pub forced_break_after: Option<ForcedBreakKind>,
269}
270
271impl LayoutLine {
272    /// Distance from the line-box top to its text baseline.
273    pub fn baseline_offset(&self) -> f64 {
274        let leading = self.height - self.ascent - self.descent;
275        self.ascent + if leading >= 0.0 { leading / 2.0 } else { 0.0 }
276    }
277
278    /// How many items at the visual start and at the visual end of the line
279    /// are spaces that end it logically.
280    ///
281    /// Those spaces hang past the line end, which is its visual right in
282    /// left-to-right text and its visual left in right-to-left text. Alignment
283    /// leaves them out of the width, and they may pass the available width.
284    ///
285    /// Plain text is never reordered, so its spaces hang at the visual end,
286    /// after an item that is not a space. Line breaking gives the spaces that
287    /// end a plain line an item of their own.
288    pub fn hanging_space_counts(&self) -> (usize, usize) {
289        let is_plain_space = |item: &LineItem| matches!(item, LineItem::Text(segment) if is_space_run(&segment.text));
290        let plain_end = self
291            .items
292            .iter()
293            .rev()
294            .take_while(|item| is_plain_space(item))
295            .count();
296        if plain_end > 0 && plain_end < self.items.len() {
297            return (0, plain_end);
298        }
299        let last_content = self
300            .items
301            .iter()
302            .filter_map(|item| match item {
303                LineItem::MultilingualText(span) if !is_space_run(span.text()) => {
304                    Some(span.logical_index())
305                }
306                _ => None,
307            })
308            .max();
309        let hangs = |item: &LineItem| match (item, last_content) {
310            (LineItem::MultilingualText(span), Some(last)) => {
311                is_space_run(span.text()) && span.logical_index() > last
312            }
313            _ => false,
314        };
315        let start = self.items.iter().take_while(|item| hangs(item)).count();
316        let end = self.items[start..]
317            .iter()
318            .rev()
319            .take_while(|item| hangs(item))
320            .count();
321        (start, end)
322    }
323}
324
325/// Whether text is only U+0020 spaces, the one character UAX 14 classes SP,
326/// which is what hangs at a line end.
327fn is_space_run(text: &str) -> bool {
328    !text.is_empty() && text.chars().all(|character| character == ' ')
329}
330
331/// Parameters for line breaking.
332#[derive(Debug, Clone)]
333pub struct LineBreakParams {
334    /// Total available width (page width minus margins).
335    pub available_width: f64,
336    /// Left indentation in points.
337    pub ind_left: f64,
338    /// Right indentation in points.
339    pub ind_right: f64,
340    /// First line indent in points (positive = indent, 0 if hanging).
341    pub ind_first_line: f64,
342    /// Hanging indent in points (positive = text lines indented relative to first).
343    pub ind_hanging: f64,
344    /// Tab stops.
345    pub tab_stops: Vec<TabStop>,
346    /// Line spacing rule and value.
347    pub line_spacing: LineSpacing,
348    /// Paragraph justification.
349    pub jc: Option<Align>,
350    /// Whether width overflow may create automatic line breaks.
351    pub wrap: bool,
352    /// Extra width kept clear at the start of individual lines, by line index.
353    ///
354    /// This is how a floating drawing pushes text aside. An empty vector, the
355    /// default, reserves nothing and reproduces unwrapped line breaking
356    /// exactly.
357    pub line_prefix_widths: Vec<f64>,
358    /// Extra width kept clear at the end of individual lines, by line index.
359    pub line_suffix_widths: Vec<f64>,
360    /// Interval between implicit tab stops, in points.
361    ///
362    /// Word calls this the default tab stop. `36.0`, half an inch, is the
363    /// value used when a document says nothing.
364    pub default_tab_interval_pt: f64,
365    /// Whether a tab stop past the right margin moves to the end of the line.
366    ///
367    /// Word 2013 and later do this, in a document whose `w:compatibilityMode`
368    /// is 15. Earlier versions keep such a stop, and the text after a stop at
369    /// or past the margin then runs on past it, which `false` gives.
370    pub clamp_tabs_past_margin: bool,
371}
372
373impl Default for LineBreakParams {
374    fn default() -> Self {
375        LineBreakParams {
376            available_width: 468.0, // US Letter with 1" margins
377            line_prefix_widths: Vec::new(),
378            line_suffix_widths: Vec::new(),
379            ind_left: 0.0,
380            ind_right: 0.0,
381            ind_first_line: 0.0,
382            ind_hanging: 0.0,
383            tab_stops: Vec::new(),
384            line_spacing: LineSpacing::Single,
385            jc: None,
386            wrap: true,
387            default_tab_interval_pt: 36.0,
388            clamp_tabs_past_margin: false,
389        }
390    }
391}
392
393/// Break inline items into lines using a greedy algorithm.
394///
395/// Tab stops are measured from the zero indent, the margin in Word, so a tab
396/// resolves against where its line starts rather than against the line's own
397/// origin. A right, centre or decimal tab takes its width from the text after
398/// it, up to the next tab or the end of the line.
399pub fn break_into_lines(
400    items: &[InlineItem],
401    params: &LineBreakParams,
402    fm: &FontManager,
403) -> Result<Vec<LayoutLine>> {
404    if items.is_empty() {
405        // Empty paragraph still gets one empty line
406        return Ok(vec![LayoutLine {
407            items: Vec::new(),
408            width: 0.0,
409            ascent: 0.0,
410            descent: 0.0,
411            line_gap: 0.0,
412            height: compute_line_height(0.0, 0.0, 0.0, 0.0, params),
413            indent_left: line_indent_at(params, 0, true),
414            available_width: line_width_at(params, 0, true),
415            is_last: true,
416            forced_break_after: None,
417        }]);
418    }
419
420    let mut lines: Vec<LayoutLine> = Vec::new();
421    let mut line = LineState::new(params, 0, true);
422
423    // Track the most recent font context for shaping tab leaders
424    let mut font_ctx: Option<(FontId, f64)> = None;
425    // Initialize from the first text segment if available
426    for item in items {
427        if let InlineItem::Text(seg)
428        | InlineItem::HyphenatedText { segment: seg, .. }
429        | InlineItem::Marker(seg) = item
430        {
431            font_ctx = Some((seg.font_id, seg.font_size));
432            break;
433        }
434        if let InlineItem::MultilingualText(seg) = item {
435            font_ctx = Some((seg.font_id(), seg.base().font_size));
436            break;
437        }
438    }
439
440    // Build breakable segments from inline items
441    let mut segments = std::collections::VecDeque::from(build_breakable_segments(items, fm)?);
442
443    while let Some(seg) = segments.pop_front() {
444        match seg {
445            BreakableSegment::Items(seg_items) if matches!(seg_items[..], [InlineItem::Tab]) => {
446                line.settle_tab(fm);
447                let mut tab = line.next_tab(params);
448                if params.wrap && tab.wraps && !line.items.is_empty() {
449                    // A tab with no stop left on its line starts the next one.
450                    lines.push(line.break_line(params, fm, false, None));
451                    tab = line.next_tab(params);
452                }
453                line.push_tab(tab, fm, font_ctx);
454            }
455            BreakableSegment::Items(seg_items) => {
456                if params.wrap
457                    && !line.items.is_empty()
458                    && line.width_with(&seg_items, fm) > line.limit + 0.01
459                    && line.width_with(&seg_items, fm) - hanging_space_width(&seg_items, fm)?
460                        > line.limit + 0.01
461                {
462                    lines.push(line.break_line(params, fm, false, None));
463                }
464
465                // Add segment items to current line
466                for item in &seg_items {
467                    // Update font context from text segments
468                    if let InlineItem::Text(seg) | InlineItem::Marker(seg) = item {
469                        font_ctx = Some((seg.font_id, seg.font_size));
470                    } else if let InlineItem::MultilingualText(seg) = item {
471                        font_ctx = Some((seg.font_id(), seg.base().font_size));
472                    }
473                    line.push(item, fm);
474                }
475            }
476            BreakableSegment::Hyphenated(boxed) => {
477                let HyphenatedSegment {
478                    segment,
479                    break_points,
480                } = *boxed;
481                let hanging = terminal_u0020_width(&segment, fm)?;
482                let whole = InlineItem::Text(segment);
483                if !params.wrap
484                    || line.width_with(std::slice::from_ref(&whole), fm) <= line.limit + 0.01
485                    || line.width_with(std::slice::from_ref(&whole), fm) - hanging
486                        <= line.limit + 0.01
487                {
488                    font_ctx = Some((segment_font_id(&whole), segment_font_size(&whole)));
489                    line.push(&whole, fm);
490                    continue;
491                }
492                let InlineItem::Text(segment) = whole else {
493                    unreachable!("built as text above")
494                };
495
496                if let Some(FittingHyphenation {
497                    prefix,
498                    hyphen,
499                    remainder,
500                    remaining_points,
501                }) =
502                    fitting_hyphenation(&segment, &break_points, line.used_width(), line.limit, fm)?
503                {
504                    for text in [prefix, hyphen] {
505                        let item = InlineItem::Text(text);
506                        font_ctx = Some((segment_font_id(&item), segment_font_size(&item)));
507                        line.push(&item, fm);
508                    }
509                    lines.push(line.break_line(params, fm, false, None));
510                    segments.push_front(BreakableSegment::Hyphenated(Box::new(
511                        HyphenatedSegment {
512                            segment: remainder,
513                            break_points: remaining_points,
514                        },
515                    )));
516                } else if !line.items.is_empty() {
517                    lines.push(line.break_line(params, fm, false, None));
518                    segments.push_front(BreakableSegment::Hyphenated(Box::new(
519                        HyphenatedSegment {
520                            segment,
521                            break_points,
522                        },
523                    )));
524                } else {
525                    let item = InlineItem::Text(segment);
526                    font_ctx = Some((segment_font_id(&item), segment_font_size(&item)));
527                    line.push(&item, fm);
528                }
529            }
530            BreakableSegment::ForcedBreak(break_kind) => {
531                let is_last = matches!(break_kind, ForcedBreakKind::Page | ForcedBreakKind::Column);
532                lines.push(line.break_line(params, fm, is_last, Some(break_kind)));
533            }
534        }
535    }
536
537    // Flush remaining items as the last line
538    lines.push(line.break_line(params, fm, true, None));
539
540    for line in &mut lines {
541        split_hanging_spaces(&mut line.items, fm)?;
542    }
543
544    Ok(lines)
545}
546
547/// Give the spaces that end a plain line an item of their own while retaining
548/// their glyph advances and source spans.
549fn split_hanging_spaces(items: &mut Vec<LineItem>, fm: &FontManager) -> Result<()> {
550    let Some(LineItem::Text(segment)) = items.last() else {
551        return Ok(());
552    };
553    if segment.field_kind.is_some() || segment.note.is_some() || is_space_run(&segment.text) {
554        return Ok(());
555    }
556    let Some(spaces) = trailing_spaces(segment, fm)? else {
557        return Ok(());
558    };
559    let Some(LineItem::Text(ink)) = items.last_mut() else {
560        unreachable!("the last item is text");
561    };
562    let glyphs = ink.glyph_ids.len() - spaces;
563    let advances = ink.advances.split_off(glyphs);
564    let width = advances.iter().sum::<f64>();
565    ink.width -= width;
566    let source = ink.source.as_mut().map(|source| {
567        source.char_end -= spaces as u32;
568        SourceSpan {
569            char_start: source.char_end,
570            char_end: source.char_end + spaces as u32,
571            ..*source
572        }
573    });
574    let hanging = TextSegment {
575        text: ink.text.split_off(ink.text.len() - spaces),
576        glyph_ids: ink.glyph_ids.split_off(glyphs),
577        advances,
578        width,
579        source,
580        hyperlink_url: ink.hyperlink_url.clone(),
581        ..*ink
582    };
583    items.push(LineItem::Text(hanging));
584    Ok(())
585}
586
587/// The line `break_into_lines` is filling.
588struct LineState {
589    /// The line index drives the per-line reservations a floating drawing
590    /// creates, so it is tracked rather than a plain first-or-not flag.
591    index: usize,
592    /// Where the line starts, measured from the zero indent.
593    start: f64,
594    /// Width the line is laid out against.
595    available: f64,
596    /// Width the line may fill before its next segment wraps. It is
597    /// `available` unless a tab stop took the line past it.
598    limit: f64,
599    items: Vec<LineItem>,
600    width: f64,
601    ascent: f64,
602    descent: f64,
603    natural_height: f64,
604    font_size: f64,
605    /// A right, centre or decimal tab still waiting for the text after it.
606    pending: Option<PendingTab>,
607}
608
609impl LineState {
610    fn new(params: &LineBreakParams, index: usize, is_first_line: bool) -> Self {
611        let available = line_width_at(params, index, is_first_line);
612        LineState {
613            index,
614            start: line_indent_at(params, index, is_first_line),
615            available,
616            limit: available,
617            items: Vec::new(),
618            width: 0.0,
619            ascent: 0.0,
620            descent: 0.0,
621            natural_height: 0.0,
622            font_size: 0.0,
623            pending: None,
624        }
625    }
626
627    fn push(&mut self, item: &InlineItem, fm: &FontManager) {
628        let (width, ascent, descent, natural_height, font_size) = item_metrics(item);
629        self.ascent = self.ascent.max(ascent);
630        self.descent = self.descent.max(descent);
631        self.natural_height = self.natural_height.max(natural_height);
632        self.font_size = self.font_size.max(font_size);
633        match self.pending.as_mut() {
634            Some(tab) => {
635                tab.add(item, width, fm);
636                self.width = tab.start + tab.width() + tab.following;
637            }
638            None => self.width += width,
639        }
640        self.items.push(inline_to_line_item(item));
641    }
642
643    /// The line's width once `items` are appended to it.
644    fn width_with(&self, items: &[InlineItem], fm: &FontManager) -> f64 {
645        match self.pending {
646            Some(mut tab) => {
647                for item in items {
648                    tab.add(item, inline_item_width(item), fm);
649                }
650                tab.visible_line_width()
651            }
652            None => self.width + items.iter().map(inline_item_width).sum::<f64>(),
653        }
654    }
655
656    /// The width appended text starts from, once a waiting tab has given up
657    /// its gap to it.
658    fn used_width(&self) -> f64 {
659        self.width - self.pending.map_or(0.0, |tab| tab.width())
660    }
661
662    /// Where a tab appended to this line goes.
663    ///
664    /// The first explicit stop past the tab wins, in the order the stops are
665    /// listed as Word reads them, bar stops excepted. A hanging indent adds a
666    /// left stop at the left indent, which is where the tab after a list
667    /// number goes. Past the last explicit stop the tab takes the next
668    /// multiple of the default interval.
669    fn next_tab(&self, params: &LineBreakParams) -> ResolvedTab {
670        let x = self.start + self.width;
671        let line_end = self.start + self.available;
672        let margin = params.available_width;
673        let hanging = (params.ind_hanging > 0.0).then_some(TabStop {
674            pos_pt: params.ind_left,
675            align: TabAlign::Left,
676            leader: None,
677        });
678        let stops = || {
679            params
680                .tab_stops
681                .iter()
682                .copied()
683                .filter(|stop| stop.align != TabAlign::Bar)
684        };
685        let next = match (
686            stops().find(|stop| stop.pos_pt > x),
687            hanging.filter(|stop| stop.pos_pt > x),
688        ) {
689            (Some(listed), Some(hanging)) if hanging.pos_pt < listed.pos_pt => Some(hanging),
690            (listed, hanging) => listed.or(hanging),
691        };
692        if let Some(stop) = next {
693            // Word 2013 and later move a stop past the right margin to the
694            // end of the line. Earlier versions keep it, and the text after a
695            // stop at or past the margin then runs on past it. A stop past the
696            // right indent lets the text run to the margin. Text after a
697            // right, centre or decimal stop is pushed back so it ends at the
698            // margin, unless an earlier version keeps the stop at or past it.
699            let past_margin = stop.pos_pt > margin;
700            let pos = if past_margin && params.clamp_tabs_past_margin {
701                line_end
702            } else {
703                stop.pos_pt
704            };
705            let reach = if !params.clamp_tabs_past_margin && pos >= margin {
706                f64::INFINITY
707            } else if pos > line_end {
708                margin.max(line_end)
709            } else {
710                line_end
711            };
712            return ResolvedTab {
713                stop: pos,
714                align: stop.align,
715                leader: leader_char(stop.leader),
716                // Word 2013 moves a left tab that reaches the end of its line
717                // to a line of its own, and the text after it to the next.
718                wraps: params.clamp_tabs_past_margin
719                    && stop.align == TabAlign::Left
720                    && pos > reach - 0.01,
721                reach,
722                shift_limit: (params.clamp_tabs_past_margin || pos < margin)
723                    .then_some(margin.max(line_end)),
724            };
725        }
726        let interval = if params.default_tab_interval_pt > 0.0 {
727            params.default_tab_interval_pt
728        } else {
729            36.0
730        };
731        let last = stops()
732            .chain(hanging)
733            .map(|stop| stop.pos_pt)
734            .fold(f64::NEG_INFINITY, f64::max);
735        let pos = ((x.max(last) / interval).floor() + 1.0) * interval;
736        ResolvedTab {
737            stop: pos,
738            align: TabAlign::Left,
739            leader: None,
740            // A default stop at or past the end of the line is no stop, unless
741            // an earlier stop took the line past its end.
742            wraps: pos > self.start + self.limit - 0.01,
743            reach: line_end,
744            shift_limit: None,
745        }
746    }
747
748    fn push_tab(&mut self, tab: ResolvedTab, fm: &FontManager, font_ctx: Option<(FontId, f64)>) {
749        self.limit = self.limit.max(tab.reach - self.start);
750        let start = self.width;
751        let stop = tab.stop - self.start;
752        if matches!(
753            tab.align,
754            TabAlign::Right | TabAlign::Center | TabAlign::Decimal
755        ) {
756            let pending = PendingTab {
757                index: self.items.len(),
758                start,
759                stop,
760                align: tab.align,
761                leader: tab.leader,
762                following: 0.0,
763                trailing: 0.0,
764                aligned: 0.0,
765                aligned_found: false,
766                in_number: false,
767                shift_limit: tab.shift_limit.map(|limit| limit - self.start),
768                font_ctx,
769            };
770            self.width = start + pending.width();
771            self.pending = Some(pending);
772            // The width is settled once the text after the tab is known.
773            self.items.push(LineItem::Tab {
774                width: 0.0,
775                leader: None,
776                align: tab.align,
777                gap: 0.0,
778            });
779        } else {
780            let width = (stop - start).max(0.0);
781            let leader = tab
782                .leader
783                .and_then(|ch| shape_leader(fm, font_ctx, ch, width));
784            self.items.push(LineItem::Tab {
785                width,
786                leader,
787                align: TabAlign::Left,
788                gap: width,
789            });
790            self.width += width;
791        }
792    }
793
794    /// Fix the width of a tab that was waiting for the text after it.
795    fn settle_tab(&mut self, fm: &FontManager) {
796        if let Some(tab) = self.pending.take() {
797            let width = tab.width();
798            let leader = tab
799                .leader
800                .and_then(|ch| shape_leader(fm, tab.font_ctx, ch, width));
801            self.items[tab.index] = LineItem::Tab {
802                width,
803                leader,
804                align: tab.align,
805                gap: tab.gap(),
806            };
807        }
808    }
809
810    /// Close this line and start the next one in its place.
811    fn break_line(
812        &mut self,
813        params: &LineBreakParams,
814        fm: &FontManager,
815        is_last: bool,
816        forced_break_after: Option<ForcedBreakKind>,
817    ) -> LayoutLine {
818        self.settle_tab(fm);
819        let next = LineState::new(params, self.index + 1, false);
820        let line = std::mem::replace(self, next);
821        let line_gap = effective_line_gap(line.ascent, line.descent, line.natural_height);
822        LayoutLine {
823            height: compute_line_height(
824                line.ascent,
825                line.descent,
826                line_gap,
827                line.font_size,
828                params,
829            ),
830            items: line.items,
831            width: line.width,
832            ascent: line.ascent,
833            descent: line.descent,
834            line_gap,
835            indent_left: line.start,
836            available_width: line.available,
837            is_last,
838            forced_break_after,
839        }
840    }
841}
842
843/// The stop a tab goes to, measured from the zero indent.
844struct ResolvedTab {
845    stop: f64,
846    align: TabAlign,
847    leader: Option<char>,
848    /// Whether the tab found no stop before the end of its line, so it moves
849    /// to the next one.
850    wraps: bool,
851    /// How far the line may reach once the tab is on it.
852    reach: f64,
853    /// The position text after a right, centre or decimal tab may not pass.
854    shift_limit: Option<f64>,
855}
856
857/// A right, centre or decimal tab, which takes its width from the text after
858/// it. Positions are measured from the start of the line.
859#[derive(Debug, Clone, Copy)]
860struct PendingTab {
861    /// Index of the tab among the line's items.
862    index: usize,
863    start: f64,
864    stop: f64,
865    align: TabAlign,
866    leader: Option<char>,
867    /// Width of the items after the tab.
868    following: f64,
869    /// Width of the spaces that end the items after the tab. Word aligns the
870    /// text without them, so text that wraps ends on its stop.
871    trailing: f64,
872    /// For a decimal tab, the width of the text before its alignment point.
873    aligned: f64,
874    aligned_found: bool,
875    /// Whether the text scanned so far ends inside a number.
876    in_number: bool,
877    shift_limit: Option<f64>,
878    font_ctx: Option<(FontId, f64)>,
879}
880
881impl PendingTab {
882    fn add(&mut self, item: &InlineItem, width: f64, fm: &FontManager) {
883        self.following += width;
884        self.trailing = match item {
885            InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. }
886                if segment.text.chars().all(char::is_whitespace) =>
887            {
888                self.trailing + width
889            }
890            InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. } => {
891                trailing_space_width(segment, fm)
892            }
893            _ => 0.0,
894        };
895        if self.align == TabAlign::Decimal && !self.aligned_found {
896            match decimal_alignment_offset(item, &mut self.in_number, fm) {
897                Some(offset) => {
898                    self.aligned += offset;
899                    self.aligned_found = true;
900                }
901                None => self.aligned += width,
902            }
903        }
904    }
905
906    /// The tab's width, which never goes negative: text too wide to end at
907    /// its stop starts where the tab does.
908    fn width(&self) -> f64 {
909        self.gap().max(0.0)
910    }
911
912    /// The tab's width before it is kept from going negative.
913    fn gap(&self) -> f64 {
914        let visible = self.following - self.trailing;
915        let before_stop = match self.align {
916            TabAlign::Center => visible / 2.0,
917            TabAlign::Decimal if self.aligned_found => self.aligned,
918            TabAlign::Decimal => self.aligned - self.trailing,
919            _ => visible,
920        };
921        let mut width = self.stop - self.start - before_stop;
922        if let Some(limit) = self.shift_limit {
923            width = width.min(limit - self.start - visible);
924        }
925        width
926    }
927
928    /// How wide the line is with this tab, less the spaces that end it,
929    /// which may run past the stop.
930    fn visible_line_width(&self) -> f64 {
931        self.start + self.width() + self.following - self.trailing
932    }
933}
934
935/// Where a decimal tab aligns within one item, as a width from its start.
936///
937/// Word aligns the first full stop, or else the end of the first number, a
938/// comma inside a number belonging to it whatever the document's decimal
939/// symbol. `in_number` carries a number from one item to the next.
940fn decimal_alignment_offset(
941    item: &InlineItem,
942    in_number: &mut bool,
943    fm: &FontManager,
944) -> Option<f64> {
945    let segment = match item {
946        InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. } => segment,
947        _ => return in_number.then_some(0.0),
948    };
949    for (index, ch) in segment.text.char_indices() {
950        if ch == '.' || (*in_number && !ch.is_ascii_digit() && ch != ',') {
951            return Some(text_prefix_width(segment, index, fm));
952        }
953        *in_number |= ch.is_ascii_digit();
954    }
955    None
956}
957
958/// Width of the spaces that end a segment's text.
959fn trailing_space_width(segment: &TextSegment, fm: &FontManager) -> f64 {
960    let trimmed = segment.text.trim_end_matches(char::is_whitespace);
961    if trimmed.len() == segment.text.len() {
962        return 0.0;
963    }
964    segment.width - text_prefix_width(segment, trimmed.len(), fm)
965}
966
967/// Width of a segment's text before a byte offset.
968fn text_prefix_width(segment: &TextSegment, byte_index: usize, fm: &FontManager) -> f64 {
969    if byte_index == 0 {
970        return 0.0;
971    }
972    let prefix = &segment.text[..byte_index];
973    if segment.advances.len() == segment.text.chars().count() {
974        return segment.advances[..prefix.chars().count()].iter().sum();
975    }
976    fm.shape_text(segment.font_id, prefix, segment.font_size)
977        .map_or(segment.width, |shaped| shaped.width)
978}
979
980fn leader_char(leader: Option<TabLeader>) -> Option<char> {
981    leader.and_then(|leader| match leader {
982        TabLeader::Dot => Some('.'),
983        TabLeader::Hyphen => Some('-'),
984        TabLeader::Underscore => Some('_'),
985        TabLeader::MiddleDot => Some('\u{00B7}'),
986        TabLeader::Heavy => Some('_'),
987        TabLeader::None => None,
988    })
989}
990
991/// Break rich text in logical order, then reorder each completed line for painting.
992pub fn break_multilingual_into_lines(
993    items: &[InlineItem],
994    params: &LineBreakParams,
995    fm: &FontManager,
996    base_direction: TextDirection,
997) -> Result<Vec<LayoutLine>> {
998    let mut lines = break_into_lines(items, params, fm)?;
999    let mut paragraph_text = String::new();
1000    let mut line_maps = Vec::with_capacity(lines.len());
1001    let mut has_text = false;
1002    for line in &lines {
1003        let line_start = paragraph_text.len();
1004        let mut positions = Vec::new();
1005        for (index, item) in line.items.iter().enumerate() {
1006            let (text, direction, shaped_level) = match item {
1007                LineItem::Text(segment) | LineItem::Marker(segment) => {
1008                    (segment.text.as_str(), segment.direction, None)
1009                }
1010                LineItem::MultilingualText(segment) => (
1011                    segment.text(),
1012                    segment.base().direction,
1013                    Some(unicode_bidi::Level::new(segment.bidi_level()).map_err(|_| {
1014                        LayoutError::Layout("rich text carried an invalid bidi level".to_owned())
1015                    })?),
1016                ),
1017                LineItem::Tab { .. } => ("\t", TextDirection::Auto, None),
1018                LineItem::Image { .. } | LineItem::Group { .. } | LineItem::Figure { .. } => {
1019                    ("\u{fffc}", TextDirection::Auto, None)
1020                }
1021            };
1022            has_text |= !text.is_empty();
1023            let start = paragraph_text.len();
1024            paragraph_text.push_str(text);
1025            let end = paragraph_text.len();
1026            positions.push((
1027                index,
1028                start,
1029                end,
1030                direction,
1031                text.chars().all(char::is_whitespace),
1032                shaped_level,
1033            ));
1034        }
1035        line_maps.push((line_start..paragraph_text.len(), positions));
1036    }
1037    if !has_text {
1038        return Ok(lines);
1039    }
1040    let paragraph_level = match base_direction {
1041        TextDirection::Auto => None,
1042        TextDirection::LeftToRight => Some(unicode_bidi::Level::ltr()),
1043        TextDirection::RightToLeft => Some(unicode_bidi::Level::rtl()),
1044    };
1045    let bidi = unicode_bidi::BidiInfo::new(&paragraph_text, paragraph_level);
1046    let [paragraph] = bidi.paragraphs.as_slice() else {
1047        return Err(LayoutError::Layout(
1048            "multilingual line layout requires one bidi paragraph".to_owned(),
1049        ));
1050    };
1051    for (line, (line_range, mapped_positions)) in lines.iter_mut().zip(line_maps) {
1052        let positions = mapped_positions
1053            .iter()
1054            .map(|(index, _, _, _, _, _)| *index)
1055            .collect::<Vec<_>>();
1056        if positions.is_empty() {
1057            continue;
1058        }
1059        let adjusted_levels = bidi.reordered_levels(paragraph, line_range);
1060        let levels = mapped_positions
1061            .into_iter()
1062            .map(|(_, start, end, direction, whitespace, shaped_level)| {
1063                let adjusted = adjusted_levels.get(start).copied().ok_or_else(|| {
1064                    LayoutError::Layout(
1065                        "multilingual line range exceeded its bidi paragraph".to_owned(),
1066                    )
1067                })?;
1068                Ok(if whitespace {
1069                    adjusted
1070                } else if let Some(shaped_level) = shaped_level {
1071                    shaped_level
1072                } else if direction == TextDirection::Auto {
1073                    adjusted
1074                } else {
1075                    explicit_direction_levels(
1076                        &paragraph_text[start..end],
1077                        direction,
1078                        paragraph.level,
1079                    )?
1080                    .first()
1081                    .copied()
1082                    .unwrap_or(adjusted)
1083                })
1084            })
1085            .collect::<Result<Vec<_>>>()?;
1086        let visual_order = unicode_bidi::BidiInfo::reorder_visual(&levels);
1087        let logical_items = positions
1088            .iter()
1089            .zip(&levels)
1090            .map(|(index, level)| match &line.items[*index] {
1091                LineItem::MultilingualText(segment) => Ok(LineItem::MultilingualText(
1092                    multilingual_segment_with_level(segment, *level)?,
1093                )),
1094                item => Ok(item.clone()),
1095            })
1096            .collect::<Result<Vec<_>>>()?;
1097        for (visual_slot, logical_index) in positions.into_iter().zip(visual_order) {
1098            line.items[visual_slot] = logical_items[logical_index].clone();
1099        }
1100    }
1101    Ok(lines)
1102}
1103
1104fn multilingual_segment_with_level(
1105    segment: &MultilingualTextSegment,
1106    level: unicode_bidi::Level,
1107) -> Result<MultilingualTextSegment> {
1108    let parity_changed = segment.bidi_level() % 2 != level.number() % 2;
1109    let cluster_order = if parity_changed {
1110        (0..segment.clusters().len()).rev().collect::<Vec<_>>()
1111    } else {
1112        (0..segment.clusters().len()).collect::<Vec<_>>()
1113    };
1114    let mut glyph_ids = Vec::with_capacity(segment.glyph_ids().len());
1115    let mut x_advances = Vec::with_capacity(segment.x_advances().len());
1116    let mut y_advances = Vec::with_capacity(segment.y_advances().len());
1117    let mut x_offsets = Vec::with_capacity(segment.x_offsets().len());
1118    let mut y_offsets = Vec::with_capacity(segment.y_offsets().len());
1119    let mut clusters = Vec::with_capacity(segment.clusters().len());
1120    for index in cluster_order {
1121        let cluster = &segment.clusters()[index];
1122        let glyph_range = cluster.glyph_start as usize..cluster.glyph_end as usize;
1123        let glyph_start = glyph_ids.len() as u32;
1124        glyph_ids.extend_from_slice(&segment.glyph_ids()[glyph_range.clone()]);
1125        x_advances.extend_from_slice(&segment.x_advances()[glyph_range.clone()]);
1126        y_advances.extend_from_slice(&segment.y_advances()[glyph_range.clone()]);
1127        x_offsets.extend_from_slice(&segment.x_offsets()[glyph_range.clone()]);
1128        y_offsets.extend_from_slice(&segment.y_offsets()[glyph_range]);
1129        clusters.push(crate::font::GlyphCluster {
1130            glyph_start,
1131            glyph_end: glyph_ids.len() as u32,
1132            char_start: cluster.char_start,
1133            char_end: cluster.char_end,
1134        });
1135    }
1136    let mut base = segment.base().clone();
1137    base.glyph_ids = glyph_ids;
1138    base.advances = x_advances.clone();
1139
1140    MultilingualTextSegment::new(
1141        base,
1142        segment.logical_index(),
1143        segment.language().map(str::to_owned),
1144        segment.script(),
1145        if level.is_rtl() {
1146            TextDirection::RightToLeft
1147        } else {
1148            TextDirection::LeftToRight
1149        },
1150        level.number(),
1151        x_advances,
1152        y_advances,
1153        x_offsets,
1154        y_offsets,
1155        clusters,
1156        segment.break_after(),
1157    )
1158}
1159
1160// ---- Internal helpers ----
1161
1162#[derive(Debug)]
1163enum BreakableSegment {
1164    /// A group of items that should be kept together (word or cluster).
1165    Items(Vec<InlineItem>),
1166    /// One language-aware text chunk and its byte-index break candidates.
1167    Hyphenated(Box<HyphenatedSegment>),
1168    /// A forced break.
1169    ForcedBreak(ForcedBreakKind),
1170}
1171
1172#[derive(Debug)]
1173struct HyphenatedSegment {
1174    segment: TextSegment,
1175    break_points: Vec<usize>,
1176}
1177
1178struct FittingHyphenation {
1179    prefix: TextSegment,
1180    hyphen: TextSegment,
1181    remainder: TextSegment,
1182    remaining_points: Vec<usize>,
1183}
1184
1185/// Whether a complex-script line may break between two logical characters.
1186pub(crate) fn multilingual_break_allowed(before: char, after: char) -> bool {
1187    const OPENING: &[char] = &['(', '[', '{', '〈', '《', '「', '『', '【', '〔', '〖'];
1188    const CLOSING_OR_NONSTARTER: &[char] = &[
1189        ')', ']', '}', '〉', '》', '」', '』', '】', '〕', '〗', '、', '。', ',', '.', '!',
1190        '?', ':', ';', '%', '‰',
1191    ];
1192    !OPENING.contains(&before) && !CLOSING_OR_NONSTARTER.contains(&after)
1193}
1194
1195/// Build breakable segments by finding break opportunities in text.
1196///
1197/// Text items are split at unicode line-break opportunities (word boundaries,
1198/// hyphens, etc.). Non-text items (tabs, images, markers) are treated as
1199/// atomic units with break opportunities around them.
1200fn build_breakable_segments(
1201    items: &[InlineItem],
1202    fm: &FontManager,
1203) -> Result<Vec<BreakableSegment>> {
1204    let mut segments = Vec::new();
1205    let mut current_group: Vec<InlineItem> = Vec::new();
1206
1207    for item in items {
1208        match item {
1209            InlineItem::LineBreak => {
1210                if !current_group.is_empty() {
1211                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1212                }
1213                segments.push(BreakableSegment::ForcedBreak(ForcedBreakKind::Line));
1214            }
1215            InlineItem::PageBreak => {
1216                if !current_group.is_empty() {
1217                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1218                }
1219                segments.push(BreakableSegment::ForcedBreak(ForcedBreakKind::Page));
1220            }
1221            InlineItem::ColumnBreak => {
1222                if !current_group.is_empty() {
1223                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1224                }
1225                segments.push(BreakableSegment::ForcedBreak(ForcedBreakKind::Column));
1226            }
1227            InlineItem::Tab => {
1228                // Tab is a break opportunity
1229                if !current_group.is_empty() {
1230                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1231                }
1232                segments.push(BreakableSegment::Items(vec![item.clone()]));
1233            }
1234            InlineItem::Text(seg) | InlineItem::HyphenatedText { segment: seg, .. } => {
1235                if seg.text.is_empty() {
1236                    if !current_group.is_empty() {
1237                        segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1238                    }
1239                    segments.push(BreakableSegment::Items(vec![item.clone()]));
1240                    continue;
1241                }
1242                // Use unicode-linebreak to find break opportunities within text
1243                let breaks = split_text_at_break_opportunities(seg);
1244                let spacing =
1245                    if breaks.len() == 1 && breaks[0].start == 0 && breaks[0].end == seg.text.len()
1246                    {
1247                        0.0
1248                    } else {
1249                        let original = fm.shape_text(seg.font_id, &seg.text, seg.font_size)?;
1250                        if original.advances.len() == seg.advances.len()
1251                            && !original.advances.is_empty()
1252                        {
1253                            (seg.width - original.width) / original.advances.len() as f64
1254                        } else {
1255                            0.0
1256                        }
1257                    };
1258
1259                for tb in &breaks {
1260                    let chunk = &seg.text[tb.start..tb.end];
1261                    if chunk.is_empty() {
1262                        continue;
1263                    }
1264
1265                    // If this chunk starts with whitespace, treat as a break opportunity
1266                    if !current_group.is_empty() && chunk.starts_with(|c: char| c.is_whitespace()) {
1267                        segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1268                    }
1269
1270                    // Create a sub-segment for just this chunk (not the entire text)
1271                    let sub_item = split_text_subsegment(seg, tb.start, tb.end, spacing, fm)?;
1272                    let language = match item {
1273                        InlineItem::HyphenatedText { language, .. } => Some(language.as_str()),
1274                        _ => None,
1275                    };
1276                    let break_points = language.map_or_else(Vec::new, |language| {
1277                        hyphenation_opportunities(language, chunk)
1278                    });
1279                    if break_points.is_empty() {
1280                        current_group.push(sub_item);
1281                    } else {
1282                        if !current_group.is_empty() {
1283                            segments
1284                                .push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1285                        }
1286                        let InlineItem::Text(segment) = sub_item else {
1287                            unreachable!("split text always returns text")
1288                        };
1289                        segments.push(BreakableSegment::Hyphenated(Box::new(HyphenatedSegment {
1290                            segment,
1291                            break_points,
1292                        })));
1293                    }
1294
1295                    if tb.is_break {
1296                        segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1297                    }
1298                }
1299
1300                // Flush any remaining
1301                if !current_group.is_empty() {
1302                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1303                }
1304            }
1305            InlineItem::MultilingualText(segment) => {
1306                current_group.push(InlineItem::MultilingualText(segment.clone()));
1307                if segment.break_after() {
1308                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1309                }
1310            }
1311            InlineItem::Marker(_)
1312            | InlineItem::Image { .. }
1313            | InlineItem::Group { .. }
1314            | InlineItem::Figure { .. } => {
1315                current_group.push(item.clone());
1316            }
1317        }
1318    }
1319
1320    if !current_group.is_empty() {
1321        segments.push(BreakableSegment::Items(current_group));
1322    }
1323
1324    Ok(segments)
1325}
1326
1327/// Create a sub-segment InlineItem from a byte range within a TextSegment.
1328///
1329/// Reshapes the selected text and preserves formatting from the parent segment.
1330fn split_text_subsegment(
1331    seg: &TextSegment,
1332    byte_start: usize,
1333    byte_end: usize,
1334    spacing: f64,
1335    fm: &FontManager,
1336) -> Result<InlineItem> {
1337    // If this is the full segment, just clone it
1338    if byte_start == 0 && byte_end == seg.text.len() {
1339        return Ok(InlineItem::Text(seg.clone()));
1340    }
1341
1342    let sub_text = seg.text[byte_start..byte_end].to_string();
1343    let mut shaped = fm.shape_text(seg.font_id, &sub_text, seg.font_size)?;
1344    for advance in &mut shaped.advances {
1345        *advance += spacing;
1346    }
1347    shaped.width += spacing * shaped.advances.len() as f64;
1348
1349    let source = seg.source.map(|source| {
1350        let start = seg.text[..byte_start].chars().count() as u32;
1351        let end = seg.text[..byte_end].chars().count() as u32;
1352        SourceSpan {
1353            node: source.node,
1354            char_start: source.char_start + start,
1355            char_end: source.char_start + end,
1356        }
1357    });
1358
1359    Ok(InlineItem::Text(TextSegment {
1360        text: sub_text,
1361        direction: seg.direction,
1362        source,
1363        font_id: seg.font_id,
1364        font_size: seg.font_size,
1365        glyph_ids: shaped.glyph_ids,
1366        advances: shaped.advances,
1367        width: shaped.width,
1368        ascent: seg.ascent,
1369        descent: seg.descent,
1370        line_gap: seg.line_gap,
1371        color: seg.color,
1372        bold: seg.bold,
1373        italic: seg.italic,
1374        underline: seg.underline,
1375        strike: seg.strike,
1376        dstrike: seg.dstrike,
1377        highlight: seg.highlight,
1378        baseline_offset: seg.baseline_offset,
1379        hyperlink_url: seg.hyperlink_url.clone(),
1380        field_kind: seg.field_kind,
1381        field_source: seg.field_source,
1382        note: seg.note,
1383    }))
1384}
1385
1386fn supported_hyphenation_language(language: &str) -> Option<hypher::Lang> {
1387    let primary = language.split('-').next()?;
1388    if primary.eq_ignore_ascii_case("en") {
1389        Some(hypher::Lang::English)
1390    } else if primary.eq_ignore_ascii_case("fr") {
1391        Some(hypher::Lang::French)
1392    } else if primary.eq_ignore_ascii_case("de") {
1393        Some(hypher::Lang::German)
1394    } else if primary.eq_ignore_ascii_case("es") {
1395        Some(hypher::Lang::Spanish)
1396    } else {
1397        None
1398    }
1399}
1400
1401fn hyphenation_opportunities(language: &str, text: &str) -> Vec<usize> {
1402    let Some(language) = supported_hyphenation_language(language) else {
1403        return Vec::new();
1404    };
1405    let word_end = text
1406        .char_indices()
1407        .take_while(|(_, character)| character.is_alphabetic())
1408        .map(|(offset, character)| offset + character.len_utf8())
1409        .last()
1410        .unwrap_or(0);
1411    if word_end == 0 || text[word_end..].chars().any(char::is_alphabetic) {
1412        return Vec::new();
1413    }
1414    let word = &text[..word_end];
1415    let syllables = hypher::hyphenate(word, language).collect::<Vec<_>>();
1416    let mut offset = 0usize;
1417    syllables
1418        .iter()
1419        .take(syllables.len().saturating_sub(1))
1420        .map(|syllable| {
1421            offset += syllable.len();
1422            offset
1423        })
1424        .collect()
1425}
1426
1427fn fitting_hyphenation(
1428    segment: &TextSegment,
1429    break_points: &[usize],
1430    current_width: f64,
1431    available_width: f64,
1432    fm: &FontManager,
1433) -> Result<Option<FittingHyphenation>> {
1434    let spacing = text_segment_spacing(segment, fm)?;
1435    let hyphen = generated_hyphen(segment, spacing, fm)?;
1436    for &break_point in break_points.iter().rev() {
1437        let InlineItem::Text(prefix) = split_text_subsegment(segment, 0, break_point, spacing, fm)?
1438        else {
1439            unreachable!("split text always returns text")
1440        };
1441        if current_width + prefix.width + hyphen.width > available_width + 0.01 {
1442            continue;
1443        }
1444        let InlineItem::Text(remainder) =
1445            split_text_subsegment(segment, break_point, segment.text.len(), spacing, fm)?
1446        else {
1447            unreachable!("split text always returns text")
1448        };
1449        let remaining_points = break_points
1450            .iter()
1451            .copied()
1452            .filter(|point| *point > break_point)
1453            .map(|point| point - break_point)
1454            .collect();
1455        return Ok(Some(FittingHyphenation {
1456            prefix,
1457            hyphen,
1458            remainder,
1459            remaining_points,
1460        }));
1461    }
1462    Ok(None)
1463}
1464
1465fn text_segment_spacing(segment: &TextSegment, fm: &FontManager) -> Result<f64> {
1466    let original = fm.shape_text(segment.font_id, &segment.text, segment.font_size)?;
1467    Ok(
1468        if original.advances.len() == segment.advances.len() && !original.advances.is_empty() {
1469            (segment.width - original.width) / original.advances.len() as f64
1470        } else {
1471            0.0
1472        },
1473    )
1474}
1475
1476fn generated_hyphen(segment: &TextSegment, spacing: f64, fm: &FontManager) -> Result<TextSegment> {
1477    let mut shaped = fm.shape_text(segment.font_id, "-", segment.font_size)?;
1478    for advance in &mut shaped.advances {
1479        *advance += spacing;
1480    }
1481    shaped.width += spacing * shaped.advances.len() as f64;
1482    Ok(TextSegment {
1483        text: "-".to_owned(),
1484        direction: segment.direction,
1485        source: None,
1486        font_id: segment.font_id,
1487        font_size: segment.font_size,
1488        glyph_ids: shaped.glyph_ids,
1489        advances: shaped.advances,
1490        width: shaped.width,
1491        ascent: segment.ascent,
1492        descent: segment.descent,
1493        line_gap: segment.line_gap,
1494        color: segment.color,
1495        bold: segment.bold,
1496        italic: segment.italic,
1497        underline: segment.underline,
1498        strike: segment.strike,
1499        dstrike: segment.dstrike,
1500        highlight: segment.highlight,
1501        baseline_offset: segment.baseline_offset,
1502        hyperlink_url: segment.hyperlink_url.clone(),
1503        field_kind: segment.field_kind,
1504        field_source: segment.field_source,
1505        note: segment.note,
1506    })
1507}
1508
1509fn segment_font_id(item: &InlineItem) -> FontId {
1510    match item {
1511        InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. } => segment.font_id,
1512        _ => unreachable!("called only for text"),
1513    }
1514}
1515
1516fn segment_font_size(item: &InlineItem) -> f64 {
1517    match item {
1518        InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. } => segment.font_size,
1519        _ => unreachable!("called only for text"),
1520    }
1521}
1522
1523struct TextBreakInfo {
1524    /// Byte range within the original text.
1525    start: usize,
1526    end: usize,
1527    /// Whether a line break is allowed after this segment.
1528    is_break: bool,
1529}
1530
1531fn split_text_at_break_opportunities(seg: &TextSegment) -> Vec<TextBreakInfo> {
1532    use unicode_linebreak::{BreakOpportunity, linebreaks};
1533
1534    let text = &seg.text;
1535    if text.is_empty() {
1536        return vec![];
1537    }
1538
1539    let mut breaks = Vec::new();
1540    let mut last_start = 0;
1541
1542    for (byte_pos, opportunity) in linebreaks(text) {
1543        if byte_pos == 0 {
1544            continue;
1545        }
1546
1547        let is_break = matches!(
1548            opportunity,
1549            BreakOpportunity::Allowed | BreakOpportunity::Mandatory
1550        );
1551
1552        breaks.push(TextBreakInfo {
1553            start: last_start,
1554            end: byte_pos,
1555            is_break,
1556        });
1557        last_start = byte_pos;
1558    }
1559
1560    // If unicode-linebreak didn't produce any breaks, treat as one chunk
1561    if breaks.is_empty() {
1562        breaks.push(TextBreakInfo {
1563            start: 0,
1564            end: text.len(),
1565            is_break: true,
1566        });
1567    }
1568
1569    breaks
1570}
1571
1572/// Width of the spaces that end a group of text items.
1573///
1574/// They hang past the end of a line instead of wrapping the group, as Word and
1575/// PowerPoint let them.
1576fn hanging_space_width(items: &[InlineItem], fm: &FontManager) -> Result<f64> {
1577    let mut width = 0.0;
1578    for item in items.iter().rev() {
1579        match item {
1580            InlineItem::MultilingualText(segment) if is_space_run(segment.text()) => {
1581                width += segment.width();
1582            }
1583            InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. } => {
1584                width += terminal_u0020_width(segment, fm)?;
1585                if !is_space_run(&segment.text) {
1586                    break;
1587                }
1588            }
1589            _ => break,
1590        }
1591    }
1592    Ok(width)
1593}
1594
1595/// Width of the spaces that end a plain text segment.
1596fn terminal_u0020_width(segment: &TextSegment, fm: &FontManager) -> Result<f64> {
1597    Ok(trailing_spaces(segment, fm)?.map_or(0.0, |spaces| {
1598        segment.advances[segment.advances.len() - spaces..]
1599            .iter()
1600            .sum()
1601    }))
1602}
1603
1604/// How many U+0020 spaces end a plain text segment, which are then as many
1605/// glyphs at its end.
1606///
1607/// Plain text is shaped left to right, so its last glyphs are its last
1608/// characters, whatever ligatures come before them. `None` when it ends with
1609/// no space, holds a right-to-left character that would reverse its glyphs, or
1610/// does not end with that many of the font's space glyph.
1611fn trailing_spaces(segment: &TextSegment, fm: &FontManager) -> Result<Option<usize>> {
1612    if segment.field_kind.is_some() || segment.note.is_some() {
1613        return Ok(None);
1614    }
1615    let spaces = segment.text.len() - segment.text.trim_end_matches(' ').len();
1616    let glyphs = segment.glyph_ids.len();
1617    if spaces == 0
1618        || glyphs < spaces
1619        || segment.advances.len() != glyphs
1620        || segment.text.chars().any(|character| {
1621            matches!(
1622                unicode_bidi::bidi_class(character),
1623                unicode_bidi::BidiClass::R | unicode_bidi::BidiClass::AL
1624            )
1625        })
1626    {
1627        return Ok(None);
1628    }
1629    let space = fm.shape_text(segment.font_id, " ", segment.font_size)?;
1630    let [space_glyph] = space.glyph_ids[..] else {
1631        return Ok(None);
1632    };
1633    Ok(segment.glyph_ids[glyphs - spaces..]
1634        .iter()
1635        .all(|glyph| *glyph == space_glyph)
1636        .then_some(spaces))
1637}
1638
1639fn inline_item_width(item: &InlineItem) -> f64 {
1640    match item {
1641        InlineItem::Text(seg) | InlineItem::HyphenatedText { segment: seg, .. } => seg.width,
1642        InlineItem::MultilingualText(seg) => seg.width(),
1643        InlineItem::Tab => 36.0, // Default tab width, will be resolved
1644        InlineItem::Image { width, .. } => *width,
1645        InlineItem::Group { width, .. } => *width,
1646        InlineItem::Figure { item, .. } => inline_item_width(item),
1647        InlineItem::Marker(seg) => seg.width,
1648        InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => 0.0,
1649    }
1650}
1651
1652fn item_metrics(item: &InlineItem) -> (f64, f64, f64, f64, f64) {
1653    // Returns (width, ascent, descent, natural height, text font size)
1654    match item {
1655        InlineItem::Text(seg) | InlineItem::HyphenatedText { segment: seg, .. } => (
1656            seg.width,
1657            seg.ascent,
1658            seg.descent,
1659            seg.ascent + seg.descent + seg.line_gap,
1660            seg.font_size,
1661        ),
1662        InlineItem::MultilingualText(seg) => (
1663            seg.width(),
1664            seg.base().ascent,
1665            seg.base().descent,
1666            seg.base().ascent + seg.base().descent + seg.base().line_gap,
1667            seg.base().font_size,
1668        ),
1669        InlineItem::Marker(seg) => (
1670            seg.width,
1671            seg.ascent,
1672            seg.descent,
1673            seg.ascent + seg.descent + seg.line_gap,
1674            0.0,
1675        ),
1676        InlineItem::Tab => (36.0, 0.0, 0.0, 0.0, 0.0),
1677        InlineItem::Image { width, height, .. } => (*width, *height, 0.0, *height, 0.0),
1678        InlineItem::Group {
1679            width,
1680            height,
1681            baseline,
1682            ..
1683        } => {
1684            let baseline = normalized_group_baseline(*height, *baseline).unwrap_or(*height);
1685            (*width, baseline, *height - baseline, *height, 0.0)
1686        }
1687        InlineItem::Figure { item, .. } => item_metrics(item),
1688        InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => {
1689            (0.0, 0.0, 0.0, 0.0, 0.0)
1690        }
1691    }
1692}
1693
1694fn normalized_group_baseline(height: f64, baseline: Option<f64>) -> Option<f64> {
1695    if !height.is_finite() {
1696        return None;
1697    }
1698    let upper = height.max(0.0);
1699    baseline
1700        .filter(|value| value.is_finite())
1701        .map(|value| value.clamp(0.0, upper))
1702}
1703
1704fn inline_to_line_item(item: &InlineItem) -> LineItem {
1705    match item {
1706        InlineItem::Text(seg) | InlineItem::HyphenatedText { segment: seg, .. } => {
1707            LineItem::Text(seg.clone())
1708        }
1709        InlineItem::MultilingualText(seg) => LineItem::MultilingualText(seg.clone()),
1710        InlineItem::Marker(seg) => LineItem::Marker(seg.clone()),
1711        // `LineState::push_tab` places tabs, so this is only a placeholder.
1712        InlineItem::Tab => LineItem::Tab {
1713            width: 0.0,
1714            leader: None,
1715            align: TabAlign::Left,
1716            gap: 0.0,
1717        },
1718        InlineItem::Image {
1719            width,
1720            height,
1721            media_id,
1722        } => LineItem::Image {
1723            width: *width,
1724            height: *height,
1725            media_id: *media_id,
1726        },
1727        InlineItem::Group {
1728            width,
1729            height,
1730            baseline,
1731            group,
1732        } => LineItem::Group {
1733            width: *width,
1734            height: *height,
1735            baseline: normalized_group_baseline(*height, *baseline),
1736            group: group.clone(),
1737        },
1738        InlineItem::Figure {
1739            item,
1740            alternate_text,
1741            structure_id,
1742        } => LineItem::Figure {
1743            item: Box::new(inline_to_line_item(item)),
1744            alternate_text: alternate_text.clone(),
1745            structure_id: *structure_id,
1746        },
1747        InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => LineItem::Tab {
1748            width: 0.0,
1749            leader: None,
1750            align: TabAlign::Left,
1751            gap: 0.0,
1752        },
1753    }
1754}
1755
1756/// Shape a leader character repeated to fill the given width.
1757fn shape_leader(
1758    fm: &FontManager,
1759    font_ctx: Option<(FontId, f64)>,
1760    leader_char: char,
1761    tab_width: f64,
1762) -> Option<TextSegment> {
1763    let (font_id, font_size) = font_ctx?;
1764    if tab_width < 1.0 {
1765        return None;
1766    }
1767
1768    // Shape a single leader character to get its advance width
1769    let single = String::from(leader_char);
1770    let shaped = fm.shape_text(font_id, &single, font_size).ok()?;
1771    if shaped.glyph_ids.is_empty() {
1772        return None;
1773    }
1774    let char_advance = shaped.advances[0];
1775    if char_advance < 0.5 {
1776        return None;
1777    }
1778
1779    // Add a small gap between leader chars (about 50% of char width for dots, less for others)
1780    let spacing = match leader_char {
1781        '.' | '\u{00B7}' => char_advance * 0.5,
1782        _ => char_advance * 0.15,
1783    };
1784    let step = char_advance + spacing;
1785    let count = ((tab_width - spacing) / step).floor() as usize;
1786    if count == 0 {
1787        return None;
1788    }
1789
1790    // Build the repeated leader text and glyph arrays
1791    let leader_text: String = std::iter::repeat_n(leader_char, count).collect();
1792    let mut glyph_ids = Vec::with_capacity(count);
1793    let mut advances = Vec::with_capacity(count);
1794    for i in 0..count {
1795        glyph_ids.push(shaped.glyph_ids[0]);
1796        if i + 1 < count {
1797            advances.push(char_advance + spacing);
1798        } else {
1799            advances.push(char_advance);
1800        }
1801    }
1802
1803    let metrics = fm.metrics(font_id, font_size).ok()?;
1804
1805    Some(TextSegment {
1806        text: leader_text,
1807        direction: TextDirection::Auto,
1808        source: None,
1809        font_id,
1810        font_size,
1811        glyph_ids,
1812        advances,
1813        width: tab_width, // fill the entire tab gap
1814        ascent: metrics.ascent,
1815        descent: metrics.descent,
1816        line_gap: metrics.line_gap,
1817        color: Color::BLACK,
1818        bold: false,
1819        italic: false,
1820        underline: None,
1821        strike: false,
1822        dstrike: false,
1823        highlight: None,
1824        baseline_offset: 0.0,
1825        hyperlink_url: None,
1826        field_kind: None,
1827        field_source: None,
1828        note: None,
1829    })
1830}
1831
1832fn compute_first_line_width(params: &LineBreakParams) -> f64 {
1833    if params.ind_hanging > 0.0 {
1834        // Hanging indent: first line has MORE width (extends left)
1835        params.available_width - params.ind_left - params.ind_right + params.ind_hanging
1836    } else {
1837        params.available_width - params.ind_left - params.ind_right - params.ind_first_line
1838    }
1839}
1840
1841fn compute_subsequent_line_width(params: &LineBreakParams) -> f64 {
1842    params.available_width - params.ind_left - params.ind_right
1843}
1844
1845/// Width kept clear at the start of a given line.
1846fn line_prefix_width(params: &LineBreakParams, line_index: usize) -> f64 {
1847    params
1848        .line_prefix_widths
1849        .get(line_index)
1850        .copied()
1851        .unwrap_or(0.0)
1852}
1853
1854/// Width kept clear at the end of a given line.
1855fn line_suffix_width(params: &LineBreakParams, line_index: usize) -> f64 {
1856    params
1857        .line_suffix_widths
1858        .get(line_index)
1859        .copied()
1860        .unwrap_or(0.0)
1861}
1862
1863/// Usable width of a line, once anything floating beside it is taken out.
1864fn line_width_at(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 {
1865    let base = if is_first_line {
1866        compute_first_line_width(params)
1867    } else {
1868        compute_subsequent_line_width(params)
1869    };
1870    (base - line_prefix_width(params, line_index) - line_suffix_width(params, line_index)).max(0.0)
1871}
1872
1873/// Where a line starts, once anything floating to its left is taken out.
1874fn line_indent_at(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 {
1875    let base = if is_first_line {
1876        first_line_indent(params)
1877    } else {
1878        subsequent_line_indent(params)
1879    };
1880    base + line_prefix_width(params, line_index)
1881}
1882
1883fn first_line_indent(params: &LineBreakParams) -> f64 {
1884    if params.ind_hanging > 0.0 {
1885        params.ind_left - params.ind_hanging
1886    } else {
1887        params.ind_left + params.ind_first_line
1888    }
1889}
1890
1891fn subsequent_line_indent(params: &LineBreakParams) -> f64 {
1892    params.ind_left
1893}
1894
1895/// Compute line height based on spacing rules.
1896fn effective_line_gap(ascent: f64, descent: f64, natural_height: f64) -> f64 {
1897    (natural_height - ascent - descent).max(0.0)
1898}
1899
1900fn compute_line_height(
1901    ascent: f64,
1902    descent: f64,
1903    line_gap: f64,
1904    font_size: f64,
1905    params: &LineBreakParams,
1906) -> f64 {
1907    let natural = ascent + descent + line_gap;
1908    let natural = if natural < 1.0 { 12.0 } else { natural }; // minimum for empty lines
1909    let font_size = if font_size < 1.0 { 12.0 } else { font_size };
1910
1911    match params.line_spacing {
1912        LineSpacing::Single => natural,
1913        LineSpacing::Multiple(factor) => font_size * factor,
1914        LineSpacing::Exact(points) => points,
1915        LineSpacing::AtLeast(points) => natural.max(points),
1916    }
1917}
1918
1919#[cfg(test)]
1920mod tests {
1921    use super::*;
1922
1923    fn make_text_segment(text: &str, width: f64) -> TextSegment {
1924        TextSegment {
1925            text: text.to_string(),
1926            direction: TextDirection::Auto,
1927            source: None,
1928            font_id: FontId(0),
1929            font_size: 12.0,
1930            glyph_ids: vec![],
1931            advances: vec![],
1932            width,
1933            ascent: 10.0,
1934            descent: 3.0,
1935            line_gap: 0.0,
1936            color: Color::BLACK,
1937            bold: false,
1938            italic: false,
1939            underline: None,
1940            strike: false,
1941            dstrike: false,
1942            highlight: None,
1943            baseline_offset: 0.0,
1944            hyperlink_url: None,
1945            field_kind: None,
1946            field_source: None,
1947            note: None,
1948        }
1949    }
1950
1951    fn deterministic_font_manager() -> FontManager {
1952        FontManager::new_deterministic().expect("bundled fonts should load")
1953    }
1954
1955    fn shaped_text_segment(fm: &mut FontManager, text: &str, spacing: f64) -> TextSegment {
1956        let font_id = fm
1957            .resolve_font(Some("Carlito"), false, false)
1958            .expect("bundled Carlito should resolve");
1959        let metrics = fm.metrics(font_id, 30.0).expect("Carlito metrics");
1960        let mut shaped = fm.shape_text(font_id, text, 30.0).expect("shape text");
1961        for advance in &mut shaped.advances {
1962            *advance += spacing;
1963        }
1964        shaped.width += spacing * shaped.advances.len() as f64;
1965        TextSegment {
1966            text: text.to_owned(),
1967            direction: TextDirection::Auto,
1968            source: None,
1969            font_id,
1970            font_size: 30.0,
1971            glyph_ids: shaped.glyph_ids,
1972            advances: shaped.advances,
1973            width: shaped.width,
1974            ascent: metrics.ascent,
1975            descent: metrics.descent,
1976            line_gap: metrics.line_gap,
1977            color: Color::BLACK,
1978            bold: false,
1979            italic: false,
1980            underline: None,
1981            strike: false,
1982            dstrike: false,
1983            highlight: None,
1984            baseline_offset: 0.0,
1985            hyperlink_url: None,
1986            field_kind: None,
1987            field_source: None,
1988            note: None,
1989        }
1990    }
1991
1992    #[test]
1993    fn automatic_hyphenation_selects_the_farthest_fitting_break_and_has_no_source() {
1994        let mut fm = deterministic_font_manager();
1995        let node = crate::SourceNodeId::new(9).unwrap();
1996        let mut segment = shaped_text_segment(&mut fm, "representation", 0.0);
1997        segment.source = Some(SourceSpan {
1998            node,
1999            char_start: 20,
2000            char_end: 34,
2001        });
2002        let width = fm
2003            .shape_text(segment.font_id, "represen-", segment.font_size)
2004            .unwrap()
2005            .width
2006            + 0.01;
2007        let lines = break_into_lines(
2008            &[InlineItem::HyphenatedText {
2009                segment,
2010                language: "en-US".to_owned(),
2011            }],
2012            &LineBreakParams {
2013                available_width: width,
2014                ..Default::default()
2015            },
2016            &fm,
2017        )
2018        .unwrap();
2019        let first = lines[0]
2020            .items
2021            .iter()
2022            .filter_map(|item| match item {
2023                LineItem::Text(text) => Some(text),
2024                _ => None,
2025            })
2026            .collect::<Vec<_>>();
2027
2028        assert_eq!(
2029            first
2030                .iter()
2031                .map(|text| text.text.as_str())
2032                .collect::<String>(),
2033            "represen-"
2034        );
2035        assert_eq!(first.last().unwrap().source, None);
2036        assert_eq!(first[0].source.unwrap().char_start, 20);
2037        assert_eq!(first[0].source.unwrap().char_end, 28);
2038    }
2039
2040    #[test]
2041    fn liang_candidates_map_supported_regional_languages_only() {
2042        assert_eq!(
2043            hyphenation_opportunities("en-US", "representation"),
2044            vec![3, 5, 8, 10]
2045        );
2046        assert_eq!(
2047            hyphenation_opportunities("fr-CA", "représentation"),
2048            vec![2, 6, 9, 11]
2049        );
2050        assert!(!hyphenation_opportunities("de-AT", "Silbentrennung").is_empty());
2051        assert!(!hyphenation_opportunities("es-MX", "representación").is_empty());
2052        assert!(hyphenation_opportunities("it-IT", "rappresentazione").is_empty());
2053    }
2054
2055    #[test]
2056    fn unwrapped_hyphenated_text_never_emits_a_conditional_hyphen() {
2057        let mut fm = deterministic_font_manager();
2058        let segment = shaped_text_segment(&mut fm, "representation", 0.0);
2059        let lines = break_into_lines(
2060            &[InlineItem::HyphenatedText {
2061                segment,
2062                language: "en-US".to_owned(),
2063            }],
2064            &LineBreakParams {
2065                available_width: 20.0,
2066                wrap: false,
2067                ..Default::default()
2068            },
2069            &fm,
2070        )
2071        .unwrap();
2072        assert_eq!(lines.len(), 1);
2073        let text = lines[0]
2074            .items
2075            .iter()
2076            .filter_map(|item| match item {
2077                LineItem::Text(text) => Some(text.text.as_str()),
2078                _ => None,
2079            })
2080            .collect::<String>();
2081
2082        assert_eq!(text, "representation");
2083    }
2084
2085    #[test]
2086    fn mixed_direction_line_uses_uax9_visual_order_without_changing_logical_text() {
2087        let mut fm = deterministic_font_manager();
2088        let mut segment = shaped_text_segment(&mut fm, "abc אבג 123", 0.0);
2089        segment.source = Some(SourceSpan {
2090            node: crate::SourceNodeId::new(7).unwrap(),
2091            char_start: 50,
2092            char_end: 61,
2093        });
2094        let rich = fm
2095            .shape_multilingual_text(segment, Some("he-IL"), TextDirection::Auto, false)
2096            .unwrap();
2097        let logical_text = rich.iter().map(|span| span.text()).collect::<String>();
2098        let sources = rich
2099            .iter()
2100            .map(|span| span.base().source.expect("logical span keeps source"))
2101            .collect::<Vec<_>>();
2102        assert_eq!(sources.first().unwrap().char_start, 50);
2103        assert_eq!(sources.last().unwrap().char_end, 61);
2104        assert!(
2105            sources
2106                .windows(2)
2107                .all(|pair| pair[0].char_end == pair[1].char_start)
2108        );
2109        let items = rich
2110            .into_iter()
2111            .map(InlineItem::MultilingualText)
2112            .collect::<Vec<_>>();
2113        let lines = break_multilingual_into_lines(
2114            &items,
2115            &LineBreakParams {
2116                available_width: 1_000.0,
2117                ..Default::default()
2118            },
2119            &fm,
2120            TextDirection::Auto,
2121        )
2122        .unwrap();
2123        let visual_text = lines[0]
2124            .items
2125            .iter()
2126            .filter_map(|item| match item {
2127                LineItem::MultilingualText(span) => Some(span.text()),
2128                _ => None,
2129            })
2130            .collect::<String>();
2131        assert_eq!(logical_text, "abc אבג 123");
2132        assert_eq!(visual_text, "abc 123 אבג");
2133        assert_eq!(
2134            lines[0]
2135                .items
2136                .iter()
2137                .filter_map(|item| match item {
2138                    LineItem::MultilingualText(span) => {
2139                        Some((span.text(), span.logical_index(), span.bidi_level()))
2140                    }
2141                    _ => None,
2142                })
2143                .collect::<Vec<_>>(),
2144            vec![
2145                ("abc", 0, 0),
2146                (" ", 1, 0),
2147                ("123", 4, 2),
2148                (" ", 3, 1),
2149                ("אבג", 2, 1)
2150            ]
2151        );
2152    }
2153
2154    #[test]
2155    fn explicit_run_directions_reorder_with_the_rtl_paragraph_once() {
2156        let mut fm = deterministic_font_manager();
2157        let mut arabic = shaped_text_segment(&mut fm, "العربية ", 0.0);
2158        arabic.direction = TextDirection::RightToLeft;
2159        let mut latin = shaped_text_segment(&mut fm, "ABC 123", 0.0);
2160        latin.direction = TextDirection::LeftToRight;
2161        let rich = fm
2162            .shape_multilingual_paragraph(
2163                vec![
2164                    (arabic, Some("ar-SA".to_owned())),
2165                    (latin, Some("en-US".to_owned())),
2166                ],
2167                TextDirection::RightToLeft,
2168                false,
2169            )
2170            .unwrap();
2171        let lines = break_multilingual_into_lines(
2172            &rich
2173                .into_iter()
2174                .map(InlineItem::MultilingualText)
2175                .collect::<Vec<_>>(),
2176            &LineBreakParams {
2177                available_width: 1_000.0,
2178                ..Default::default()
2179            },
2180            &fm,
2181            TextDirection::RightToLeft,
2182        )
2183        .unwrap();
2184
2185        let visual = lines[0]
2186            .items
2187            .iter()
2188            .filter_map(|item| match item {
2189                LineItem::MultilingualText(span) => {
2190                    Some((span.text(), span.bidi_level(), span.base().direction))
2191                }
2192                _ => None,
2193            })
2194            .collect::<Vec<_>>();
2195        assert_eq!(
2196            visual.iter().map(|span| span.0).collect::<String>(),
2197            "ABC 123 العربية",
2198            "{visual:?}"
2199        );
2200    }
2201
2202    #[test]
2203    fn explicit_rtl_run_retains_numeric_levels_and_line_local_whitespace_reset() {
2204        let mut fm = deterministic_font_manager();
2205        let node = crate::SourceNodeId::new(13).unwrap();
2206        let mut segment = shaped_text_segment(&mut fm, "אבג 123   ", 0.0);
2207        segment.direction = TextDirection::RightToLeft;
2208        segment.source = Some(SourceSpan {
2209            node,
2210            char_start: 40,
2211            char_end: 50,
2212        });
2213        let rich = fm
2214            .shape_multilingual_paragraph(
2215                vec![(segment, Some("he-IL".to_owned()))],
2216                TextDirection::LeftToRight,
2217                false,
2218            )
2219            .unwrap();
2220        assert!(
2221            rich.iter()
2222                .any(|span| span.text() == "123" && span.bidi_level() == 2),
2223            "numeric span must retain its higher even level: {:?}",
2224            rich.iter()
2225                .map(|span| (span.text(), span.bidi_level()))
2226                .collect::<Vec<_>>()
2227        );
2228
2229        let lines = break_multilingual_into_lines(
2230            &rich
2231                .into_iter()
2232                .map(InlineItem::MultilingualText)
2233                .collect::<Vec<_>>(),
2234            &LineBreakParams {
2235                available_width: 1_000.0,
2236                ..Default::default()
2237            },
2238            &fm,
2239            TextDirection::LeftToRight,
2240        )
2241        .unwrap();
2242        let spans = lines[0]
2243            .items
2244            .iter()
2245            .filter_map(|item| match item {
2246                LineItem::MultilingualText(span) => Some(span),
2247                _ => None,
2248            })
2249            .collect::<Vec<_>>();
2250        assert!(spans.iter().any(|span| {
2251            span.text().chars().all(char::is_whitespace) && span.bidi_level() == 0
2252        }));
2253        assert!(
2254            spans
2255                .iter()
2256                .all(|span| span.base().source.unwrap().node == node)
2257        );
2258    }
2259
2260    #[test]
2261    fn inline_object_participates_in_rtl_visual_order_without_changing_text_source() {
2262        let mut fm = deterministic_font_manager();
2263        let node = crate::SourceNodeId::new(14).unwrap();
2264        let mut segment = shaped_text_segment(&mut fm, "אבג", 0.0);
2265        segment.source = Some(SourceSpan {
2266            node,
2267            char_start: 8,
2268            char_end: 11,
2269        });
2270        let mut rich = fm
2271            .shape_multilingual_text(segment, Some("he-IL"), TextDirection::RightToLeft, false)
2272            .unwrap();
2273        assert_eq!(rich.len(), 1);
2274        let items = vec![
2275            InlineItem::MultilingualText(rich.remove(0)),
2276            InlineItem::Image {
2277                width: 12.0,
2278                height: 12.0,
2279                media_id: MediaId(77),
2280            },
2281        ];
2282
2283        let lines = break_multilingual_into_lines(
2284            &items,
2285            &LineBreakParams {
2286                available_width: 1_000.0,
2287                ..Default::default()
2288            },
2289            &fm,
2290            TextDirection::RightToLeft,
2291        )
2292        .unwrap();
2293
2294        assert!(matches!(
2295            lines[0].items[0],
2296            LineItem::Image {
2297                media_id: MediaId(77),
2298                ..
2299            }
2300        ));
2301        let LineItem::MultilingualText(text) = &lines[0].items[1] else {
2302            panic!("RTL text must paint after the inline object")
2303        };
2304        assert_eq!(text.text(), "אבג");
2305        assert_eq!(
2306            text.base().source.unwrap(),
2307            SourceSpan {
2308                node,
2309                char_start: 8,
2310                char_end: 11,
2311            }
2312        );
2313    }
2314
2315    #[test]
2316    fn explicit_rtl_hyphenatable_latin_spans_keep_their_natural_even_levels() {
2317        let mut fm = deterministic_font_manager();
2318        let mut letters = shaped_text_segment(&mut fm, "ABC ", 0.0);
2319        letters.direction = TextDirection::RightToLeft;
2320        let mut digits = shaped_text_segment(&mut fm, "123", 0.0);
2321        digits.direction = TextDirection::RightToLeft;
2322        let lines = break_multilingual_into_lines(
2323            &[
2324                InlineItem::HyphenatedText {
2325                    segment: letters,
2326                    language: "en-US".to_owned(),
2327                },
2328                InlineItem::HyphenatedText {
2329                    segment: digits,
2330                    language: "en-US".to_owned(),
2331                },
2332            ],
2333            &LineBreakParams {
2334                available_width: 1_000.0,
2335                ..Default::default()
2336            },
2337            &fm,
2338            TextDirection::LeftToRight,
2339        )
2340        .unwrap();
2341        let visual = lines[0]
2342            .items
2343            .iter()
2344            .filter_map(|item| match item {
2345                LineItem::Text(segment) => Some(segment.text.as_str()),
2346                _ => None,
2347            })
2348            .collect::<String>();
2349        assert_eq!(visual, "ABC 123");
2350    }
2351
2352    #[test]
2353    fn one_styled_run_applies_line_local_l1_before_l2_without_losing_source() {
2354        let mut fm = deterministic_font_manager();
2355        let node = crate::SourceNodeId::new(9).unwrap();
2356        let mut segment = shaped_text_segment(&mut fm, "אבג   אבג", 0.0);
2357        segment.source = Some(SourceSpan {
2358            node,
2359            char_start: 20,
2360            char_end: 29,
2361        });
2362        let rich = fm
2363            .shape_multilingual_paragraph(vec![(segment, None)], TextDirection::LeftToRight, false)
2364            .unwrap();
2365        assert_eq!(
2366            rich.iter().map(|span| span.text()).collect::<Vec<_>>(),
2367            ["אבג", "   ", "אבג"]
2368        );
2369        let first_line_width = rich[0].width() + rich[1].width() + 0.01;
2370        let items = rich
2371            .into_iter()
2372            .map(InlineItem::MultilingualText)
2373            .collect::<Vec<_>>();
2374
2375        let lines = break_multilingual_into_lines(
2376            &items,
2377            &LineBreakParams {
2378                available_width: first_line_width,
2379                ..Default::default()
2380            },
2381            &fm,
2382            TextDirection::LeftToRight,
2383        )
2384        .unwrap();
2385        let spans = lines
2386            .iter()
2387            .flat_map(|line| &line.items)
2388            .filter_map(|item| match item {
2389                LineItem::MultilingualText(span) => Some((
2390                    span.text(),
2391                    span.logical_index(),
2392                    span.bidi_level(),
2393                    span.direction(),
2394                    span.base().source.unwrap(),
2395                )),
2396                _ => None,
2397            })
2398            .collect::<Vec<_>>();
2399        assert_eq!(
2400            spans
2401                .iter()
2402                .map(|(text, index, level, direction, source)| (
2403                    *text,
2404                    *index,
2405                    *level,
2406                    *direction,
2407                    source.char_start..source.char_end,
2408                ))
2409                .collect::<Vec<_>>(),
2410            [
2411                ("אבג", 0, 1, TextDirection::RightToLeft, 20..23),
2412                ("   ", 1, 0, TextDirection::LeftToRight, 23..26),
2413                ("אבג", 2, 1, TextDirection::RightToLeft, 26..29),
2414            ]
2415        );
2416        assert!(spans.iter().all(|(_, _, _, _, source)| source.node == node));
2417        let first_line = lines[0]
2418            .items
2419            .iter()
2420            .filter_map(|item| match item {
2421                LineItem::MultilingualText(span) => Some(span.text()),
2422                _ => None,
2423            })
2424            .collect::<String>();
2425
2426        assert_eq!(first_line, "אבג   ");
2427    }
2428
2429    #[test]
2430    fn cjk_prohibited_punctuation_never_starts_or_ends_a_line() {
2431        let mut fm = deterministic_font_manager();
2432        let mut segment = shaped_text_segment(&mut fm, "〈中〉、你好世界", 0.0);
2433        segment.source = Some(SourceSpan {
2434            node: crate::SourceNodeId::new(8).unwrap(),
2435            char_start: 5,
2436            char_end: 13,
2437        });
2438        let rich = fm
2439            .shape_multilingual_text(segment, Some("zh-CN"), TextDirection::LeftToRight, false)
2440            .unwrap();
2441        let items = rich
2442            .into_iter()
2443            .map(InlineItem::MultilingualText)
2444            .collect::<Vec<_>>();
2445        let lines = break_multilingual_into_lines(
2446            &items,
2447            &LineBreakParams {
2448                available_width: 35.0,
2449                ..Default::default()
2450            },
2451            &fm,
2452            TextDirection::LeftToRight,
2453        )
2454        .unwrap();
2455        let line_text = lines
2456            .iter()
2457            .map(|line| {
2458                line.items
2459                    .iter()
2460                    .filter_map(|item| match item {
2461                        LineItem::MultilingualText(span) => Some(span.text()),
2462                        _ => None,
2463                    })
2464                    .collect::<String>()
2465            })
2466            .collect::<Vec<_>>();
2467        assert_eq!(line_text, ["〈中〉、", "你", "好", "世", "界"]);
2468        assert!(
2469            line_text
2470                .iter()
2471                .all(|text| { !text.starts_with(['〉', '、']) && !text.ends_with('〈') })
2472        );
2473    }
2474
2475    #[test]
2476    fn rich_lines_never_start_with_a_comma_a_space_or_a_hyphen() {
2477        let mut fm = deterministic_font_manager();
2478        let text = "Repainted in 3 weeks while still in service, or re-coated next spring";
2479        let segment = shaped_text_segment(&mut fm, text, 0.0);
2480        let items = fm
2481            .shape_multilingual_paragraph(vec![(segment, None)], TextDirection::LeftToRight, false)
2482            .unwrap()
2483            .into_iter()
2484            .map(InlineItem::MultilingualText)
2485            .collect::<Vec<_>>();
2486        let rich_text = |line: &LayoutLine| {
2487            line.items
2488                .iter()
2489                .filter_map(|item| match item {
2490                    LineItem::MultilingualText(span) => Some(span.text()),
2491                    _ => None,
2492                })
2493                .collect::<String>()
2494        };
2495
2496        for width in (300..=1000).step_by(5) {
2497            let lines = break_multilingual_into_lines(
2498                &items,
2499                &LineBreakParams {
2500                    available_width: f64::from(width),
2501                    ..Default::default()
2502                },
2503                &fm,
2504                TextDirection::LeftToRight,
2505            )
2506            .unwrap();
2507            let texts = lines.iter().map(rich_text).collect::<Vec<_>>();
2508            assert_eq!(texts.concat(), text);
2509            for line_text in &texts[1..] {
2510                assert!(
2511                    !line_text.starts_with([',', ' ', '-']),
2512                    "{width}: {texts:?}"
2513                );
2514            }
2515            for line in &lines {
2516                let hanging = line
2517                    .items
2518                    .iter()
2519                    .rev()
2520                    .map_while(|item| match item {
2521                        LineItem::MultilingualText(span) if span.text().trim().is_empty() => {
2522                            Some(span.width())
2523                        }
2524                        _ => None,
2525                    })
2526                    .sum::<f64>();
2527                assert!(
2528                    line.width - hanging <= line.available_width + 0.01,
2529                    "{width}: {texts:?}"
2530                );
2531            }
2532        }
2533    }
2534
2535    #[test]
2536    fn a_rich_line_lets_the_space_after_its_last_word_hang() {
2537        let mut fm = deterministic_font_manager();
2538        let first = "Repainted in 3 weeks while still in service,";
2539        let segment = shaped_text_segment(&mut fm, &format!("{first} or re-coated"), 0.0);
2540        let ink = shaped_text_segment(&mut fm, first, 0.0).width;
2541        let items = fm
2542            .shape_multilingual_paragraph(vec![(segment, None)], TextDirection::LeftToRight, false)
2543            .unwrap()
2544            .into_iter()
2545            .map(InlineItem::MultilingualText)
2546            .collect::<Vec<_>>();
2547
2548        // The comma fits and the space after it does not.
2549        let lines = break_multilingual_into_lines(
2550            &items,
2551            &LineBreakParams {
2552                available_width: ink + 1.0,
2553                ..Default::default()
2554            },
2555            &fm,
2556            TextDirection::LeftToRight,
2557        )
2558        .unwrap();
2559
2560        let LineItem::MultilingualText(last) = lines[0].items.last().unwrap() else {
2561            panic!("the first line ends with rich text");
2562        };
2563        assert_eq!(last.text(), " ");
2564        assert!((lines[0].width - last.width() - ink).abs() < 0.001);
2565        assert!(lines[0].width > lines[0].available_width);
2566        let LineItem::MultilingualText(next) = &lines[1].items[0] else {
2567            panic!("the second line starts with rich text");
2568        };
2569        assert_eq!(next.text(), "or");
2570    }
2571
2572    #[test]
2573    fn a_plain_line_lets_the_spaces_after_its_last_word_hang() {
2574        let mut fm = deterministic_font_manager();
2575        let plain_text = |line: &LayoutLine| {
2576            line.items
2577                .iter()
2578                .map(|item| match item {
2579                    LineItem::Text(segment) => segment.text.as_str(),
2580                    _ => panic!("plain lines hold plain text"),
2581                })
2582                .collect::<String>()
2583        };
2584        // The comma fits and the spaces after it do not. U+0020 hangs, and a
2585        // no-break space stays part of its word. Carlito ligates the "ffi" of
2586        // "office", so the word has fewer glyphs than characters.
2587        let service = "Repainted in 3 weeks while still in service,";
2588        let office = "Repainted in 3 weeks while still in office,";
2589        let cases = [
2590            (service, " ", format!("{service} "), "or "),
2591            (service, "   ", format!("{service}   "), "or "),
2592            (
2593                service,
2594                "\u{a0} ",
2595                "Repainted in 3 weeks while still in ".to_owned(),
2596                "service,",
2597            ),
2598            (office, " ", format!("{office} "), "or "),
2599        ];
2600        for (first, spaces, first_line, next_start) in cases {
2601            let ink = shaped_text_segment(&mut fm, first, 0.0).width;
2602            let text = format!("{first}{spaces}or re-coated");
2603            let items = [InlineItem::Text(shaped_text_segment(&mut fm, &text, 0.0))];
2604            let lines = break_into_lines(
2605                &items,
2606                &LineBreakParams {
2607                    available_width: ink + 1.0,
2608                    ..Default::default()
2609                },
2610                &fm,
2611            )
2612            .unwrap();
2613
2614            assert_eq!(plain_text(&lines[0]), first_line, "{text:?}");
2615            assert!(plain_text(&lines[1]).starts_with(next_start), "{text:?}");
2616            // The spaces are an item of their own, past the available width.
2617            assert_eq!(lines[0].hanging_space_counts(), (0, 1), "{text:?}");
2618            let LineItem::Text(last) = lines[0].items.last().unwrap() else {
2619                unreachable!("plain lines hold plain text");
2620            };
2621            assert_eq!(last.text, first_line[first_line.trim_end().len()..]);
2622            assert_eq!(last.glyph_ids.len(), last.text.chars().count());
2623            assert!(lines[0].width - last.width <= lines[0].available_width + 0.01);
2624        }
2625        let ligated = shaped_text_segment(&mut fm, office, 0.0);
2626        assert!(ligated.glyph_ids.len() < office.chars().count());
2627    }
2628
2629    #[test]
2630    fn hanging_spaces_sit_at_the_visual_end_of_their_direction() {
2631        let mut fm = deterministic_font_manager();
2632        let cases = [
2633            ("שלום עולם זה טקסט ארוך", TextDirection::RightToLeft, (1, 0)),
2634            (
2635                "one two three four five",
2636                TextDirection::LeftToRight,
2637                (0, 1),
2638            ),
2639            // An ideographic space is not UAX 14 SP, so it does not hang.
2640            (
2641                "one\u{3000}two\u{3000}three",
2642                TextDirection::LeftToRight,
2643                (0, 0),
2644            ),
2645        ];
2646        for (text, direction, wrapped) in cases {
2647            let segment = shaped_text_segment(&mut fm, text, 0.0);
2648            let items = fm
2649                .shape_multilingual_paragraph(vec![(segment, None)], direction, false)
2650                .unwrap()
2651                .into_iter()
2652                .map(InlineItem::MultilingualText)
2653                .collect::<Vec<_>>();
2654            let lines = break_multilingual_into_lines(
2655                &items,
2656                &LineBreakParams {
2657                    available_width: 150.0,
2658                    ..Default::default()
2659                },
2660                &fm,
2661                direction,
2662            )
2663            .unwrap();
2664            assert!(lines.len() > 1, "{text}");
2665            for line in &lines[..lines.len() - 1] {
2666                assert_eq!(line.hanging_space_counts(), wrapped, "{text}");
2667            }
2668            assert_eq!(lines.last().unwrap().hanging_space_counts(), (0, 0));
2669        }
2670    }
2671
2672    #[test]
2673    fn legacy_stable_source_fixture_compiles_unchanged_public_shapes() {
2674        let segment = make_text_segment("legacy", 42.0);
2675        let _inline = InlineItem::Text(segment.clone());
2676        let _line = LineItem::Text(segment.clone());
2677        let _params = LineBreakParams {
2678            available_width: 468.0,
2679            ind_left: 0.0,
2680            ind_right: 0.0,
2681            ind_first_line: 0.0,
2682            ind_hanging: 0.0,
2683            tab_stops: Vec::new(),
2684            line_spacing: LineSpacing::Single,
2685            jc: None,
2686            wrap: true,
2687            line_prefix_widths: Vec::new(),
2688            line_suffix_widths: Vec::new(),
2689            default_tab_interval_pt: 36.0,
2690            clamp_tabs_past_margin: false,
2691        };
2692        let _shaped = crate::ShapedText {
2693            glyph_ids: segment.glyph_ids.clone(),
2694            advances: segment.advances.clone(),
2695            width: segment.width,
2696        };
2697        let _run = crate::GlyphRun {
2698            origin: crate::Point { x: 0.0, y: 0.0 },
2699            font_id: segment.font_id,
2700            font_size: segment.font_size,
2701            glyph_ids: segment.glyph_ids,
2702            advances: segment.advances,
2703            text: segment.text,
2704            source: segment.source,
2705            color: segment.color,
2706            bold: segment.bold,
2707            italic: segment.italic,
2708            field_kind: segment.field_kind,
2709            field_source: segment.field_source,
2710            note: segment.note,
2711            tab_aligned: None,
2712        };
2713    }
2714
2715    #[test]
2716    fn empty_paragraph_gets_one_line() {
2717        let fm = deterministic_font_manager();
2718        let lines = break_into_lines(&[], &LineBreakParams::default(), &fm).unwrap();
2719        assert_eq!(lines.len(), 1);
2720        assert!(lines[0].is_last);
2721        assert!(lines[0].items.is_empty());
2722    }
2723
2724    #[test]
2725    fn single_word_fits_one_line() {
2726        let fm = deterministic_font_manager();
2727        let items = vec![InlineItem::Text(make_text_segment("Hello", 50.0))];
2728        let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
2729        assert_eq!(lines.len(), 1);
2730        assert!(lines[0].is_last);
2731    }
2732
2733    #[test]
2734    fn words_wrap_to_multiple_lines() {
2735        let fm = deterministic_font_manager();
2736        // Each word is 200pt wide, line is 468pt → should wrap
2737        let mut items = vec![
2738            InlineItem::Text(make_text_segment("Word1", 200.0)),
2739            InlineItem::Text(make_text_segment("Word2", 200.0)),
2740        ];
2741        items.push(InlineItem::Text(make_text_segment("Word3", 200.0)));
2742
2743        let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
2744        assert!(lines.len() >= 2);
2745    }
2746
2747    #[test]
2748    fn ligature_runs_reshape_each_break_chunk_without_duplicate_glyphs() {
2749        let mut fm = deterministic_font_manager();
2750        let text = "by providing opportunities to crawl in cluttered spaces and handle 3-dimensional objects";
2751        let spacing = 0.4;
2752        let segment = shaped_text_segment(&mut fm, text, spacing);
2753        assert_ne!(segment.glyph_ids.len(), text.chars().count());
2754
2755        let lines = break_into_lines(
2756            &[InlineItem::Text(segment)],
2757            &LineBreakParams {
2758                available_width: 260.0,
2759                ..LineBreakParams::default()
2760            },
2761            &fm,
2762        )
2763        .expect("wrap ligature-bearing text");
2764        assert!(lines.len() > 1);
2765
2766        let mut rendered_text = String::new();
2767        for text_segment in lines.iter().flat_map(|line| {
2768            line.items.iter().filter_map(|item| match item {
2769                LineItem::Text(segment) => Some(segment),
2770                _ => None,
2771            })
2772        }) {
2773            rendered_text.push_str(&text_segment.text);
2774            let exact = fm
2775                .shape_text(
2776                    text_segment.font_id,
2777                    &text_segment.text,
2778                    text_segment.font_size,
2779                )
2780                .expect("reshape emitted chunk");
2781            assert_eq!(text_segment.glyph_ids, exact.glyph_ids);
2782            assert_eq!(text_segment.advances.len(), exact.advances.len());
2783            for (actual, unspaced) in text_segment.advances.iter().zip(exact.advances) {
2784                assert!((actual - (unspaced + spacing)).abs() < 1.0e-10);
2785            }
2786        }
2787        assert_eq!(rendered_text, text);
2788    }
2789
2790    #[test]
2791    fn line_splitting_preserves_contiguous_unicode_source_ranges() {
2792        let mut fm = deterministic_font_manager();
2793        let node = crate::SourceNodeId::new(7).expect("a non-zero source id");
2794        let mut segment = shaped_text_segment(&mut fm, "ab 🚀界 cd", 0.0);
2795        segment.source = Some(crate::SourceSpan {
2796            node,
2797            char_start: 11,
2798            char_end: 19,
2799        });
2800
2801        let lines = break_into_lines(
2802            &[InlineItem::Text(segment)],
2803            &LineBreakParams {
2804                available_width: 55.0,
2805                ..LineBreakParams::default()
2806            },
2807            &fm,
2808        )
2809        .expect("split mixed Unicode text");
2810        let sourced = lines
2811            .iter()
2812            .flat_map(|line| &line.items)
2813            .filter_map(|item| match item {
2814                LineItem::Text(segment) => segment.source,
2815                _ => None,
2816            })
2817            .collect::<Vec<_>>();
2818
2819        assert!(sourced.len() > 1, "the fixture must cross a line boundary");
2820        assert_eq!(sourced.first().expect("first range").char_start, 11);
2821        assert_eq!(sourced.last().expect("last range").char_end, 19);
2822        for pair in sourced.windows(2) {
2823            assert_eq!(pair[0].node, node);
2824            assert_eq!(pair[0].char_end, pair[1].char_start);
2825        }
2826    }
2827
2828    #[test]
2829    fn forced_line_break() {
2830        let fm = deterministic_font_manager();
2831        let items = vec![
2832            InlineItem::Text(make_text_segment("Before", 50.0)),
2833            InlineItem::LineBreak,
2834            InlineItem::Text(make_text_segment("After", 50.0)),
2835        ];
2836        let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
2837        assert!(lines.len() >= 2);
2838    }
2839
2840    #[test]
2841    fn line_page_and_column_breaks_retain_their_kind() {
2842        let fm = deterministic_font_manager();
2843        let items = vec![
2844            InlineItem::LineBreak,
2845            InlineItem::PageBreak,
2846            InlineItem::ColumnBreak,
2847            InlineItem::Text(make_text_segment("after", 30.0)),
2848        ];
2849        let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
2850
2851        assert_eq!(
2852            lines
2853                .iter()
2854                .map(|line| line.forced_break_after)
2855                .collect::<Vec<_>>(),
2856            [
2857                Some(ForcedBreakKind::Line),
2858                Some(ForcedBreakKind::Page),
2859                Some(ForcedBreakKind::Column),
2860                None,
2861            ]
2862        );
2863    }
2864
2865    #[test]
2866    fn line_height_exact() {
2867        let params = LineBreakParams {
2868            line_spacing: LineSpacing::Exact(24.0),
2869            ..Default::default()
2870        };
2871        let h = compute_line_height(10.0, 3.0, 5.0, 12.0, &params);
2872        assert!((h - 24.0).abs() < 0.01);
2873    }
2874
2875    #[test]
2876    fn line_height_auto() {
2877        let params = LineBreakParams {
2878            line_spacing: LineSpacing::Multiple(2.0),
2879            ..Default::default()
2880        };
2881        let h = compute_line_height(10.0, 3.0, 2.0, 15.0, &params);
2882        assert!((h - 30.0).abs() < 0.01); // 15 * 2.0
2883    }
2884
2885    #[test]
2886    fn first_line_indent() {
2887        let params = LineBreakParams {
2888            ind_first_line: 36.0,
2889            ..Default::default()
2890        };
2891        let first_w = compute_first_line_width(&params);
2892        let subseq_w = compute_subsequent_line_width(&params);
2893        assert!(first_w < subseq_w);
2894    }
2895
2896    #[test]
2897    fn hanging_indent() {
2898        let params = LineBreakParams {
2899            ind_left: 36.0,
2900            ind_hanging: 36.0,
2901            ..Default::default()
2902        };
2903        let first_indent = super::first_line_indent(&params);
2904        let subseq_indent = super::subsequent_line_indent(&params);
2905        assert!(first_indent < subseq_indent);
2906    }
2907
2908    fn text_item(text: &str, width: f64) -> InlineItem {
2909        InlineItem::Text(make_text_segment(text, width))
2910    }
2911
2912    /// A segment whose every character advances by `advance`.
2913    fn even_text_item(text: &str, advance: f64) -> InlineItem {
2914        let count = text.chars().count();
2915        let mut segment = make_text_segment(text, advance * count as f64);
2916        segment.glyph_ids = vec![0; count];
2917        segment.advances = vec![advance; count];
2918        InlineItem::Text(segment)
2919    }
2920
2921    fn stop(pos_pt: f64, align: TabAlign) -> TabStop {
2922        TabStop {
2923            pos_pt,
2924            align,
2925            leader: None,
2926        }
2927    }
2928
2929    fn with_stops(tab_stops: Vec<TabStop>) -> LineBreakParams {
2930        LineBreakParams {
2931            tab_stops,
2932            ..Default::default()
2933        }
2934    }
2935
2936    fn tab_widths(line: &LayoutLine) -> Vec<f64> {
2937        line.items
2938            .iter()
2939            .filter_map(|item| match item {
2940                LineItem::Tab { width, .. } => Some(*width),
2941                _ => None,
2942            })
2943            .collect()
2944    }
2945
2946    /// Tab widths on each line of `items`.
2947    fn tab_widths_by_line(items: &[InlineItem], params: &LineBreakParams) -> Vec<Vec<f64>> {
2948        break_into_lines(items, params, &deterministic_font_manager())
2949            .unwrap()
2950            .iter()
2951            .map(tab_widths)
2952            .collect()
2953    }
2954
2955    fn assert_widths(actual: &[f64], expected: &[f64]) {
2956        assert_eq!(actual.len(), expected.len(), "{actual:?} != {expected:?}");
2957        for (actual_width, expected_width) in actual.iter().zip(expected) {
2958            assert!(
2959                (actual_width - expected_width).abs() < 0.01,
2960                "{actual:?} != {expected:?}"
2961            );
2962        }
2963    }
2964
2965    #[test]
2966    fn tab_stop_resolution() {
2967        let widths = tab_widths_by_line(
2968            &[
2969                text_item("abc", 36.0),
2970                InlineItem::Tab,
2971                text_item("x", 10.0),
2972            ],
2973            &with_stops(vec![stop(72.0, TabAlign::Left)]),
2974        );
2975        assert_widths(&widths[0], &[36.0]);
2976    }
2977
2978    #[test]
2979    fn default_tab_stops() {
2980        let items = [
2981            text_item("a", 10.0),
2982            InlineItem::Tab,
2983            text_item("b", 5.0),
2984            InlineItem::Tab,
2985            text_item("c", 5.0),
2986        ];
2987        // The tab ends at the next half inch after where it starts, not
2988        // after where a half-inch placeholder would end.
2989        assert_widths(
2990            &tab_widths_by_line(&items, &LineBreakParams::default())[0],
2991            &[26.0, 31.0],
2992        );
2993
2994        // A document default tab stop moves every implicit stop with it.
2995        let params = LineBreakParams {
2996            default_tab_interval_pt: 72.0,
2997            ..Default::default()
2998        };
2999        assert_widths(&tab_widths_by_line(&items, &params)[0], &[62.0, 67.0]);
3000    }
3001
3002    #[test]
3003    fn tab_stop_with_dot_leader() {
3004        let mut fm = deterministic_font_manager();
3005        let title = shaped_text_segment(&mut fm, "Title", 0.0);
3006        let number = shaped_text_segment(&mut fm, "12", 0.0);
3007        let (title_width, number_width) = (title.width, number.width);
3008        let params = with_stops(vec![TabStop {
3009            pos_pt: 400.0,
3010            align: TabAlign::Right,
3011            leader: Some(TabLeader::Dot),
3012        }]);
3013        let lines = break_into_lines(
3014            &[
3015                InlineItem::Text(title),
3016                InlineItem::Tab,
3017                InlineItem::Text(number),
3018            ],
3019            &params,
3020            &fm,
3021        )
3022        .unwrap();
3023
3024        // The text after a right stop ends at the stop.
3025        let expected = 400.0 - title_width - number_width;
3026        let LineItem::Tab {
3027            width,
3028            leader: Some(leader),
3029            align: TabAlign::Right,
3030            ..
3031        } = &lines[0].items[1]
3032        else {
3033            panic!("a right stop with a dot leader shapes its leader");
3034        };
3035        assert!((width - expected).abs() < 0.01);
3036        assert!((leader.width - expected).abs() < 0.01);
3037        assert!(!leader.glyph_ids.is_empty());
3038        assert!(leader.text.chars().all(|ch| ch == '.'));
3039        assert!((lines[0].width - 400.0).abs() < 0.01);
3040    }
3041
3042    #[test]
3043    fn tab_stops_are_measured_from_the_zero_indent() {
3044        let params = LineBreakParams {
3045            ind_left: 36.0,
3046            tab_stops: vec![stop(150.0, TabAlign::Left)],
3047            ..Default::default()
3048        };
3049        let items = [
3050            text_item("Title", 26.5),
3051            InlineItem::Tab,
3052            text_item("12", 18.0),
3053        ];
3054        let widths = tab_widths_by_line(&items, &params);
3055        assert_widths(&widths[0], &[150.0 - 36.0 - 26.5]);
3056
3057        // Default stops too: Word puts them at multiples of the interval
3058        // from the margin, not from the indent.
3059        let params = LineBreakParams {
3060            ind_left: 20.0,
3061            ..Default::default()
3062        };
3063        let widths = tab_widths_by_line(&items, &params);
3064        assert_widths(&widths[0], &[72.0 - 20.0 - 26.5]);
3065    }
3066
3067    #[test]
3068    fn centre_and_decimal_stops_align_the_text_after_them() {
3069        let centred = tab_widths_by_line(
3070            &[
3071                text_item("T", 10.0),
3072                InlineItem::Tab,
3073                text_item("ABCDEF", 50.0),
3074            ],
3075            &with_stops(vec![stop(150.0, TabAlign::Center)]),
3076        );
3077        assert_widths(&centred[0], &[150.0 - 10.0 - 25.0]);
3078
3079        // The first full stop sits on the stop, or else the end of the
3080        // first number, as Word aligns them.
3081        for (text, before) in [
3082            ("123.45", 3),
3083            ("12345e", 5),
3084            ("-12.5", 3),
3085            ("12,5e", 4),
3086            ("Ab12cd.5", 4),
3087            (".5", 0),
3088            ("abc", 3),
3089        ] {
3090            let widths = tab_widths_by_line(
3091                &[
3092                    text_item("T", 10.0),
3093                    InlineItem::Tab,
3094                    even_text_item(text, 6.0),
3095                ],
3096                &with_stops(vec![stop(150.0, TabAlign::Decimal)]),
3097            );
3098            assert_widths(&widths[0], &[150.0 - 10.0 - 6.0 * before as f64]);
3099        }
3100
3101        // A number split across runs is still one number.
3102        let widths = tab_widths_by_line(
3103            &[
3104                text_item("T", 10.0),
3105                InlineItem::Tab,
3106                even_text_item("12", 6.0),
3107                even_text_item("34kg", 6.0),
3108            ],
3109            &with_stops(vec![stop(150.0, TabAlign::Decimal)]),
3110        );
3111        assert_widths(&widths[0], &[150.0 - 10.0 - 24.0]);
3112    }
3113
3114    #[test]
3115    fn text_too_wide_for_its_stop_starts_where_the_tab_does() {
3116        for align in [TabAlign::Right, TabAlign::Center, TabAlign::Decimal] {
3117            let widths = tab_widths_by_line(
3118                &[
3119                    text_item("Title", 26.5),
3120                    InlineItem::Tab,
3121                    text_item("wide", 136.0),
3122                ],
3123                &with_stops(vec![stop(60.0, align)]),
3124            );
3125            assert_widths(&widths[0], &[0.0]);
3126        }
3127    }
3128
3129    #[test]
3130    fn bar_stops_neither_stop_a_tab_nor_clear_default_stops() {
3131        let widths = tab_widths_by_line(
3132            &[text_item("T", 10.0), InlineItem::Tab, text_item("12", 18.0)],
3133            &with_stops(vec![stop(75.0, TabAlign::Bar)]),
3134        );
3135        assert_widths(&widths[0], &[26.0]);
3136    }
3137
3138    #[test]
3139    fn default_stops_start_after_the_last_explicit_stop() {
3140        let params = with_stops(vec![
3141            stop(20.0, TabAlign::Left),
3142            stop(150.0, TabAlign::Left),
3143        ]);
3144        let widths = tab_widths_by_line(
3145            &[
3146                text_item("T", 10.0),
3147                InlineItem::Tab,
3148                text_item("A", 10.0),
3149                InlineItem::Tab,
3150                text_item("B", 10.0),
3151                InlineItem::Tab,
3152                text_item("C", 10.0),
3153            ],
3154            &params,
3155        );
3156        // 20, then 150 skipping the default stops before it, then 180.
3157        assert_widths(&widths[0], &[10.0, 120.0, 20.0]);
3158    }
3159
3160    #[test]
3161    fn a_hanging_indent_is_a_stop_for_the_first_line() {
3162        // Left 72, hanging 72: the first line starts at 0 and the tab after
3163        // a list number goes to 72, not to the default stop at 36.
3164        let params = LineBreakParams {
3165            ind_left: 72.0,
3166            ind_hanging: 72.0,
3167            ..Default::default()
3168        };
3169        let items = [
3170            text_item("1.", 12.0),
3171            InlineItem::Tab,
3172            text_item("Item", 30.0),
3173        ];
3174        assert_widths(&tab_widths_by_line(&items, &params)[0], &[60.0]);
3175
3176        // An explicit stop before the indent still comes first.
3177        let params = LineBreakParams {
3178            tab_stops: vec![stop(36.0, TabAlign::Left)],
3179            ..params
3180        };
3181        assert_widths(&tab_widths_by_line(&items, &params)[0], &[24.0]);
3182    }
3183
3184    #[test]
3185    fn a_tab_with_no_default_stop_left_moves_to_the_next_line() {
3186        let params = LineBreakParams {
3187            available_width: 432.0,
3188            ..Default::default()
3189        };
3190        let widths = tab_widths_by_line(
3191            &[
3192                text_item("x", 423.5),
3193                InlineItem::Tab,
3194                text_item("12", 18.0),
3195            ],
3196            &params,
3197        );
3198        assert_eq!(widths.len(), 2);
3199        assert_widths(&widths[0], &[]);
3200        assert_widths(&widths[1], &[36.0]);
3201    }
3202
3203    #[test]
3204    fn stops_past_the_right_margin_follow_the_compatibility_mode() {
3205        let items = [text_item("T", 13.0), InlineItem::Tab, text_item("12", 18.0)];
3206        let params = |align, clamp, ind_right| LineBreakParams {
3207            available_width: 432.0,
3208            ind_right,
3209            tab_stops: vec![stop(500.0, align)],
3210            clamp_tabs_past_margin: clamp,
3211            ..Default::default()
3212        };
3213        // Word 2010 keeps the stop, Word 2013 ends the text at the margin,
3214        // or at the right indent when there is one.
3215        let widths = tab_widths_by_line(&items, &params(TabAlign::Right, false, 0.0));
3216        assert_widths(&widths[0], &[500.0 - 31.0]);
3217        let widths = tab_widths_by_line(&items, &params(TabAlign::Right, true, 0.0));
3218        assert_widths(&widths[0], &[432.0 - 31.0]);
3219        let widths = tab_widths_by_line(&items, &params(TabAlign::Right, true, 100.0));
3220        assert_widths(&widths[0], &[332.0 - 31.0]);
3221
3222        // A left stop: Word 2010 keeps the text after it on the line, past
3223        // the margin. Word 2013 moves the tab to a line of its own, where it
3224        // reaches the end of the line, and the text to the line after.
3225        let long = [
3226            text_item("T", 13.0),
3227            InlineItem::Tab,
3228            text_item("aa ", 20.0),
3229            text_item("bb", 20.0),
3230        ];
3231        let widths = tab_widths_by_line(&long, &params(TabAlign::Left, false, 0.0));
3232        assert_eq!(widths.len(), 1);
3233        assert_widths(&widths[0], &[487.0]);
3234        let widths = tab_widths_by_line(&long, &params(TabAlign::Left, true, 0.0));
3235        assert_eq!(widths.len(), 3);
3236        assert_widths(&widths[0], &[]);
3237        assert_widths(&widths[1], &[432.0]);
3238        assert_widths(&widths[2], &[]);
3239        // So does a left stop exactly at the end of the line.
3240        let at_end = LineBreakParams {
3241            tab_stops: vec![stop(432.0, TabAlign::Left)],
3242            ..params(TabAlign::Left, true, 0.0)
3243        };
3244        assert_eq!(tab_widths_by_line(&long, &at_end).len(), 3);
3245
3246        // The spaces that end right-aligned text that wraps run past the
3247        // stop, so the last word ends on it.
3248        let mut fm = deterministic_font_manager();
3249        let word = shaped_text_segment(&mut fm, "word ", 0.0);
3250        let bare = fm
3251            .shape_text(word.font_id, "word", word.font_size)
3252            .unwrap()
3253            .width;
3254        let mut wrapped = vec![text_item("A", 10.0), InlineItem::Tab];
3255        wrapped.extend((0..40).map(|_| InlineItem::Text(word.clone())));
3256        let lines = break_into_lines(
3257            &wrapped,
3258            &LineBreakParams {
3259                available_width: 432.0,
3260                tab_stops: vec![stop(432.0, TabAlign::Right)],
3261                clamp_tabs_past_margin: true,
3262                ..Default::default()
3263            },
3264            &fm,
3265        )
3266        .unwrap();
3267        // The final space now has its own hanging item and is not a word.
3268        let words = lines[0].items.len() - 2 - lines[0].hanging_space_counts().1;
3269        let tab = tab_widths(&lines[0])[0];
3270        let end = 10.0 + tab + (words - 1) as f64 * word.width + bare;
3271        assert!(
3272            (end - 432.0).abs() < 0.01,
3273            "{words} words, tab {tab}, {} {bare}, end {end}",
3274            word.width
3275        );
3276
3277        // A stop inside the margin but past the right indent keeps its place,
3278        // and the text after it may run to the margin.
3279        let params = LineBreakParams {
3280            available_width: 432.0,
3281            ind_right: 100.0,
3282            tab_stops: vec![stop(400.0, TabAlign::Left)],
3283            ..Default::default()
3284        };
3285        let widths = tab_widths_by_line(&items, &params);
3286        assert_eq!(widths.len(), 1);
3287        assert_widths(&widths[0], &[387.0]);
3288    }
3289
3290    #[test]
3291    fn text_after_a_centre_stop_is_pushed_back_inside_the_margin() {
3292        let items = [
3293            text_item("T", 13.0),
3294            InlineItem::Tab,
3295            text_item("CENTERED", 66.6),
3296        ];
3297        let params = |pos, clamp| LineBreakParams {
3298            available_width: 432.0,
3299            tab_stops: vec![stop(pos, TabAlign::Center)],
3300            clamp_tabs_past_margin: clamp,
3301            ..Default::default()
3302        };
3303        for clamp in [false, true] {
3304            let widths = tab_widths_by_line(&items, &params(420.0, clamp));
3305            assert_widths(&widths[0], &[432.0 - 13.0 - 66.6]);
3306        }
3307        // Word 2010 keeps a stop at the margin as it is.
3308        let widths = tab_widths_by_line(&items, &params(432.0, false));
3309        assert_widths(&widths[0], &[432.0 - 13.0 - 33.3]);
3310        let widths = tab_widths_by_line(&items, &params(432.0, true));
3311        assert_widths(&widths[0], &[432.0 - 13.0 - 66.6]);
3312    }
3313
3314    #[test]
3315    fn the_eleven_line_tests_pass_with_owned_types() {
3316        assert!(LineBreakParams::default().wrap);
3317        empty_paragraph_gets_one_line();
3318        single_word_fits_one_line();
3319        words_wrap_to_multiple_lines();
3320        forced_line_break();
3321        line_height_exact();
3322        line_height_auto();
3323        first_line_indent();
3324        hanging_indent();
3325        tab_stop_resolution();
3326        default_tab_stops();
3327        tab_stop_with_dot_leader();
3328    }
3329
3330    #[test]
3331    fn line_spacing_variants_preserve_existing_height_rules() {
3332        let height = |line_spacing| {
3333            compute_line_height(
3334                10.0,
3335                3.0,
3336                2.0,
3337                11.0,
3338                &LineBreakParams {
3339                    line_spacing,
3340                    ..Default::default()
3341                },
3342            )
3343        };
3344
3345        assert!((height(LineSpacing::Single) - 15.0).abs() < 0.01);
3346        assert!((height(LineSpacing::Multiple(1.5)) - 16.5).abs() < 0.01);
3347        assert!((height(LineSpacing::Exact(8.25)) - 8.25).abs() < 0.01);
3348        assert!((height(LineSpacing::AtLeast(8.25)) - 15.0).abs() < 0.01);
3349        assert!((height(LineSpacing::AtLeast(18.5)) - 18.5).abs() < 0.01);
3350    }
3351
3352    #[test]
3353    fn mixed_font_line_uses_tallest_full_natural_advance() {
3354        let fm = deterministic_font_manager();
3355        let mut first = make_text_segment("first", 20.0);
3356        first.ascent = 10.0;
3357        first.descent = 2.0;
3358        first.line_gap = 4.0;
3359        let mut second = make_text_segment("second", 20.0);
3360        second.ascent = 8.0;
3361        second.descent = 5.0;
3362        second.line_gap = 1.0;
3363
3364        let lines = break_into_lines(
3365            &[InlineItem::Text(first), InlineItem::Text(second)],
3366            &LineBreakParams::default(),
3367            &fm,
3368        )
3369        .expect("lay out mixed-font line");
3370
3371        assert_eq!(lines.len(), 1);
3372        assert!((lines[0].ascent - 10.0).abs() < 0.01);
3373        assert!((lines[0].descent - 5.0).abs() < 0.01);
3374        assert!((lines[0].line_gap - 1.0).abs() < 0.01);
3375        assert!((lines[0].height - 16.0).abs() < 0.01);
3376        assert!((lines[0].baseline_offset() - 10.5).abs() < 0.01);
3377    }
3378
3379    #[test]
3380    fn multiple_spacing_uses_largest_text_point_size_on_each_line() {
3381        let fm = deterministic_font_manager();
3382        let mut first = make_text_segment("first", 20.0);
3383        first.font_size = 12.0;
3384        first.line_gap = 4.0;
3385        let mut second = make_text_segment("second", 20.0);
3386        second.font_size = 20.0;
3387        second.line_gap = 1.0;
3388
3389        let lines = break_into_lines(
3390            &[InlineItem::Text(first), InlineItem::Text(second)],
3391            &LineBreakParams {
3392                line_spacing: LineSpacing::Multiple(1.25),
3393                ..LineBreakParams::default()
3394            },
3395            &fm,
3396        )
3397        .expect("lay out percentage-spaced mixed-size line");
3398
3399        assert_eq!(lines.len(), 1);
3400        assert!((lines[0].height - 25.0).abs() < 0.01);
3401    }
3402
3403    #[test]
3404    fn positive_leading_is_split_and_below_natural_exact_spacing_is_not_clamped() {
3405        let positive = LayoutLine {
3406            items: Vec::new(),
3407            width: 0.0,
3408            ascent: 10.0,
3409            descent: 3.0,
3410            line_gap: 5.0,
3411            height: 18.0,
3412            indent_left: 0.0,
3413            available_width: 100.0,
3414            is_last: true,
3415            forced_break_after: None,
3416        };
3417        let below_natural = LayoutLine {
3418            height: 8.0,
3419            ..positive.clone()
3420        };
3421
3422        assert!((positive.baseline_offset() - 12.5).abs() < 0.01);
3423        assert!((below_natural.height - 8.0).abs() < 0.01);
3424        assert!((below_natural.baseline_offset() - 10.0).abs() < 0.01);
3425    }
3426
3427    #[test]
3428    fn zero_gap_and_empty_segment_preserve_natural_height_rules() {
3429        let fm = deterministic_font_manager();
3430        let zero_gap = make_text_segment("zero", 20.0);
3431        let mut empty = make_text_segment("", 0.0);
3432        empty.line_gap = 4.0;
3433
3434        let zero_gap_line = break_into_lines(
3435            &[InlineItem::Text(zero_gap)],
3436            &LineBreakParams::default(),
3437            &fm,
3438        )
3439        .expect("lay out zero-gap line");
3440        let empty_line =
3441            break_into_lines(&[InlineItem::Text(empty)], &LineBreakParams::default(), &fm)
3442                .expect("lay out styled empty line");
3443
3444        assert!((zero_gap_line[0].height - 13.0).abs() < 0.01);
3445        assert!((empty_line[0].height - 17.0).abs() < 0.01);
3446    }
3447
3448    #[test]
3449    fn wrap_false_only_breaks_on_an_explicit_break() {
3450        let fm = deterministic_font_manager();
3451        let params = LineBreakParams {
3452            available_width: 100.0,
3453            wrap: false,
3454            ..Default::default()
3455        };
3456
3457        for forced_break in [
3458            InlineItem::LineBreak,
3459            InlineItem::PageBreak,
3460            InlineItem::ColumnBreak,
3461        ] {
3462            let items = vec![
3463                InlineItem::Text(make_text_segment("one", 80.0)),
3464                InlineItem::Text(make_text_segment("two", 80.0)),
3465                forced_break,
3466                InlineItem::Text(make_text_segment("three", 80.0)),
3467                InlineItem::Text(make_text_segment("four", 80.0)),
3468            ];
3469            let lines = break_into_lines(&items, &params, &fm).unwrap();
3470
3471            assert_eq!(lines.len(), 2);
3472            assert!((lines[0].width - 160.0).abs() < 0.01);
3473            assert!((lines[1].width - 160.0).abs() < 0.01);
3474        }
3475    }
3476
3477    #[test]
3478    fn staged_image_types_use_media_id_instead_of_embed_id() {
3479        let media_id = crate::MediaId::from_bytes(b"image");
3480        let item = inline_to_line_item(&InlineItem::Image {
3481            width: 10.0,
3482            height: 20.0,
3483            media_id,
3484        });
3485        let LineItem::Image {
3486            media_id: actual, ..
3487        } = item
3488        else {
3489            panic!("image should remain an image");
3490        };
3491        assert_eq!(actual, media_id);
3492    }
3493
3494    #[test]
3495    fn group_inline_item_breaks_and_positions_like_an_image() {
3496        use crate::{GroupElement, PositionedElement, Transform};
3497
3498        let group = GroupElement {
3499            transform: Transform::IDENTITY,
3500            clip: None,
3501            opacity: 1.0,
3502            effects: Vec::new(),
3503            children: vec![PositionedElement::FilledRect {
3504                rect: crate::Rect {
3505                    x: 2.0,
3506                    y: 3.0,
3507                    width: 4.0,
3508                    height: 5.0,
3509                },
3510                color: crate::Color::BLACK,
3511            }],
3512        };
3513        let items = vec![InlineItem::Group {
3514            width: 80.0,
3515            height: 40.0,
3516            baseline: None,
3517            group: group.clone(),
3518        }];
3519        let lines = break_into_lines(
3520            &items,
3521            &LineBreakParams {
3522                available_width: 80.0,
3523                ..Default::default()
3524            },
3525            &deterministic_font_manager(),
3526        )
3527        .expect("group line breaking");
3528
3529        assert_eq!(lines.len(), 1);
3530        assert_eq!(lines[0].width, 80.0);
3531        assert_eq!(lines[0].height, 40.0);
3532        let LineItem::Group {
3533            width,
3534            height,
3535            group: actual,
3536            ..
3537        } = &lines[0].items[0]
3538        else {
3539            panic!("inline group should remain a group line item");
3540        };
3541        assert_eq!((*width, *height), (80.0, 40.0));
3542        assert_eq!(actual, &group);
3543    }
3544
3545    #[test]
3546    fn baseline_aware_inline_groups_contribute_exact_ascent_and_descent() {
3547        use crate::{GroupElement, Transform};
3548
3549        let group = GroupElement {
3550            transform: Transform::IDENTITY,
3551            clip: None,
3552            opacity: 1.0,
3553            effects: Vec::new(),
3554            children: Vec::new(),
3555        };
3556        let lines = break_into_lines(
3557            &[
3558                InlineItem::Group {
3559                    width: 20.0,
3560                    height: 18.0,
3561                    baseline: None,
3562                    group: group.clone(),
3563                },
3564                InlineItem::Group {
3565                    width: 20.0,
3566                    height: 18.0,
3567                    baseline: Some(11.0),
3568                    group,
3569                },
3570            ],
3571            &LineBreakParams {
3572                available_width: 100.0,
3573                ..Default::default()
3574            },
3575            &deterministic_font_manager(),
3576        )
3577        .expect("group line breaking");
3578
3579        assert_eq!(lines.len(), 1);
3580        assert_eq!(lines[0].ascent, 18.0);
3581        assert_eq!(lines[0].descent, 7.0);
3582        let LineItem::Group {
3583            baseline: Some(baseline),
3584            ..
3585        } = &lines[0].items[1]
3586        else {
3587            panic!("baseline should survive line breaking");
3588        };
3589        assert_eq!(*baseline, 11.0);
3590    }
3591
3592    #[test]
3593    fn group_baselines_are_normalized_before_pagination() {
3594        use crate::{GroupElement, Transform};
3595
3596        for (height, baseline, expected) in [
3597            (18.0, Some(-4.0), Some(0.0)),
3598            (18.0, Some(30.0), Some(18.0)),
3599            (18.0, Some(f64::NAN), None),
3600            (-18.0, Some(4.0), Some(0.0)),
3601            (f64::NAN, Some(4.0), None),
3602        ] {
3603            let item = InlineItem::Group {
3604                width: 20.0,
3605                height,
3606                baseline,
3607                group: GroupElement {
3608                    transform: Transform::IDENTITY,
3609                    clip: None,
3610                    opacity: 1.0,
3611                    effects: Vec::new(),
3612                    children: Vec::new(),
3613                },
3614            };
3615            let LineItem::Group { baseline, .. } = inline_to_line_item(&item) else {
3616                panic!("inline group should remain a group line item");
3617            };
3618            assert_eq!(baseline, expected);
3619        }
3620    }
3621}