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