Skip to main content

oxml_layout/
line.rs

1//! Line breaking: converts inline items into laid-out lines.
2//!
3//! Uses a greedy algorithm with unicode-linebreak for break opportunities.
4
5use crate::error::{LayoutError, Result};
6use crate::font::{FontManager, MultilingualTextSegment, TextDirection, explicit_direction_levels};
7use crate::output::{
8    Color, FieldKind, FieldSource, FontId, GroupElement, MediaId, SourceSpan, StructureId,
9};
10
11/// A tab stop positioned in typographic points.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct TabStop {
14    pub pos_pt: f64,
15    pub align: TabAlign,
16    pub leader: Option<TabLeader>,
17}
18
19/// Paragraph alignment.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Align {
22    Start,
23    Center,
24    End,
25    Justify,
26    Distribute,
27}
28
29/// Alignment relative to a tab stop.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum TabAlign {
32    Left,
33    Center,
34    Right,
35    Decimal,
36    Bar,
37}
38
39/// Leader style used to fill a tab gap.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum TabLeader {
42    None,
43    Dot,
44    Hyphen,
45    Underscore,
46    Heavy,
47    MiddleDot,
48}
49
50/// Underline style applied to a text segment.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Underline {
53    Single,
54    Words,
55    Double,
56    Thick,
57    Dotted,
58    Dash,
59    DotDash,
60    DotDotDash,
61    Wave,
62}
63
64/// Line height rule.
65#[derive(Debug, Clone, Copy, PartialEq)]
66pub enum LineSpacing {
67    Single,
68    /// A multiple of the largest text point size on the line.
69    Multiple(f64),
70    Exact(f64),
71    AtLeast(f64),
72}
73
74/// An inline item to be placed on a line.
75#[derive(Debug, Clone)]
76#[non_exhaustive]
77pub enum InlineItem {
78    /// A shaped text segment.
79    Text(TextSegment),
80    /// A validated script, font, and bidi-level text span.
81    MultilingualText(MultilingualTextSegment),
82    /// A shaped text segment eligible for language-aware automatic hyphenation.
83    HyphenatedText {
84        segment: TextSegment,
85        language: String,
86    },
87    /// A tab character.
88    Tab,
89    /// A forced line break.
90    LineBreak,
91    /// A forced page break.
92    PageBreak,
93    /// A forced column break.
94    ColumnBreak,
95    /// An inline image.
96    Image {
97        width: f64,
98        height: f64,
99        media_id: MediaId,
100    },
101    /// A backend-neutral group with child-local coordinates.
102    Group {
103        width: f64,
104        height: f64,
105        /// Distance from the group top to its text baseline. `None` keeps the
106        /// established top-aligned drawing behavior.
107        baseline: Option<f64>,
108        group: GroupElement,
109    },
110    /// An informative drawing carried to a semantic output container.
111    Figure {
112        item: Box<InlineItem>,
113        alternate_text: String,
114        structure_id: Option<StructureId>,
115    },
116    /// A numbering marker (rendered before the first line).
117    Marker(TextSegment),
118}
119
120/// Which stream a note reference belongs to.
121///
122/// A reference carries only a number in the markup, and the two streams
123/// number independently, so a document can hold a footnote and an endnote
124/// that share a number. Without the stream the two are indistinguishable and
125/// one silently shadows the other.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
127pub enum NoteStream {
128    /// Rendered at the foot of the page carrying the reference.
129    Footnote,
130    /// Rendered at the end of the document.
131    Endnote,
132}
133
134/// A reference to one note, unique across both streams.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
136pub struct NoteRef {
137    pub stream: NoteStream,
138    pub id: i32,
139}
140
141/// A shaped text segment with associated formatting.
142#[derive(Debug, Clone)]
143pub struct TextSegment {
144    pub text: String,
145    /// Requested character direction before paragraph-wide bidi resolution.
146    pub direction: TextDirection,
147    /// Exact source range for this segment, when directly attributable.
148    pub source: Option<SourceSpan>,
149    pub font_id: FontId,
150    pub font_size: f64,
151    pub glyph_ids: Vec<u16>,
152    pub advances: Vec<f64>,
153    pub width: f64,
154    pub ascent: f64,
155    pub descent: f64,
156    /// Additional font leading included in the natural line advance.
157    pub line_gap: f64,
158    pub color: Color,
159    pub bold: bool,
160    pub italic: bool,
161    /// Underline style (None = no underline).
162    pub underline: Option<Underline>,
163    /// Single strikethrough.
164    pub strike: bool,
165    /// Double strikethrough.
166    pub dstrike: bool,
167    /// Highlight/background color for the run.
168    pub highlight: Option<Color>,
169    /// Baseline offset in points (positive = raise, negative = lower).
170    pub baseline_offset: f64,
171    /// Hyperlink URL if this segment is inside a hyperlink.
172    pub hyperlink_url: Option<String>,
173    /// If this segment is a field placeholder, the kind of field.
174    pub field_kind: Option<FieldKind>,
175    /// Source field of a page-number placeholder, when attributable.
176    pub field_source: Option<FieldSource>,
177    /// If this segment is a note reference marker, which note it points at.
178    pub note: Option<NoteRef>,
179}
180
181/// A single item positioned on a line.
182#[derive(Debug, Clone)]
183#[non_exhaustive]
184pub enum LineItem {
185    Text(TextSegment),
186    /// A validated script, font, and bidi-level text span.
187    MultilingualText(MultilingualTextSegment),
188    Tab {
189        width: f64,
190        /// Pre-shaped leader text to fill the tab gap (e.g., dots, hyphens).
191        leader: Option<TextSegment>,
192    },
193    Image {
194        width: f64,
195        height: f64,
196        media_id: MediaId,
197    },
198    Group {
199        width: f64,
200        height: f64,
201        /// Distance from the group top to its text baseline.
202        baseline: Option<f64>,
203        group: GroupElement,
204    },
205    /// An informative drawing carried to a semantic output container.
206    Figure {
207        item: Box<LineItem>,
208        alternate_text: String,
209        structure_id: Option<StructureId>,
210    },
211    Marker(TextSegment),
212}
213
214impl LineItem {
215    pub fn width(&self) -> f64 {
216        match self {
217            LineItem::Text(seg) => seg.width,
218            LineItem::MultilingualText(seg) => seg.width(),
219            LineItem::Tab { width, .. } => *width,
220            LineItem::Image { width, .. } => *width,
221            LineItem::Group { width, .. } => *width,
222            LineItem::Figure { item, .. } => item.width(),
223            LineItem::Marker(seg) => seg.width,
224        }
225    }
226}
227
228/// The kind of explicit break that ended a laid-out line.
229#[non_exhaustive]
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub enum ForcedBreakKind {
232    Line,
233    Page,
234    Column,
235}
236
237/// A laid-out line within a paragraph.
238#[derive(Debug, Clone)]
239pub struct LayoutLine {
240    pub items: Vec<LineItem>,
241    /// Total content width of the line.
242    pub width: f64,
243    /// Maximum ascent on this line (above baseline).
244    pub ascent: f64,
245    /// Maximum descent on this line (below baseline).
246    pub descent: f64,
247    /// Effective leading needed to preserve the tallest run's natural advance.
248    pub line_gap: f64,
249    /// Total line height.
250    pub height: f64,
251    /// Left indent for this line.
252    pub indent_left: f64,
253    /// Available width this line was laid out against.
254    pub available_width: f64,
255    /// Whether this is the last line of the paragraph.
256    pub is_last: bool,
257    /// The explicit break that ended this line, when present.
258    pub forced_break_after: Option<ForcedBreakKind>,
259}
260
261impl LayoutLine {
262    /// Distance from the line-box top to its text baseline.
263    pub fn baseline_offset(&self) -> f64 {
264        let leading = self.height - self.ascent - self.descent;
265        self.ascent + if leading >= 0.0 { leading / 2.0 } else { 0.0 }
266    }
267}
268
269/// Parameters for line breaking.
270#[derive(Debug, Clone)]
271pub struct LineBreakParams {
272    /// Total available width (page width minus margins).
273    pub available_width: f64,
274    /// Left indentation in points.
275    pub ind_left: f64,
276    /// Right indentation in points.
277    pub ind_right: f64,
278    /// First line indent in points (positive = indent, 0 if hanging).
279    pub ind_first_line: f64,
280    /// Hanging indent in points (positive = text lines indented relative to first).
281    pub ind_hanging: f64,
282    /// Tab stops.
283    pub tab_stops: Vec<TabStop>,
284    /// Line spacing rule and value.
285    pub line_spacing: LineSpacing,
286    /// Paragraph justification.
287    pub jc: Option<Align>,
288    /// Whether width overflow may create automatic line breaks.
289    pub wrap: bool,
290    /// Extra width kept clear at the start of individual lines, by line index.
291    ///
292    /// This is how a floating drawing pushes text aside. An empty vector, the
293    /// default, reserves nothing and reproduces unwrapped line breaking
294    /// exactly.
295    pub line_prefix_widths: Vec<f64>,
296    /// Extra width kept clear at the end of individual lines, by line index.
297    pub line_suffix_widths: Vec<f64>,
298}
299
300impl Default for LineBreakParams {
301    fn default() -> Self {
302        LineBreakParams {
303            available_width: 468.0, // US Letter with 1" margins
304            line_prefix_widths: Vec::new(),
305            line_suffix_widths: Vec::new(),
306            ind_left: 0.0,
307            ind_right: 0.0,
308            ind_first_line: 0.0,
309            ind_hanging: 0.0,
310            tab_stops: Vec::new(),
311            line_spacing: LineSpacing::Single,
312            jc: None,
313            wrap: true,
314        }
315    }
316}
317
318/// Break inline items into lines using a greedy algorithm.
319pub fn break_into_lines(
320    items: &[InlineItem],
321    params: &LineBreakParams,
322    fm: &FontManager,
323) -> Result<Vec<LayoutLine>> {
324    if items.is_empty() {
325        // Empty paragraph still gets one empty line
326        return Ok(vec![LayoutLine {
327            items: Vec::new(),
328            width: 0.0,
329            ascent: 0.0,
330            descent: 0.0,
331            line_gap: 0.0,
332            height: compute_line_height(0.0, 0.0, 0.0, 0.0, params),
333            indent_left: line_indent_at(params, 0, true),
334            available_width: line_width_at(params, 0, true),
335            is_last: true,
336            forced_break_after: None,
337        }]);
338    }
339
340    let mut lines: Vec<LayoutLine> = Vec::new();
341    let mut current_items: Vec<LineItem> = Vec::new();
342    let mut current_width: f64 = 0.0;
343    let mut current_ascent: f64 = 0.0;
344    let mut current_descent: f64 = 0.0;
345    let mut current_natural_height: f64 = 0.0;
346    let mut current_font_size: f64 = 0.0;
347    // The line index drives the per-line reservations a floating drawing
348    // creates, so it is tracked rather than a plain first-or-not flag.
349    let mut line_index = 0usize;
350    let mut is_first_line = true;
351
352    let first_line_width = line_width_at(params, 0, true);
353
354    let mut line_avail = first_line_width;
355
356    // Track the most recent font context for shaping tab leaders
357    let mut font_ctx: Option<(FontId, f64)> = None;
358    // Initialize from the first text segment if available
359    for item in items {
360        if let InlineItem::Text(seg)
361        | InlineItem::HyphenatedText { segment: seg, .. }
362        | InlineItem::Marker(seg) = item
363        {
364            font_ctx = Some((seg.font_id, seg.font_size));
365            break;
366        }
367        if let InlineItem::MultilingualText(seg) = item {
368            font_ctx = Some((seg.font_id(), seg.base().font_size));
369            break;
370        }
371    }
372
373    // Build breakable segments from inline items
374    let mut segments = std::collections::VecDeque::from(build_breakable_segments(items, fm)?);
375
376    while let Some(seg) = segments.pop_front() {
377        match seg {
378            BreakableSegment::Items(seg_items) => {
379                let seg_width: f64 = seg_items.iter().map(inline_item_width).sum();
380
381                if params.wrap
382                    && !current_items.is_empty()
383                    && current_width + seg_width > line_avail + 0.01
384                {
385                    // Finish current line
386                    let indent = line_indent_at(params, line_index, is_first_line);
387                    let line_gap =
388                        effective_line_gap(current_ascent, current_descent, current_natural_height);
389                    lines.push(LayoutLine {
390                        items: std::mem::take(&mut current_items),
391                        width: current_width,
392                        ascent: current_ascent,
393                        descent: current_descent,
394                        line_gap,
395                        height: compute_line_height(
396                            current_ascent,
397                            current_descent,
398                            line_gap,
399                            current_font_size,
400                            params,
401                        ),
402                        indent_left: indent,
403                        available_width: line_avail,
404                        is_last: false,
405                        forced_break_after: None,
406                    });
407                    current_width = 0.0;
408                    current_ascent = 0.0;
409                    current_descent = 0.0;
410                    current_natural_height = 0.0;
411                    current_font_size = 0.0;
412                    is_first_line = false;
413                    line_index += 1;
414                    line_avail = line_width_at(params, line_index, false);
415                }
416
417                // Add segment items to current line
418                for item in &seg_items {
419                    let (w, a, d, natural_height, font_size) = item_metrics(item);
420                    current_width += w;
421                    if a > current_ascent {
422                        current_ascent = a;
423                    }
424                    if d > current_descent {
425                        current_descent = d;
426                    }
427                    current_natural_height = current_natural_height.max(natural_height);
428                    current_font_size = current_font_size.max(font_size);
429                    // Update font context from text segments
430                    if let InlineItem::Text(seg) | InlineItem::Marker(seg) = item {
431                        font_ctx = Some((seg.font_id, seg.font_size));
432                    } else if let InlineItem::MultilingualText(seg) = item {
433                        font_ctx = Some((seg.font_id(), seg.base().font_size));
434                    }
435                    current_items.push(inline_to_line_item(
436                        item,
437                        current_width,
438                        &params.tab_stops,
439                        fm,
440                        font_ctx,
441                    ));
442                }
443            }
444            BreakableSegment::Hyphenated(boxed) => {
445                let HyphenatedSegment {
446                    segment,
447                    break_points,
448                } = *boxed;
449                let fits = current_width + segment.width <= line_avail + 0.01;
450                if !params.wrap || fits {
451                    let item = InlineItem::Text(segment);
452                    let (w, a, d, natural_height, font_size) = item_metrics(&item);
453                    current_width += w;
454                    current_ascent = current_ascent.max(a);
455                    current_descent = current_descent.max(d);
456                    current_natural_height = current_natural_height.max(natural_height);
457                    current_font_size = current_font_size.max(font_size);
458                    font_ctx = Some((segment_font_id(&item), segment_font_size(&item)));
459                    current_items.push(inline_to_line_item(
460                        &item,
461                        current_width,
462                        &params.tab_stops,
463                        fm,
464                        font_ctx,
465                    ));
466                    continue;
467                }
468
469                if let Some(FittingHyphenation {
470                    prefix,
471                    hyphen,
472                    remainder,
473                    remaining_points,
474                }) = fitting_hyphenation(&segment, &break_points, current_width, line_avail, fm)?
475                {
476                    for text in [prefix, hyphen] {
477                        let item = InlineItem::Text(text);
478                        let (w, a, d, natural_height, font_size) = item_metrics(&item);
479                        current_width += w;
480                        current_ascent = current_ascent.max(a);
481                        current_descent = current_descent.max(d);
482                        current_natural_height = current_natural_height.max(natural_height);
483                        current_font_size = current_font_size.max(font_size);
484                        font_ctx = Some((segment_font_id(&item), segment_font_size(&item)));
485                        current_items.push(inline_to_line_item(
486                            &item,
487                            current_width,
488                            &params.tab_stops,
489                            fm,
490                            font_ctx,
491                        ));
492                    }
493
494                    let indent = line_indent_at(params, line_index, is_first_line);
495                    let line_gap =
496                        effective_line_gap(current_ascent, current_descent, current_natural_height);
497                    lines.push(LayoutLine {
498                        items: std::mem::take(&mut current_items),
499                        width: current_width,
500                        ascent: current_ascent,
501                        descent: current_descent,
502                        line_gap,
503                        height: compute_line_height(
504                            current_ascent,
505                            current_descent,
506                            line_gap,
507                            current_font_size,
508                            params,
509                        ),
510                        indent_left: indent,
511                        available_width: line_avail,
512                        is_last: false,
513                        forced_break_after: None,
514                    });
515                    current_width = 0.0;
516                    current_ascent = 0.0;
517                    current_descent = 0.0;
518                    current_natural_height = 0.0;
519                    current_font_size = 0.0;
520                    is_first_line = false;
521                    line_index += 1;
522                    line_avail = line_width_at(params, line_index, false);
523                    segments.push_front(BreakableSegment::Hyphenated(Box::new(
524                        HyphenatedSegment {
525                            segment: remainder,
526                            break_points: remaining_points,
527                        },
528                    )));
529                } else if !current_items.is_empty() {
530                    let indent = line_indent_at(params, line_index, is_first_line);
531                    let line_gap =
532                        effective_line_gap(current_ascent, current_descent, current_natural_height);
533                    lines.push(LayoutLine {
534                        items: std::mem::take(&mut current_items),
535                        width: current_width,
536                        ascent: current_ascent,
537                        descent: current_descent,
538                        line_gap,
539                        height: compute_line_height(
540                            current_ascent,
541                            current_descent,
542                            line_gap,
543                            current_font_size,
544                            params,
545                        ),
546                        indent_left: indent,
547                        available_width: line_avail,
548                        is_last: false,
549                        forced_break_after: None,
550                    });
551                    current_width = 0.0;
552                    current_ascent = 0.0;
553                    current_descent = 0.0;
554                    current_natural_height = 0.0;
555                    current_font_size = 0.0;
556                    is_first_line = false;
557                    line_index += 1;
558                    line_avail = line_width_at(params, line_index, false);
559                    segments.push_front(BreakableSegment::Hyphenated(Box::new(
560                        HyphenatedSegment {
561                            segment,
562                            break_points,
563                        },
564                    )));
565                } else {
566                    let item = InlineItem::Text(segment);
567                    let (w, a, d, natural_height, font_size) = item_metrics(&item);
568                    current_width += w;
569                    current_ascent = current_ascent.max(a);
570                    current_descent = current_descent.max(d);
571                    current_natural_height = current_natural_height.max(natural_height);
572                    current_font_size = current_font_size.max(font_size);
573                    font_ctx = Some((segment_font_id(&item), segment_font_size(&item)));
574                    current_items.push(inline_to_line_item(
575                        &item,
576                        current_width,
577                        &params.tab_stops,
578                        fm,
579                        font_ctx,
580                    ));
581                }
582            }
583            BreakableSegment::ForcedBreak(break_kind) => {
584                let indent = line_indent_at(params, line_index, is_first_line);
585                let line_gap =
586                    effective_line_gap(current_ascent, current_descent, current_natural_height);
587                lines.push(LayoutLine {
588                    items: std::mem::take(&mut current_items),
589                    width: current_width,
590                    ascent: current_ascent,
591                    descent: current_descent,
592                    line_gap,
593                    height: compute_line_height(
594                        current_ascent,
595                        current_descent,
596                        line_gap,
597                        current_font_size,
598                        params,
599                    ),
600                    indent_left: indent,
601                    available_width: line_avail,
602                    is_last: matches!(break_kind, ForcedBreakKind::Page | ForcedBreakKind::Column),
603                    forced_break_after: Some(break_kind),
604                });
605                current_width = 0.0;
606                current_ascent = 0.0;
607                current_descent = 0.0;
608                current_natural_height = 0.0;
609                current_font_size = 0.0;
610                is_first_line = false;
611                line_index += 1;
612                line_avail = line_width_at(params, line_index, false);
613            }
614        }
615    }
616
617    // Flush remaining items as the last line
618    let indent = line_indent_at(params, line_index, is_first_line);
619    let line_gap = effective_line_gap(current_ascent, current_descent, current_natural_height);
620    lines.push(LayoutLine {
621        items: current_items,
622        width: current_width,
623        ascent: current_ascent,
624        descent: current_descent,
625        line_gap,
626        height: compute_line_height(
627            current_ascent,
628            current_descent,
629            line_gap,
630            current_font_size,
631            params,
632        ),
633        indent_left: indent,
634        available_width: line_avail,
635        is_last: true,
636        forced_break_after: None,
637    });
638
639    Ok(lines)
640}
641
642/// Break rich text in logical order, then reorder each completed line for painting.
643pub fn break_multilingual_into_lines(
644    items: &[InlineItem],
645    params: &LineBreakParams,
646    fm: &FontManager,
647    base_direction: TextDirection,
648) -> Result<Vec<LayoutLine>> {
649    let mut lines = break_into_lines(items, params, fm)?;
650    let mut paragraph_text = String::new();
651    let mut line_maps = Vec::with_capacity(lines.len());
652    let mut has_text = false;
653    for line in &lines {
654        let line_start = paragraph_text.len();
655        let mut positions = Vec::new();
656        for (index, item) in line.items.iter().enumerate() {
657            let (text, direction, shaped_level) = match item {
658                LineItem::Text(segment) | LineItem::Marker(segment) => {
659                    (segment.text.as_str(), segment.direction, None)
660                }
661                LineItem::MultilingualText(segment) => (
662                    segment.text(),
663                    segment.base().direction,
664                    Some(unicode_bidi::Level::new(segment.bidi_level()).map_err(|_| {
665                        LayoutError::Layout("rich text carried an invalid bidi level".to_owned())
666                    })?),
667                ),
668                LineItem::Tab { .. } => ("\t", TextDirection::Auto, None),
669                LineItem::Image { .. } | LineItem::Group { .. } | LineItem::Figure { .. } => {
670                    ("\u{fffc}", TextDirection::Auto, None)
671                }
672            };
673            has_text |= !text.is_empty();
674            let start = paragraph_text.len();
675            paragraph_text.push_str(text);
676            let end = paragraph_text.len();
677            positions.push((
678                index,
679                start,
680                end,
681                direction,
682                text.chars().all(char::is_whitespace),
683                shaped_level,
684            ));
685        }
686        line_maps.push((line_start..paragraph_text.len(), positions));
687    }
688    if !has_text {
689        return Ok(lines);
690    }
691    let paragraph_level = match base_direction {
692        TextDirection::Auto => None,
693        TextDirection::LeftToRight => Some(unicode_bidi::Level::ltr()),
694        TextDirection::RightToLeft => Some(unicode_bidi::Level::rtl()),
695    };
696    let bidi = unicode_bidi::BidiInfo::new(&paragraph_text, paragraph_level);
697    let [paragraph] = bidi.paragraphs.as_slice() else {
698        return Err(LayoutError::Layout(
699            "multilingual line layout requires one bidi paragraph".to_owned(),
700        ));
701    };
702    for (line, (line_range, mapped_positions)) in lines.iter_mut().zip(line_maps) {
703        let positions = mapped_positions
704            .iter()
705            .map(|(index, _, _, _, _, _)| *index)
706            .collect::<Vec<_>>();
707        if positions.is_empty() {
708            continue;
709        }
710        let adjusted_levels = bidi.reordered_levels(paragraph, line_range);
711        let levels = mapped_positions
712            .into_iter()
713            .map(|(_, start, end, direction, whitespace, shaped_level)| {
714                let adjusted = adjusted_levels.get(start).copied().ok_or_else(|| {
715                    LayoutError::Layout(
716                        "multilingual line range exceeded its bidi paragraph".to_owned(),
717                    )
718                })?;
719                Ok(if whitespace {
720                    adjusted
721                } else if let Some(shaped_level) = shaped_level {
722                    shaped_level
723                } else if direction == TextDirection::Auto {
724                    adjusted
725                } else {
726                    explicit_direction_levels(
727                        &paragraph_text[start..end],
728                        direction,
729                        paragraph.level,
730                    )?
731                    .first()
732                    .copied()
733                    .unwrap_or(adjusted)
734                })
735            })
736            .collect::<Result<Vec<_>>>()?;
737        let visual_order = unicode_bidi::BidiInfo::reorder_visual(&levels);
738        let logical_items = positions
739            .iter()
740            .zip(&levels)
741            .map(|(index, level)| match &line.items[*index] {
742                LineItem::MultilingualText(segment) => Ok(LineItem::MultilingualText(
743                    multilingual_segment_with_level(segment, *level)?,
744                )),
745                item => Ok(item.clone()),
746            })
747            .collect::<Result<Vec<_>>>()?;
748        for (visual_slot, logical_index) in positions.into_iter().zip(visual_order) {
749            line.items[visual_slot] = logical_items[logical_index].clone();
750        }
751    }
752    Ok(lines)
753}
754
755fn multilingual_segment_with_level(
756    segment: &MultilingualTextSegment,
757    level: unicode_bidi::Level,
758) -> Result<MultilingualTextSegment> {
759    let parity_changed = segment.bidi_level() % 2 != level.number() % 2;
760    let cluster_order = if parity_changed {
761        (0..segment.clusters().len()).rev().collect::<Vec<_>>()
762    } else {
763        (0..segment.clusters().len()).collect::<Vec<_>>()
764    };
765    let mut glyph_ids = Vec::with_capacity(segment.glyph_ids().len());
766    let mut x_advances = Vec::with_capacity(segment.x_advances().len());
767    let mut y_advances = Vec::with_capacity(segment.y_advances().len());
768    let mut x_offsets = Vec::with_capacity(segment.x_offsets().len());
769    let mut y_offsets = Vec::with_capacity(segment.y_offsets().len());
770    let mut clusters = Vec::with_capacity(segment.clusters().len());
771    for index in cluster_order {
772        let cluster = &segment.clusters()[index];
773        let glyph_range = cluster.glyph_start as usize..cluster.glyph_end as usize;
774        let glyph_start = glyph_ids.len() as u32;
775        glyph_ids.extend_from_slice(&segment.glyph_ids()[glyph_range.clone()]);
776        x_advances.extend_from_slice(&segment.x_advances()[glyph_range.clone()]);
777        y_advances.extend_from_slice(&segment.y_advances()[glyph_range.clone()]);
778        x_offsets.extend_from_slice(&segment.x_offsets()[glyph_range.clone()]);
779        y_offsets.extend_from_slice(&segment.y_offsets()[glyph_range]);
780        clusters.push(crate::font::GlyphCluster {
781            glyph_start,
782            glyph_end: glyph_ids.len() as u32,
783            char_start: cluster.char_start,
784            char_end: cluster.char_end,
785        });
786    }
787    let mut base = segment.base().clone();
788    base.glyph_ids = glyph_ids;
789    base.advances = x_advances.clone();
790
791    MultilingualTextSegment::new(
792        base,
793        segment.logical_index(),
794        segment.language().map(str::to_owned),
795        segment.script(),
796        if level.is_rtl() {
797            TextDirection::RightToLeft
798        } else {
799            TextDirection::LeftToRight
800        },
801        level.number(),
802        x_advances,
803        y_advances,
804        x_offsets,
805        y_offsets,
806        clusters,
807        segment.break_after(),
808    )
809}
810
811// ---- Internal helpers ----
812
813#[derive(Debug)]
814enum BreakableSegment {
815    /// A group of items that should be kept together (word or cluster).
816    Items(Vec<InlineItem>),
817    /// One language-aware text chunk and its byte-index break candidates.
818    Hyphenated(Box<HyphenatedSegment>),
819    /// A forced break.
820    ForcedBreak(ForcedBreakKind),
821}
822
823#[derive(Debug)]
824struct HyphenatedSegment {
825    segment: TextSegment,
826    break_points: Vec<usize>,
827}
828
829struct FittingHyphenation {
830    prefix: TextSegment,
831    hyphen: TextSegment,
832    remainder: TextSegment,
833    remaining_points: Vec<usize>,
834}
835
836/// Whether a complex-script line may break between two logical characters.
837pub(crate) fn multilingual_break_allowed(before: char, after: char) -> bool {
838    const OPENING: &[char] = &['(', '[', '{', '〈', '《', '「', '『', '【', '〔', '〖'];
839    const CLOSING_OR_NONSTARTER: &[char] = &[
840        ')', ']', '}', '〉', '》', '」', '』', '】', '〕', '〗', '、', '。', ',', '.', '!',
841        '?', ':', ';', '%', '‰',
842    ];
843    !OPENING.contains(&before) && !CLOSING_OR_NONSTARTER.contains(&after)
844}
845
846/// Build breakable segments by finding break opportunities in text.
847///
848/// Text items are split at unicode line-break opportunities (word boundaries,
849/// hyphens, etc.). Non-text items (tabs, images, markers) are treated as
850/// atomic units with break opportunities around them.
851fn build_breakable_segments(
852    items: &[InlineItem],
853    fm: &FontManager,
854) -> Result<Vec<BreakableSegment>> {
855    let mut segments = Vec::new();
856    let mut current_group: Vec<InlineItem> = Vec::new();
857
858    for item in items {
859        match item {
860            InlineItem::LineBreak => {
861                if !current_group.is_empty() {
862                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
863                }
864                segments.push(BreakableSegment::ForcedBreak(ForcedBreakKind::Line));
865            }
866            InlineItem::PageBreak => {
867                if !current_group.is_empty() {
868                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
869                }
870                segments.push(BreakableSegment::ForcedBreak(ForcedBreakKind::Page));
871            }
872            InlineItem::ColumnBreak => {
873                if !current_group.is_empty() {
874                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
875                }
876                segments.push(BreakableSegment::ForcedBreak(ForcedBreakKind::Column));
877            }
878            InlineItem::Tab => {
879                // Tab is a break opportunity
880                if !current_group.is_empty() {
881                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
882                }
883                segments.push(BreakableSegment::Items(vec![item.clone()]));
884            }
885            InlineItem::Text(seg) | InlineItem::HyphenatedText { segment: seg, .. } => {
886                if seg.text.is_empty() {
887                    if !current_group.is_empty() {
888                        segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
889                    }
890                    segments.push(BreakableSegment::Items(vec![item.clone()]));
891                    continue;
892                }
893                // Use unicode-linebreak to find break opportunities within text
894                let breaks = split_text_at_break_opportunities(seg);
895                let spacing =
896                    if breaks.len() == 1 && breaks[0].start == 0 && breaks[0].end == seg.text.len()
897                    {
898                        0.0
899                    } else {
900                        let original = fm.shape_text(seg.font_id, &seg.text, seg.font_size)?;
901                        if original.advances.len() == seg.advances.len()
902                            && !original.advances.is_empty()
903                        {
904                            (seg.width - original.width) / original.advances.len() as f64
905                        } else {
906                            0.0
907                        }
908                    };
909
910                for tb in &breaks {
911                    let chunk = &seg.text[tb.start..tb.end];
912                    if chunk.is_empty() {
913                        continue;
914                    }
915
916                    // If this chunk starts with whitespace, treat as a break opportunity
917                    if !current_group.is_empty() && chunk.starts_with(|c: char| c.is_whitespace()) {
918                        segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
919                    }
920
921                    // Create a sub-segment for just this chunk (not the entire text)
922                    let sub_item = split_text_subsegment(seg, tb.start, tb.end, spacing, fm)?;
923                    let language = match item {
924                        InlineItem::HyphenatedText { language, .. } => Some(language.as_str()),
925                        _ => None,
926                    };
927                    let break_points = language.map_or_else(Vec::new, |language| {
928                        hyphenation_opportunities(language, chunk)
929                    });
930                    if break_points.is_empty() {
931                        current_group.push(sub_item);
932                    } else {
933                        if !current_group.is_empty() {
934                            segments
935                                .push(BreakableSegment::Items(std::mem::take(&mut current_group)));
936                        }
937                        let InlineItem::Text(segment) = sub_item else {
938                            unreachable!("split text always returns text")
939                        };
940                        segments.push(BreakableSegment::Hyphenated(Box::new(HyphenatedSegment {
941                            segment,
942                            break_points,
943                        })));
944                    }
945
946                    if tb.is_break {
947                        segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
948                    }
949                }
950
951                // Flush any remaining
952                if !current_group.is_empty() {
953                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
954                }
955            }
956            InlineItem::MultilingualText(segment) => {
957                current_group.push(InlineItem::MultilingualText(segment.clone()));
958                if segment.break_after() {
959                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
960                }
961            }
962            InlineItem::Marker(_)
963            | InlineItem::Image { .. }
964            | InlineItem::Group { .. }
965            | InlineItem::Figure { .. } => {
966                current_group.push(item.clone());
967            }
968        }
969    }
970
971    if !current_group.is_empty() {
972        segments.push(BreakableSegment::Items(current_group));
973    }
974
975    Ok(segments)
976}
977
978/// Create a sub-segment InlineItem from a byte range within a TextSegment.
979///
980/// Reshapes the selected text and preserves formatting from the parent segment.
981fn split_text_subsegment(
982    seg: &TextSegment,
983    byte_start: usize,
984    byte_end: usize,
985    spacing: f64,
986    fm: &FontManager,
987) -> Result<InlineItem> {
988    // If this is the full segment, just clone it
989    if byte_start == 0 && byte_end == seg.text.len() {
990        return Ok(InlineItem::Text(seg.clone()));
991    }
992
993    let sub_text = seg.text[byte_start..byte_end].to_string();
994    let mut shaped = fm.shape_text(seg.font_id, &sub_text, seg.font_size)?;
995    for advance in &mut shaped.advances {
996        *advance += spacing;
997    }
998    shaped.width += spacing * shaped.advances.len() as f64;
999
1000    let source = seg.source.map(|source| {
1001        let start = seg.text[..byte_start].chars().count() as u32;
1002        let end = seg.text[..byte_end].chars().count() as u32;
1003        SourceSpan {
1004            node: source.node,
1005            char_start: source.char_start + start,
1006            char_end: source.char_start + end,
1007        }
1008    });
1009
1010    Ok(InlineItem::Text(TextSegment {
1011        text: sub_text,
1012        direction: seg.direction,
1013        source,
1014        font_id: seg.font_id,
1015        font_size: seg.font_size,
1016        glyph_ids: shaped.glyph_ids,
1017        advances: shaped.advances,
1018        width: shaped.width,
1019        ascent: seg.ascent,
1020        descent: seg.descent,
1021        line_gap: seg.line_gap,
1022        color: seg.color,
1023        bold: seg.bold,
1024        italic: seg.italic,
1025        underline: seg.underline,
1026        strike: seg.strike,
1027        dstrike: seg.dstrike,
1028        highlight: seg.highlight,
1029        baseline_offset: seg.baseline_offset,
1030        hyperlink_url: seg.hyperlink_url.clone(),
1031        field_kind: seg.field_kind,
1032        field_source: seg.field_source,
1033        note: seg.note,
1034    }))
1035}
1036
1037fn supported_hyphenation_language(language: &str) -> Option<hypher::Lang> {
1038    let primary = language.split('-').next()?;
1039    if primary.eq_ignore_ascii_case("en") {
1040        Some(hypher::Lang::English)
1041    } else if primary.eq_ignore_ascii_case("fr") {
1042        Some(hypher::Lang::French)
1043    } else if primary.eq_ignore_ascii_case("de") {
1044        Some(hypher::Lang::German)
1045    } else if primary.eq_ignore_ascii_case("es") {
1046        Some(hypher::Lang::Spanish)
1047    } else {
1048        None
1049    }
1050}
1051
1052fn hyphenation_opportunities(language: &str, text: &str) -> Vec<usize> {
1053    let Some(language) = supported_hyphenation_language(language) else {
1054        return Vec::new();
1055    };
1056    let word_end = text
1057        .char_indices()
1058        .take_while(|(_, character)| character.is_alphabetic())
1059        .map(|(offset, character)| offset + character.len_utf8())
1060        .last()
1061        .unwrap_or(0);
1062    if word_end == 0 || text[word_end..].chars().any(char::is_alphabetic) {
1063        return Vec::new();
1064    }
1065    let word = &text[..word_end];
1066    let syllables = hypher::hyphenate(word, language).collect::<Vec<_>>();
1067    let mut offset = 0usize;
1068    syllables
1069        .iter()
1070        .take(syllables.len().saturating_sub(1))
1071        .map(|syllable| {
1072            offset += syllable.len();
1073            offset
1074        })
1075        .collect()
1076}
1077
1078fn fitting_hyphenation(
1079    segment: &TextSegment,
1080    break_points: &[usize],
1081    current_width: f64,
1082    available_width: f64,
1083    fm: &FontManager,
1084) -> Result<Option<FittingHyphenation>> {
1085    let spacing = text_segment_spacing(segment, fm)?;
1086    let hyphen = generated_hyphen(segment, spacing, fm)?;
1087    for &break_point in break_points.iter().rev() {
1088        let InlineItem::Text(prefix) = split_text_subsegment(segment, 0, break_point, spacing, fm)?
1089        else {
1090            unreachable!("split text always returns text")
1091        };
1092        if current_width + prefix.width + hyphen.width > available_width + 0.01 {
1093            continue;
1094        }
1095        let InlineItem::Text(remainder) =
1096            split_text_subsegment(segment, break_point, segment.text.len(), spacing, fm)?
1097        else {
1098            unreachable!("split text always returns text")
1099        };
1100        let remaining_points = break_points
1101            .iter()
1102            .copied()
1103            .filter(|point| *point > break_point)
1104            .map(|point| point - break_point)
1105            .collect();
1106        return Ok(Some(FittingHyphenation {
1107            prefix,
1108            hyphen,
1109            remainder,
1110            remaining_points,
1111        }));
1112    }
1113    Ok(None)
1114}
1115
1116fn text_segment_spacing(segment: &TextSegment, fm: &FontManager) -> Result<f64> {
1117    let original = fm.shape_text(segment.font_id, &segment.text, segment.font_size)?;
1118    Ok(
1119        if original.advances.len() == segment.advances.len() && !original.advances.is_empty() {
1120            (segment.width - original.width) / original.advances.len() as f64
1121        } else {
1122            0.0
1123        },
1124    )
1125}
1126
1127fn generated_hyphen(segment: &TextSegment, spacing: f64, fm: &FontManager) -> Result<TextSegment> {
1128    let mut shaped = fm.shape_text(segment.font_id, "-", segment.font_size)?;
1129    for advance in &mut shaped.advances {
1130        *advance += spacing;
1131    }
1132    shaped.width += spacing * shaped.advances.len() as f64;
1133    Ok(TextSegment {
1134        text: "-".to_owned(),
1135        direction: segment.direction,
1136        source: None,
1137        font_id: segment.font_id,
1138        font_size: segment.font_size,
1139        glyph_ids: shaped.glyph_ids,
1140        advances: shaped.advances,
1141        width: shaped.width,
1142        ascent: segment.ascent,
1143        descent: segment.descent,
1144        line_gap: segment.line_gap,
1145        color: segment.color,
1146        bold: segment.bold,
1147        italic: segment.italic,
1148        underline: segment.underline,
1149        strike: segment.strike,
1150        dstrike: segment.dstrike,
1151        highlight: segment.highlight,
1152        baseline_offset: segment.baseline_offset,
1153        hyperlink_url: segment.hyperlink_url.clone(),
1154        field_kind: segment.field_kind,
1155        field_source: segment.field_source,
1156        note: segment.note,
1157    })
1158}
1159
1160fn segment_font_id(item: &InlineItem) -> FontId {
1161    match item {
1162        InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. } => segment.font_id,
1163        _ => unreachable!("called only for text"),
1164    }
1165}
1166
1167fn segment_font_size(item: &InlineItem) -> f64 {
1168    match item {
1169        InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. } => segment.font_size,
1170        _ => unreachable!("called only for text"),
1171    }
1172}
1173
1174struct TextBreakInfo {
1175    /// Byte range within the original text.
1176    start: usize,
1177    end: usize,
1178    /// Whether a line break is allowed after this segment.
1179    is_break: bool,
1180}
1181
1182fn split_text_at_break_opportunities(seg: &TextSegment) -> Vec<TextBreakInfo> {
1183    use unicode_linebreak::{BreakOpportunity, linebreaks};
1184
1185    let text = &seg.text;
1186    if text.is_empty() {
1187        return vec![];
1188    }
1189
1190    let mut breaks = Vec::new();
1191    let mut last_start = 0;
1192
1193    for (byte_pos, opportunity) in linebreaks(text) {
1194        if byte_pos == 0 {
1195            continue;
1196        }
1197
1198        let is_break = matches!(
1199            opportunity,
1200            BreakOpportunity::Allowed | BreakOpportunity::Mandatory
1201        );
1202
1203        breaks.push(TextBreakInfo {
1204            start: last_start,
1205            end: byte_pos,
1206            is_break,
1207        });
1208        last_start = byte_pos;
1209    }
1210
1211    // If unicode-linebreak didn't produce any breaks, treat as one chunk
1212    if breaks.is_empty() {
1213        breaks.push(TextBreakInfo {
1214            start: 0,
1215            end: text.len(),
1216            is_break: true,
1217        });
1218    }
1219
1220    breaks
1221}
1222
1223fn inline_item_width(item: &InlineItem) -> f64 {
1224    match item {
1225        InlineItem::Text(seg) | InlineItem::HyphenatedText { segment: seg, .. } => seg.width,
1226        InlineItem::MultilingualText(seg) => seg.width(),
1227        InlineItem::Tab => 36.0, // Default tab width, will be resolved
1228        InlineItem::Image { width, .. } => *width,
1229        InlineItem::Group { width, .. } => *width,
1230        InlineItem::Figure { item, .. } => inline_item_width(item),
1231        InlineItem::Marker(seg) => seg.width,
1232        InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => 0.0,
1233    }
1234}
1235
1236fn item_metrics(item: &InlineItem) -> (f64, f64, f64, f64, f64) {
1237    // Returns (width, ascent, descent, natural height, text font size)
1238    match item {
1239        InlineItem::Text(seg) | InlineItem::HyphenatedText { segment: seg, .. } => (
1240            seg.width,
1241            seg.ascent,
1242            seg.descent,
1243            seg.ascent + seg.descent + seg.line_gap,
1244            seg.font_size,
1245        ),
1246        InlineItem::MultilingualText(seg) => (
1247            seg.width(),
1248            seg.base().ascent,
1249            seg.base().descent,
1250            seg.base().ascent + seg.base().descent + seg.base().line_gap,
1251            seg.base().font_size,
1252        ),
1253        InlineItem::Marker(seg) => (
1254            seg.width,
1255            seg.ascent,
1256            seg.descent,
1257            seg.ascent + seg.descent + seg.line_gap,
1258            0.0,
1259        ),
1260        InlineItem::Tab => (36.0, 0.0, 0.0, 0.0, 0.0),
1261        InlineItem::Image { width, height, .. } => (*width, *height, 0.0, *height, 0.0),
1262        InlineItem::Group {
1263            width,
1264            height,
1265            baseline,
1266            ..
1267        } => {
1268            let baseline = normalized_group_baseline(*height, *baseline).unwrap_or(*height);
1269            (*width, baseline, *height - baseline, *height, 0.0)
1270        }
1271        InlineItem::Figure { item, .. } => item_metrics(item),
1272        InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => {
1273            (0.0, 0.0, 0.0, 0.0, 0.0)
1274        }
1275    }
1276}
1277
1278fn normalized_group_baseline(height: f64, baseline: Option<f64>) -> Option<f64> {
1279    if !height.is_finite() {
1280        return None;
1281    }
1282    let upper = height.max(0.0);
1283    baseline
1284        .filter(|value| value.is_finite())
1285        .map(|value| value.clamp(0.0, upper))
1286}
1287
1288fn inline_to_line_item(
1289    item: &InlineItem,
1290    current_x: f64,
1291    tab_stops: &[TabStop],
1292    fm: &FontManager,
1293    font_ctx: Option<(FontId, f64)>,
1294) -> LineItem {
1295    match item {
1296        InlineItem::Text(seg) | InlineItem::HyphenatedText { segment: seg, .. } => {
1297            LineItem::Text(seg.clone())
1298        }
1299        InlineItem::MultilingualText(seg) => LineItem::MultilingualText(seg.clone()),
1300        InlineItem::Marker(seg) => LineItem::Marker(seg.clone()),
1301        InlineItem::Tab => {
1302            let (tab_width, leader_char) = resolve_tab_width(current_x, tab_stops);
1303            let leader = leader_char.and_then(|ch| shape_leader(fm, font_ctx, ch, tab_width));
1304            LineItem::Tab {
1305                width: tab_width,
1306                leader,
1307            }
1308        }
1309        InlineItem::Image {
1310            width,
1311            height,
1312            media_id,
1313        } => LineItem::Image {
1314            width: *width,
1315            height: *height,
1316            media_id: *media_id,
1317        },
1318        InlineItem::Group {
1319            width,
1320            height,
1321            baseline,
1322            group,
1323        } => LineItem::Group {
1324            width: *width,
1325            height: *height,
1326            baseline: normalized_group_baseline(*height, *baseline),
1327            group: group.clone(),
1328        },
1329        InlineItem::Figure {
1330            item,
1331            alternate_text,
1332            structure_id,
1333        } => LineItem::Figure {
1334            item: Box::new(inline_to_line_item(
1335                item, current_x, tab_stops, fm, font_ctx,
1336            )),
1337            alternate_text: alternate_text.clone(),
1338            structure_id: *structure_id,
1339        },
1340        InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => LineItem::Tab {
1341            width: 0.0,
1342            leader: None,
1343        },
1344    }
1345}
1346
1347/// Shape a leader character repeated to fill the given width.
1348fn shape_leader(
1349    fm: &FontManager,
1350    font_ctx: Option<(FontId, f64)>,
1351    leader_char: char,
1352    tab_width: f64,
1353) -> Option<TextSegment> {
1354    let (font_id, font_size) = font_ctx?;
1355    if tab_width < 1.0 {
1356        return None;
1357    }
1358
1359    // Shape a single leader character to get its advance width
1360    let single = String::from(leader_char);
1361    let shaped = fm.shape_text(font_id, &single, font_size).ok()?;
1362    if shaped.glyph_ids.is_empty() {
1363        return None;
1364    }
1365    let char_advance = shaped.advances[0];
1366    if char_advance < 0.5 {
1367        return None;
1368    }
1369
1370    // Add a small gap between leader chars (about 50% of char width for dots, less for others)
1371    let spacing = match leader_char {
1372        '.' | '\u{00B7}' => char_advance * 0.5,
1373        _ => char_advance * 0.15,
1374    };
1375    let step = char_advance + spacing;
1376    let count = ((tab_width - spacing) / step).floor() as usize;
1377    if count == 0 {
1378        return None;
1379    }
1380
1381    // Build the repeated leader text and glyph arrays
1382    let leader_text: String = std::iter::repeat_n(leader_char, count).collect();
1383    let mut glyph_ids = Vec::with_capacity(count);
1384    let mut advances = Vec::with_capacity(count);
1385    for i in 0..count {
1386        glyph_ids.push(shaped.glyph_ids[0]);
1387        if i + 1 < count {
1388            advances.push(char_advance + spacing);
1389        } else {
1390            advances.push(char_advance);
1391        }
1392    }
1393
1394    let metrics = fm.metrics(font_id, font_size).ok()?;
1395
1396    Some(TextSegment {
1397        text: leader_text,
1398        direction: TextDirection::Auto,
1399        source: None,
1400        font_id,
1401        font_size,
1402        glyph_ids,
1403        advances,
1404        width: tab_width, // fill the entire tab gap
1405        ascent: metrics.ascent,
1406        descent: metrics.descent,
1407        line_gap: metrics.line_gap,
1408        color: Color::BLACK,
1409        bold: false,
1410        italic: false,
1411        underline: None,
1412        strike: false,
1413        dstrike: false,
1414        highlight: None,
1415        baseline_offset: 0.0,
1416        hyperlink_url: None,
1417        field_kind: None,
1418        field_source: None,
1419        note: None,
1420    })
1421}
1422
1423/// Resolve tab stop width and leader character based on current x position and defined stops.
1424fn resolve_tab_width(current_x: f64, tab_stops: &[TabStop]) -> (f64, Option<char>) {
1425    // Find the next tab stop after the current position
1426    for stop in tab_stops {
1427        let stop_pos = stop.pos_pt;
1428        if stop_pos > current_x {
1429            let width = match stop.align {
1430                TabAlign::Left => stop_pos - current_x,
1431                TabAlign::Center => (stop_pos - current_x).max(0.0),
1432                TabAlign::Right => (stop_pos - current_x).max(0.0),
1433                _ => stop_pos - current_x,
1434            };
1435            let leader = stop.leader.and_then(|l| match l {
1436                TabLeader::Dot => Some('.'),
1437                TabLeader::Hyphen => Some('-'),
1438                TabLeader::Underscore => Some('_'),
1439                TabLeader::MiddleDot => Some('\u{00B7}'),
1440                TabLeader::Heavy => Some('_'),
1441                TabLeader::None => None,
1442            });
1443            return (width, leader);
1444        }
1445    }
1446    // Default tab stops every 0.5 inches (36pt)
1447    let default_interval = 36.0;
1448    let next_stop = ((current_x / default_interval).floor() + 1.0) * default_interval;
1449    (next_stop - current_x, None)
1450}
1451
1452fn compute_first_line_width(params: &LineBreakParams) -> f64 {
1453    if params.ind_hanging > 0.0 {
1454        // Hanging indent: first line has MORE width (extends left)
1455        params.available_width - params.ind_left - params.ind_right + params.ind_hanging
1456    } else {
1457        params.available_width - params.ind_left - params.ind_right - params.ind_first_line
1458    }
1459}
1460
1461fn compute_subsequent_line_width(params: &LineBreakParams) -> f64 {
1462    params.available_width - params.ind_left - params.ind_right
1463}
1464
1465/// Width kept clear at the start of a given line.
1466fn line_prefix_width(params: &LineBreakParams, line_index: usize) -> f64 {
1467    params
1468        .line_prefix_widths
1469        .get(line_index)
1470        .copied()
1471        .unwrap_or(0.0)
1472}
1473
1474/// Width kept clear at the end of a given line.
1475fn line_suffix_width(params: &LineBreakParams, line_index: usize) -> f64 {
1476    params
1477        .line_suffix_widths
1478        .get(line_index)
1479        .copied()
1480        .unwrap_or(0.0)
1481}
1482
1483/// Usable width of a line, once anything floating beside it is taken out.
1484fn line_width_at(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 {
1485    let base = if is_first_line {
1486        compute_first_line_width(params)
1487    } else {
1488        compute_subsequent_line_width(params)
1489    };
1490    (base - line_prefix_width(params, line_index) - line_suffix_width(params, line_index)).max(0.0)
1491}
1492
1493/// Where a line starts, once anything floating to its left is taken out.
1494fn line_indent_at(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 {
1495    let base = if is_first_line {
1496        first_line_indent(params)
1497    } else {
1498        subsequent_line_indent(params)
1499    };
1500    base + line_prefix_width(params, line_index)
1501}
1502
1503fn first_line_indent(params: &LineBreakParams) -> f64 {
1504    if params.ind_hanging > 0.0 {
1505        params.ind_left - params.ind_hanging
1506    } else {
1507        params.ind_left + params.ind_first_line
1508    }
1509}
1510
1511fn subsequent_line_indent(params: &LineBreakParams) -> f64 {
1512    params.ind_left
1513}
1514
1515/// Compute line height based on spacing rules.
1516fn effective_line_gap(ascent: f64, descent: f64, natural_height: f64) -> f64 {
1517    (natural_height - ascent - descent).max(0.0)
1518}
1519
1520fn compute_line_height(
1521    ascent: f64,
1522    descent: f64,
1523    line_gap: f64,
1524    font_size: f64,
1525    params: &LineBreakParams,
1526) -> f64 {
1527    let natural = ascent + descent + line_gap;
1528    let natural = if natural < 1.0 { 12.0 } else { natural }; // minimum for empty lines
1529    let font_size = if font_size < 1.0 { 12.0 } else { font_size };
1530
1531    match params.line_spacing {
1532        LineSpacing::Single => natural,
1533        LineSpacing::Multiple(factor) => font_size * factor,
1534        LineSpacing::Exact(points) => points,
1535        LineSpacing::AtLeast(points) => natural.max(points),
1536    }
1537}
1538
1539#[cfg(test)]
1540mod tests {
1541    use super::*;
1542
1543    fn make_text_segment(text: &str, width: f64) -> TextSegment {
1544        TextSegment {
1545            text: text.to_string(),
1546            direction: TextDirection::Auto,
1547            source: None,
1548            font_id: FontId(0),
1549            font_size: 12.0,
1550            glyph_ids: vec![],
1551            advances: vec![],
1552            width,
1553            ascent: 10.0,
1554            descent: 3.0,
1555            line_gap: 0.0,
1556            color: Color::BLACK,
1557            bold: false,
1558            italic: false,
1559            underline: None,
1560            strike: false,
1561            dstrike: false,
1562            highlight: None,
1563            baseline_offset: 0.0,
1564            hyperlink_url: None,
1565            field_kind: None,
1566            field_source: None,
1567            note: None,
1568        }
1569    }
1570
1571    fn deterministic_font_manager() -> FontManager {
1572        FontManager::new_deterministic().expect("bundled fonts should load")
1573    }
1574
1575    fn shaped_text_segment(fm: &mut FontManager, text: &str, spacing: f64) -> TextSegment {
1576        let font_id = fm
1577            .resolve_font(Some("Carlito"), false, false)
1578            .expect("bundled Carlito should resolve");
1579        let metrics = fm.metrics(font_id, 30.0).expect("Carlito metrics");
1580        let mut shaped = fm.shape_text(font_id, text, 30.0).expect("shape text");
1581        for advance in &mut shaped.advances {
1582            *advance += spacing;
1583        }
1584        shaped.width += spacing * shaped.advances.len() as f64;
1585        TextSegment {
1586            text: text.to_owned(),
1587            direction: TextDirection::Auto,
1588            source: None,
1589            font_id,
1590            font_size: 30.0,
1591            glyph_ids: shaped.glyph_ids,
1592            advances: shaped.advances,
1593            width: shaped.width,
1594            ascent: metrics.ascent,
1595            descent: metrics.descent,
1596            line_gap: metrics.line_gap,
1597            color: Color::BLACK,
1598            bold: false,
1599            italic: false,
1600            underline: None,
1601            strike: false,
1602            dstrike: false,
1603            highlight: None,
1604            baseline_offset: 0.0,
1605            hyperlink_url: None,
1606            field_kind: None,
1607            field_source: None,
1608            note: None,
1609        }
1610    }
1611
1612    #[test]
1613    fn automatic_hyphenation_selects_the_farthest_fitting_break_and_has_no_source() {
1614        let mut fm = deterministic_font_manager();
1615        let node = crate::SourceNodeId::new(9).unwrap();
1616        let mut segment = shaped_text_segment(&mut fm, "representation", 0.0);
1617        segment.source = Some(SourceSpan {
1618            node,
1619            char_start: 20,
1620            char_end: 34,
1621        });
1622        let width = fm
1623            .shape_text(segment.font_id, "represen-", segment.font_size)
1624            .unwrap()
1625            .width
1626            + 0.01;
1627        let lines = break_into_lines(
1628            &[InlineItem::HyphenatedText {
1629                segment,
1630                language: "en-US".to_owned(),
1631            }],
1632            &LineBreakParams {
1633                available_width: width,
1634                ..Default::default()
1635            },
1636            &fm,
1637        )
1638        .unwrap();
1639        let first = lines[0]
1640            .items
1641            .iter()
1642            .filter_map(|item| match item {
1643                LineItem::Text(text) => Some(text),
1644                _ => None,
1645            })
1646            .collect::<Vec<_>>();
1647
1648        assert_eq!(
1649            first
1650                .iter()
1651                .map(|text| text.text.as_str())
1652                .collect::<String>(),
1653            "represen-"
1654        );
1655        assert_eq!(first.last().unwrap().source, None);
1656        assert_eq!(first[0].source.unwrap().char_start, 20);
1657        assert_eq!(first[0].source.unwrap().char_end, 28);
1658    }
1659
1660    #[test]
1661    fn liang_candidates_map_supported_regional_languages_only() {
1662        assert_eq!(
1663            hyphenation_opportunities("en-US", "representation"),
1664            vec![3, 5, 8, 10]
1665        );
1666        assert_eq!(
1667            hyphenation_opportunities("fr-CA", "représentation"),
1668            vec![2, 6, 9, 11]
1669        );
1670        assert!(!hyphenation_opportunities("de-AT", "Silbentrennung").is_empty());
1671        assert!(!hyphenation_opportunities("es-MX", "representación").is_empty());
1672        assert!(hyphenation_opportunities("it-IT", "rappresentazione").is_empty());
1673    }
1674
1675    #[test]
1676    fn unwrapped_hyphenated_text_never_emits_a_conditional_hyphen() {
1677        let mut fm = deterministic_font_manager();
1678        let segment = shaped_text_segment(&mut fm, "representation", 0.0);
1679        let lines = break_into_lines(
1680            &[InlineItem::HyphenatedText {
1681                segment,
1682                language: "en-US".to_owned(),
1683            }],
1684            &LineBreakParams {
1685                available_width: 20.0,
1686                wrap: false,
1687                ..Default::default()
1688            },
1689            &fm,
1690        )
1691        .unwrap();
1692        assert_eq!(lines.len(), 1);
1693        let text = lines[0]
1694            .items
1695            .iter()
1696            .filter_map(|item| match item {
1697                LineItem::Text(text) => Some(text.text.as_str()),
1698                _ => None,
1699            })
1700            .collect::<String>();
1701
1702        assert_eq!(text, "representation");
1703    }
1704
1705    #[test]
1706    fn mixed_direction_line_uses_uax9_visual_order_without_changing_logical_text() {
1707        let mut fm = deterministic_font_manager();
1708        let mut segment = shaped_text_segment(&mut fm, "abc אבג 123", 0.0);
1709        segment.source = Some(SourceSpan {
1710            node: crate::SourceNodeId::new(7).unwrap(),
1711            char_start: 50,
1712            char_end: 61,
1713        });
1714        let rich = fm
1715            .shape_multilingual_text(segment, Some("he-IL"), TextDirection::Auto, false)
1716            .unwrap();
1717        let logical_text = rich.iter().map(|span| span.text()).collect::<String>();
1718        let sources = rich
1719            .iter()
1720            .map(|span| span.base().source.expect("logical span keeps source"))
1721            .collect::<Vec<_>>();
1722        assert_eq!(sources.first().unwrap().char_start, 50);
1723        assert_eq!(sources.last().unwrap().char_end, 61);
1724        assert!(
1725            sources
1726                .windows(2)
1727                .all(|pair| pair[0].char_end == pair[1].char_start)
1728        );
1729        let items = rich
1730            .into_iter()
1731            .map(InlineItem::MultilingualText)
1732            .collect::<Vec<_>>();
1733        let lines = break_multilingual_into_lines(
1734            &items,
1735            &LineBreakParams {
1736                available_width: 1_000.0,
1737                ..Default::default()
1738            },
1739            &fm,
1740            TextDirection::Auto,
1741        )
1742        .unwrap();
1743        let visual_text = lines[0]
1744            .items
1745            .iter()
1746            .filter_map(|item| match item {
1747                LineItem::MultilingualText(span) => Some(span.text()),
1748                _ => None,
1749            })
1750            .collect::<String>();
1751        assert_eq!(logical_text, "abc אבג 123");
1752        assert_eq!(visual_text, "abc 123 אבג");
1753        assert_eq!(
1754            lines[0]
1755                .items
1756                .iter()
1757                .filter_map(|item| match item {
1758                    LineItem::MultilingualText(span) => {
1759                        Some((span.text(), span.logical_index(), span.bidi_level()))
1760                    }
1761                    _ => None,
1762                })
1763                .collect::<Vec<_>>(),
1764            vec![
1765                ("abc", 0, 0),
1766                (" ", 1, 0),
1767                ("123", 4, 2),
1768                (" ", 3, 1),
1769                ("אבג", 2, 1)
1770            ]
1771        );
1772    }
1773
1774    #[test]
1775    fn explicit_run_directions_reorder_with_the_rtl_paragraph_once() {
1776        let mut fm = deterministic_font_manager();
1777        let mut arabic = shaped_text_segment(&mut fm, "العربية ", 0.0);
1778        arabic.direction = TextDirection::RightToLeft;
1779        let mut latin = shaped_text_segment(&mut fm, "ABC 123", 0.0);
1780        latin.direction = TextDirection::LeftToRight;
1781        let rich = fm
1782            .shape_multilingual_paragraph(
1783                vec![
1784                    (arabic, Some("ar-SA".to_owned())),
1785                    (latin, Some("en-US".to_owned())),
1786                ],
1787                TextDirection::RightToLeft,
1788                false,
1789            )
1790            .unwrap();
1791        let lines = break_multilingual_into_lines(
1792            &rich
1793                .into_iter()
1794                .map(InlineItem::MultilingualText)
1795                .collect::<Vec<_>>(),
1796            &LineBreakParams {
1797                available_width: 1_000.0,
1798                ..Default::default()
1799            },
1800            &fm,
1801            TextDirection::RightToLeft,
1802        )
1803        .unwrap();
1804
1805        let visual = lines[0]
1806            .items
1807            .iter()
1808            .filter_map(|item| match item {
1809                LineItem::MultilingualText(span) => {
1810                    Some((span.text(), span.bidi_level(), span.base().direction))
1811                }
1812                _ => None,
1813            })
1814            .collect::<Vec<_>>();
1815        assert_eq!(
1816            visual.iter().map(|span| span.0).collect::<String>(),
1817            "ABC 123 العربية",
1818            "{visual:?}"
1819        );
1820    }
1821
1822    #[test]
1823    fn explicit_rtl_run_retains_numeric_levels_and_line_local_whitespace_reset() {
1824        let mut fm = deterministic_font_manager();
1825        let node = crate::SourceNodeId::new(13).unwrap();
1826        let mut segment = shaped_text_segment(&mut fm, "אבג 123   ", 0.0);
1827        segment.direction = TextDirection::RightToLeft;
1828        segment.source = Some(SourceSpan {
1829            node,
1830            char_start: 40,
1831            char_end: 50,
1832        });
1833        let rich = fm
1834            .shape_multilingual_paragraph(
1835                vec![(segment, Some("he-IL".to_owned()))],
1836                TextDirection::LeftToRight,
1837                false,
1838            )
1839            .unwrap();
1840        assert!(
1841            rich.iter()
1842                .any(|span| span.text() == "123" && span.bidi_level() == 2),
1843            "numeric span must retain its higher even level: {:?}",
1844            rich.iter()
1845                .map(|span| (span.text(), span.bidi_level()))
1846                .collect::<Vec<_>>()
1847        );
1848
1849        let lines = break_multilingual_into_lines(
1850            &rich
1851                .into_iter()
1852                .map(InlineItem::MultilingualText)
1853                .collect::<Vec<_>>(),
1854            &LineBreakParams {
1855                available_width: 1_000.0,
1856                ..Default::default()
1857            },
1858            &fm,
1859            TextDirection::LeftToRight,
1860        )
1861        .unwrap();
1862        let spans = lines[0]
1863            .items
1864            .iter()
1865            .filter_map(|item| match item {
1866                LineItem::MultilingualText(span) => Some(span),
1867                _ => None,
1868            })
1869            .collect::<Vec<_>>();
1870        assert!(spans.iter().any(|span| {
1871            span.text().chars().all(char::is_whitespace) && span.bidi_level() == 0
1872        }));
1873        assert!(
1874            spans
1875                .iter()
1876                .all(|span| span.base().source.unwrap().node == node)
1877        );
1878    }
1879
1880    #[test]
1881    fn inline_object_participates_in_rtl_visual_order_without_changing_text_source() {
1882        let mut fm = deterministic_font_manager();
1883        let node = crate::SourceNodeId::new(14).unwrap();
1884        let mut segment = shaped_text_segment(&mut fm, "אבג", 0.0);
1885        segment.source = Some(SourceSpan {
1886            node,
1887            char_start: 8,
1888            char_end: 11,
1889        });
1890        let mut rich = fm
1891            .shape_multilingual_text(segment, Some("he-IL"), TextDirection::RightToLeft, false)
1892            .unwrap();
1893        assert_eq!(rich.len(), 1);
1894        let items = vec![
1895            InlineItem::MultilingualText(rich.remove(0)),
1896            InlineItem::Image {
1897                width: 12.0,
1898                height: 12.0,
1899                media_id: MediaId(77),
1900            },
1901        ];
1902
1903        let lines = break_multilingual_into_lines(
1904            &items,
1905            &LineBreakParams {
1906                available_width: 1_000.0,
1907                ..Default::default()
1908            },
1909            &fm,
1910            TextDirection::RightToLeft,
1911        )
1912        .unwrap();
1913
1914        assert!(matches!(
1915            lines[0].items[0],
1916            LineItem::Image {
1917                media_id: MediaId(77),
1918                ..
1919            }
1920        ));
1921        let LineItem::MultilingualText(text) = &lines[0].items[1] else {
1922            panic!("RTL text must paint after the inline object")
1923        };
1924        assert_eq!(text.text(), "אבג");
1925        assert_eq!(
1926            text.base().source.unwrap(),
1927            SourceSpan {
1928                node,
1929                char_start: 8,
1930                char_end: 11,
1931            }
1932        );
1933    }
1934
1935    #[test]
1936    fn explicit_rtl_hyphenatable_latin_spans_keep_their_natural_even_levels() {
1937        let mut fm = deterministic_font_manager();
1938        let mut letters = shaped_text_segment(&mut fm, "ABC ", 0.0);
1939        letters.direction = TextDirection::RightToLeft;
1940        let mut digits = shaped_text_segment(&mut fm, "123", 0.0);
1941        digits.direction = TextDirection::RightToLeft;
1942        let lines = break_multilingual_into_lines(
1943            &[
1944                InlineItem::HyphenatedText {
1945                    segment: letters,
1946                    language: "en-US".to_owned(),
1947                },
1948                InlineItem::HyphenatedText {
1949                    segment: digits,
1950                    language: "en-US".to_owned(),
1951                },
1952            ],
1953            &LineBreakParams {
1954                available_width: 1_000.0,
1955                ..Default::default()
1956            },
1957            &fm,
1958            TextDirection::LeftToRight,
1959        )
1960        .unwrap();
1961        let visual = lines[0]
1962            .items
1963            .iter()
1964            .filter_map(|item| match item {
1965                LineItem::Text(segment) => Some(segment.text.as_str()),
1966                _ => None,
1967            })
1968            .collect::<String>();
1969        assert_eq!(visual, "ABC 123");
1970    }
1971
1972    #[test]
1973    fn one_styled_run_applies_line_local_l1_before_l2_without_losing_source() {
1974        let mut fm = deterministic_font_manager();
1975        let node = crate::SourceNodeId::new(9).unwrap();
1976        let mut segment = shaped_text_segment(&mut fm, "אבג   אבג", 0.0);
1977        segment.source = Some(SourceSpan {
1978            node,
1979            char_start: 20,
1980            char_end: 29,
1981        });
1982        let rich = fm
1983            .shape_multilingual_paragraph(vec![(segment, None)], TextDirection::LeftToRight, false)
1984            .unwrap();
1985        assert_eq!(
1986            rich.iter().map(|span| span.text()).collect::<Vec<_>>(),
1987            ["אבג", "   ", "אבג"]
1988        );
1989        let first_line_width = rich[0].width() + rich[1].width() + 0.01;
1990        let items = rich
1991            .into_iter()
1992            .map(InlineItem::MultilingualText)
1993            .collect::<Vec<_>>();
1994
1995        let lines = break_multilingual_into_lines(
1996            &items,
1997            &LineBreakParams {
1998                available_width: first_line_width,
1999                ..Default::default()
2000            },
2001            &fm,
2002            TextDirection::LeftToRight,
2003        )
2004        .unwrap();
2005        let spans = lines
2006            .iter()
2007            .flat_map(|line| &line.items)
2008            .filter_map(|item| match item {
2009                LineItem::MultilingualText(span) => Some((
2010                    span.text(),
2011                    span.logical_index(),
2012                    span.bidi_level(),
2013                    span.direction(),
2014                    span.base().source.unwrap(),
2015                )),
2016                _ => None,
2017            })
2018            .collect::<Vec<_>>();
2019        assert_eq!(
2020            spans
2021                .iter()
2022                .map(|(text, index, level, direction, source)| (
2023                    *text,
2024                    *index,
2025                    *level,
2026                    *direction,
2027                    source.char_start..source.char_end,
2028                ))
2029                .collect::<Vec<_>>(),
2030            [
2031                ("אבג", 0, 1, TextDirection::RightToLeft, 20..23),
2032                ("   ", 1, 0, TextDirection::LeftToRight, 23..26),
2033                ("אבג", 2, 1, TextDirection::RightToLeft, 26..29),
2034            ]
2035        );
2036        assert!(spans.iter().all(|(_, _, _, _, source)| source.node == node));
2037        let first_line = lines[0]
2038            .items
2039            .iter()
2040            .filter_map(|item| match item {
2041                LineItem::MultilingualText(span) => Some(span.text()),
2042                _ => None,
2043            })
2044            .collect::<String>();
2045
2046        assert_eq!(first_line, "אבג   ");
2047    }
2048
2049    #[test]
2050    fn cjk_prohibited_punctuation_never_starts_or_ends_a_line() {
2051        let mut fm = deterministic_font_manager();
2052        let mut segment = shaped_text_segment(&mut fm, "〈中〉、你好世界", 0.0);
2053        segment.source = Some(SourceSpan {
2054            node: crate::SourceNodeId::new(8).unwrap(),
2055            char_start: 5,
2056            char_end: 13,
2057        });
2058        let rich = fm
2059            .shape_multilingual_text(segment, Some("zh-CN"), TextDirection::LeftToRight, false)
2060            .unwrap();
2061        let items = rich
2062            .into_iter()
2063            .map(InlineItem::MultilingualText)
2064            .collect::<Vec<_>>();
2065        let lines = break_multilingual_into_lines(
2066            &items,
2067            &LineBreakParams {
2068                available_width: 35.0,
2069                ..Default::default()
2070            },
2071            &fm,
2072            TextDirection::LeftToRight,
2073        )
2074        .unwrap();
2075        let line_text = lines
2076            .iter()
2077            .map(|line| {
2078                line.items
2079                    .iter()
2080                    .filter_map(|item| match item {
2081                        LineItem::MultilingualText(span) => Some(span.text()),
2082                        _ => None,
2083                    })
2084                    .collect::<String>()
2085            })
2086            .collect::<Vec<_>>();
2087        assert_eq!(line_text, ["〈中〉、", "你", "好", "世", "界"]);
2088        assert!(
2089            line_text
2090                .iter()
2091                .all(|text| { !text.starts_with(['〉', '、']) && !text.ends_with('〈') })
2092        );
2093    }
2094
2095    #[test]
2096    fn legacy_stable_source_fixture_compiles_unchanged_public_shapes() {
2097        let segment = make_text_segment("legacy", 42.0);
2098        let _inline = InlineItem::Text(segment.clone());
2099        let _line = LineItem::Text(segment.clone());
2100        let _params = LineBreakParams {
2101            available_width: 468.0,
2102            ind_left: 0.0,
2103            ind_right: 0.0,
2104            ind_first_line: 0.0,
2105            ind_hanging: 0.0,
2106            tab_stops: Vec::new(),
2107            line_spacing: LineSpacing::Single,
2108            jc: None,
2109            wrap: true,
2110            line_prefix_widths: Vec::new(),
2111            line_suffix_widths: Vec::new(),
2112        };
2113        let _shaped = crate::ShapedText {
2114            glyph_ids: segment.glyph_ids.clone(),
2115            advances: segment.advances.clone(),
2116            width: segment.width,
2117        };
2118        let _run = crate::GlyphRun {
2119            origin: crate::Point { x: 0.0, y: 0.0 },
2120            font_id: segment.font_id,
2121            font_size: segment.font_size,
2122            glyph_ids: segment.glyph_ids,
2123            advances: segment.advances,
2124            text: segment.text,
2125            source: segment.source,
2126            color: segment.color,
2127            bold: segment.bold,
2128            italic: segment.italic,
2129            field_kind: segment.field_kind,
2130            field_source: segment.field_source,
2131            note: segment.note,
2132        };
2133    }
2134
2135    #[test]
2136    fn empty_paragraph_gets_one_line() {
2137        let fm = deterministic_font_manager();
2138        let lines = break_into_lines(&[], &LineBreakParams::default(), &fm).unwrap();
2139        assert_eq!(lines.len(), 1);
2140        assert!(lines[0].is_last);
2141        assert!(lines[0].items.is_empty());
2142    }
2143
2144    #[test]
2145    fn single_word_fits_one_line() {
2146        let fm = deterministic_font_manager();
2147        let items = vec![InlineItem::Text(make_text_segment("Hello", 50.0))];
2148        let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
2149        assert_eq!(lines.len(), 1);
2150        assert!(lines[0].is_last);
2151    }
2152
2153    #[test]
2154    fn words_wrap_to_multiple_lines() {
2155        let fm = deterministic_font_manager();
2156        // Each word is 200pt wide, line is 468pt → should wrap
2157        let mut items = vec![
2158            InlineItem::Text(make_text_segment("Word1", 200.0)),
2159            InlineItem::Text(make_text_segment("Word2", 200.0)),
2160        ];
2161        items.push(InlineItem::Text(make_text_segment("Word3", 200.0)));
2162
2163        let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
2164        assert!(lines.len() >= 2);
2165    }
2166
2167    #[test]
2168    fn ligature_runs_reshape_each_break_chunk_without_duplicate_glyphs() {
2169        let mut fm = deterministic_font_manager();
2170        let text = "by providing opportunities to crawl in cluttered spaces and handle 3-dimensional objects";
2171        let spacing = 0.4;
2172        let segment = shaped_text_segment(&mut fm, text, spacing);
2173        assert_ne!(segment.glyph_ids.len(), text.chars().count());
2174
2175        let lines = break_into_lines(
2176            &[InlineItem::Text(segment)],
2177            &LineBreakParams {
2178                available_width: 260.0,
2179                ..LineBreakParams::default()
2180            },
2181            &fm,
2182        )
2183        .expect("wrap ligature-bearing text");
2184        assert!(lines.len() > 1);
2185
2186        let mut rendered_text = String::new();
2187        for text_segment in lines.iter().flat_map(|line| {
2188            line.items.iter().filter_map(|item| match item {
2189                LineItem::Text(segment) => Some(segment),
2190                _ => None,
2191            })
2192        }) {
2193            rendered_text.push_str(&text_segment.text);
2194            let exact = fm
2195                .shape_text(
2196                    text_segment.font_id,
2197                    &text_segment.text,
2198                    text_segment.font_size,
2199                )
2200                .expect("reshape emitted chunk");
2201            assert_eq!(text_segment.glyph_ids, exact.glyph_ids);
2202            assert_eq!(text_segment.advances.len(), exact.advances.len());
2203            for (actual, unspaced) in text_segment.advances.iter().zip(exact.advances) {
2204                assert!((actual - (unspaced + spacing)).abs() < 1.0e-10);
2205            }
2206        }
2207        assert_eq!(rendered_text, text);
2208    }
2209
2210    #[test]
2211    fn line_splitting_preserves_contiguous_unicode_source_ranges() {
2212        let mut fm = deterministic_font_manager();
2213        let node = crate::SourceNodeId::new(7).expect("a non-zero source id");
2214        let mut segment = shaped_text_segment(&mut fm, "ab 🚀界 cd", 0.0);
2215        segment.source = Some(crate::SourceSpan {
2216            node,
2217            char_start: 11,
2218            char_end: 19,
2219        });
2220
2221        let lines = break_into_lines(
2222            &[InlineItem::Text(segment)],
2223            &LineBreakParams {
2224                available_width: 55.0,
2225                ..LineBreakParams::default()
2226            },
2227            &fm,
2228        )
2229        .expect("split mixed Unicode text");
2230        let sourced = lines
2231            .iter()
2232            .flat_map(|line| &line.items)
2233            .filter_map(|item| match item {
2234                LineItem::Text(segment) => segment.source,
2235                _ => None,
2236            })
2237            .collect::<Vec<_>>();
2238
2239        assert!(sourced.len() > 1, "the fixture must cross a line boundary");
2240        assert_eq!(sourced.first().expect("first range").char_start, 11);
2241        assert_eq!(sourced.last().expect("last range").char_end, 19);
2242        for pair in sourced.windows(2) {
2243            assert_eq!(pair[0].node, node);
2244            assert_eq!(pair[0].char_end, pair[1].char_start);
2245        }
2246    }
2247
2248    #[test]
2249    fn forced_line_break() {
2250        let fm = deterministic_font_manager();
2251        let items = vec![
2252            InlineItem::Text(make_text_segment("Before", 50.0)),
2253            InlineItem::LineBreak,
2254            InlineItem::Text(make_text_segment("After", 50.0)),
2255        ];
2256        let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
2257        assert!(lines.len() >= 2);
2258    }
2259
2260    #[test]
2261    fn line_page_and_column_breaks_retain_their_kind() {
2262        let fm = deterministic_font_manager();
2263        let items = vec![
2264            InlineItem::LineBreak,
2265            InlineItem::PageBreak,
2266            InlineItem::ColumnBreak,
2267            InlineItem::Text(make_text_segment("after", 30.0)),
2268        ];
2269        let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
2270
2271        assert_eq!(
2272            lines
2273                .iter()
2274                .map(|line| line.forced_break_after)
2275                .collect::<Vec<_>>(),
2276            [
2277                Some(ForcedBreakKind::Line),
2278                Some(ForcedBreakKind::Page),
2279                Some(ForcedBreakKind::Column),
2280                None,
2281            ]
2282        );
2283    }
2284
2285    #[test]
2286    fn line_height_exact() {
2287        let params = LineBreakParams {
2288            line_spacing: LineSpacing::Exact(24.0),
2289            ..Default::default()
2290        };
2291        let h = compute_line_height(10.0, 3.0, 5.0, 12.0, &params);
2292        assert!((h - 24.0).abs() < 0.01);
2293    }
2294
2295    #[test]
2296    fn line_height_auto() {
2297        let params = LineBreakParams {
2298            line_spacing: LineSpacing::Multiple(2.0),
2299            ..Default::default()
2300        };
2301        let h = compute_line_height(10.0, 3.0, 2.0, 15.0, &params);
2302        assert!((h - 30.0).abs() < 0.01); // 15 * 2.0
2303    }
2304
2305    #[test]
2306    fn first_line_indent() {
2307        let params = LineBreakParams {
2308            ind_first_line: 36.0,
2309            ..Default::default()
2310        };
2311        let first_w = compute_first_line_width(&params);
2312        let subseq_w = compute_subsequent_line_width(&params);
2313        assert!(first_w < subseq_w);
2314    }
2315
2316    #[test]
2317    fn hanging_indent() {
2318        let params = LineBreakParams {
2319            ind_left: 36.0,
2320            ind_hanging: 36.0,
2321            ..Default::default()
2322        };
2323        let first_indent = super::first_line_indent(&params);
2324        let subseq_indent = super::subsequent_line_indent(&params);
2325        assert!(first_indent < subseq_indent);
2326    }
2327
2328    #[test]
2329    fn tab_stop_resolution() {
2330        let stops = vec![TabStop {
2331            pos_pt: 72.0,
2332            align: TabAlign::Left,
2333            leader: None,
2334        }];
2335        let (w, leader) = resolve_tab_width(36.0, &stops);
2336        assert!((w - 36.0).abs() < 0.01);
2337        assert!(leader.is_none());
2338    }
2339
2340    #[test]
2341    fn default_tab_stops() {
2342        let (w, _) = resolve_tab_width(10.0, &[]);
2343        assert!((w - 26.0).abs() < 0.01); // next stop at 36pt
2344    }
2345
2346    #[test]
2347    fn tab_stop_with_dot_leader() {
2348        let stops = vec![TabStop {
2349            pos_pt: 400.0,
2350            align: TabAlign::Right,
2351            leader: Some(TabLeader::Dot),
2352        }];
2353        let (w, leader) = resolve_tab_width(100.0, &stops);
2354        assert!((w - 300.0).abs() < 0.01);
2355        assert_eq!(leader, Some('.'));
2356    }
2357
2358    #[test]
2359    fn the_eleven_line_tests_pass_with_owned_types() {
2360        assert!(LineBreakParams::default().wrap);
2361        empty_paragraph_gets_one_line();
2362        single_word_fits_one_line();
2363        words_wrap_to_multiple_lines();
2364        forced_line_break();
2365        line_height_exact();
2366        line_height_auto();
2367        first_line_indent();
2368        hanging_indent();
2369        tab_stop_resolution();
2370        default_tab_stops();
2371        tab_stop_with_dot_leader();
2372    }
2373
2374    #[test]
2375    fn line_spacing_variants_preserve_existing_height_rules() {
2376        let height = |line_spacing| {
2377            compute_line_height(
2378                10.0,
2379                3.0,
2380                2.0,
2381                11.0,
2382                &LineBreakParams {
2383                    line_spacing,
2384                    ..Default::default()
2385                },
2386            )
2387        };
2388
2389        assert!((height(LineSpacing::Single) - 15.0).abs() < 0.01);
2390        assert!((height(LineSpacing::Multiple(1.5)) - 16.5).abs() < 0.01);
2391        assert!((height(LineSpacing::Exact(8.25)) - 8.25).abs() < 0.01);
2392        assert!((height(LineSpacing::AtLeast(8.25)) - 15.0).abs() < 0.01);
2393        assert!((height(LineSpacing::AtLeast(18.5)) - 18.5).abs() < 0.01);
2394    }
2395
2396    #[test]
2397    fn mixed_font_line_uses_tallest_full_natural_advance() {
2398        let fm = deterministic_font_manager();
2399        let mut first = make_text_segment("first", 20.0);
2400        first.ascent = 10.0;
2401        first.descent = 2.0;
2402        first.line_gap = 4.0;
2403        let mut second = make_text_segment("second", 20.0);
2404        second.ascent = 8.0;
2405        second.descent = 5.0;
2406        second.line_gap = 1.0;
2407
2408        let lines = break_into_lines(
2409            &[InlineItem::Text(first), InlineItem::Text(second)],
2410            &LineBreakParams::default(),
2411            &fm,
2412        )
2413        .expect("lay out mixed-font line");
2414
2415        assert_eq!(lines.len(), 1);
2416        assert!((lines[0].ascent - 10.0).abs() < 0.01);
2417        assert!((lines[0].descent - 5.0).abs() < 0.01);
2418        assert!((lines[0].line_gap - 1.0).abs() < 0.01);
2419        assert!((lines[0].height - 16.0).abs() < 0.01);
2420        assert!((lines[0].baseline_offset() - 10.5).abs() < 0.01);
2421    }
2422
2423    #[test]
2424    fn multiple_spacing_uses_largest_text_point_size_on_each_line() {
2425        let fm = deterministic_font_manager();
2426        let mut first = make_text_segment("first", 20.0);
2427        first.font_size = 12.0;
2428        first.line_gap = 4.0;
2429        let mut second = make_text_segment("second", 20.0);
2430        second.font_size = 20.0;
2431        second.line_gap = 1.0;
2432
2433        let lines = break_into_lines(
2434            &[InlineItem::Text(first), InlineItem::Text(second)],
2435            &LineBreakParams {
2436                line_spacing: LineSpacing::Multiple(1.25),
2437                ..LineBreakParams::default()
2438            },
2439            &fm,
2440        )
2441        .expect("lay out percentage-spaced mixed-size line");
2442
2443        assert_eq!(lines.len(), 1);
2444        assert!((lines[0].height - 25.0).abs() < 0.01);
2445    }
2446
2447    #[test]
2448    fn positive_leading_is_split_and_below_natural_exact_spacing_is_not_clamped() {
2449        let positive = LayoutLine {
2450            items: Vec::new(),
2451            width: 0.0,
2452            ascent: 10.0,
2453            descent: 3.0,
2454            line_gap: 5.0,
2455            height: 18.0,
2456            indent_left: 0.0,
2457            available_width: 100.0,
2458            is_last: true,
2459            forced_break_after: None,
2460        };
2461        let below_natural = LayoutLine {
2462            height: 8.0,
2463            ..positive.clone()
2464        };
2465
2466        assert!((positive.baseline_offset() - 12.5).abs() < 0.01);
2467        assert!((below_natural.height - 8.0).abs() < 0.01);
2468        assert!((below_natural.baseline_offset() - 10.0).abs() < 0.01);
2469    }
2470
2471    #[test]
2472    fn zero_gap_and_empty_segment_preserve_natural_height_rules() {
2473        let fm = deterministic_font_manager();
2474        let zero_gap = make_text_segment("zero", 20.0);
2475        let mut empty = make_text_segment("", 0.0);
2476        empty.line_gap = 4.0;
2477
2478        let zero_gap_line = break_into_lines(
2479            &[InlineItem::Text(zero_gap)],
2480            &LineBreakParams::default(),
2481            &fm,
2482        )
2483        .expect("lay out zero-gap line");
2484        let empty_line =
2485            break_into_lines(&[InlineItem::Text(empty)], &LineBreakParams::default(), &fm)
2486                .expect("lay out styled empty line");
2487
2488        assert!((zero_gap_line[0].height - 13.0).abs() < 0.01);
2489        assert!((empty_line[0].height - 17.0).abs() < 0.01);
2490    }
2491
2492    #[test]
2493    fn wrap_false_only_breaks_on_an_explicit_break() {
2494        let fm = deterministic_font_manager();
2495        let params = LineBreakParams {
2496            available_width: 100.0,
2497            wrap: false,
2498            ..Default::default()
2499        };
2500
2501        for forced_break in [
2502            InlineItem::LineBreak,
2503            InlineItem::PageBreak,
2504            InlineItem::ColumnBreak,
2505        ] {
2506            let items = vec![
2507                InlineItem::Text(make_text_segment("one", 80.0)),
2508                InlineItem::Text(make_text_segment("two", 80.0)),
2509                forced_break,
2510                InlineItem::Text(make_text_segment("three", 80.0)),
2511                InlineItem::Text(make_text_segment("four", 80.0)),
2512            ];
2513            let lines = break_into_lines(&items, &params, &fm).unwrap();
2514
2515            assert_eq!(lines.len(), 2);
2516            assert!((lines[0].width - 160.0).abs() < 0.01);
2517            assert!((lines[1].width - 160.0).abs() < 0.01);
2518        }
2519    }
2520
2521    #[test]
2522    fn tab_stops_use_point_positions_and_owned_leaders() {
2523        let mut fm = deterministic_font_manager();
2524        let font_id = fm
2525            .resolve_font(Some("Carlito"), false, false)
2526            .expect("bundled Carlito should resolve");
2527        let stop = TabStop {
2528            pos_pt: 72.25,
2529            align: TabAlign::Decimal,
2530            leader: Some(TabLeader::Dot),
2531        };
2532
2533        let item = inline_to_line_item(&InlineItem::Tab, 12.0, &[stop], &fm, Some((font_id, 12.0)));
2534
2535        let LineItem::Tab {
2536            width,
2537            leader: Some(leader),
2538        } = item
2539        else {
2540            panic!("owned dot leader should shape into a tab line item");
2541        };
2542        assert!((width - 60.25).abs() < 0.01);
2543        assert!((leader.width - 60.25).abs() < 0.01);
2544        assert!(!leader.glyph_ids.is_empty());
2545        assert!(leader.text.chars().all(|ch| ch == '.'));
2546    }
2547
2548    #[test]
2549    fn staged_image_types_use_media_id_instead_of_embed_id() {
2550        let media_id = crate::MediaId::from_bytes(b"image");
2551        let item = inline_to_line_item(
2552            &InlineItem::Image {
2553                width: 10.0,
2554                height: 20.0,
2555                media_id,
2556            },
2557            0.0,
2558            &[],
2559            &deterministic_font_manager(),
2560            None,
2561        );
2562        let LineItem::Image {
2563            media_id: actual, ..
2564        } = item
2565        else {
2566            panic!("image should remain an image");
2567        };
2568        assert_eq!(actual, media_id);
2569    }
2570
2571    #[test]
2572    fn group_inline_item_breaks_and_positions_like_an_image() {
2573        use crate::{GroupElement, PositionedElement, Transform};
2574
2575        let group = GroupElement {
2576            transform: Transform::IDENTITY,
2577            clip: None,
2578            opacity: 1.0,
2579            effects: Vec::new(),
2580            children: vec![PositionedElement::FilledRect {
2581                rect: crate::Rect {
2582                    x: 2.0,
2583                    y: 3.0,
2584                    width: 4.0,
2585                    height: 5.0,
2586                },
2587                color: crate::Color::BLACK,
2588            }],
2589        };
2590        let items = vec![InlineItem::Group {
2591            width: 80.0,
2592            height: 40.0,
2593            baseline: None,
2594            group: group.clone(),
2595        }];
2596        let lines = break_into_lines(
2597            &items,
2598            &LineBreakParams {
2599                available_width: 80.0,
2600                ..Default::default()
2601            },
2602            &deterministic_font_manager(),
2603        )
2604        .expect("group line breaking");
2605
2606        assert_eq!(lines.len(), 1);
2607        assert_eq!(lines[0].width, 80.0);
2608        assert_eq!(lines[0].height, 40.0);
2609        let LineItem::Group {
2610            width,
2611            height,
2612            group: actual,
2613            ..
2614        } = &lines[0].items[0]
2615        else {
2616            panic!("inline group should remain a group line item");
2617        };
2618        assert_eq!((*width, *height), (80.0, 40.0));
2619        assert_eq!(actual, &group);
2620    }
2621
2622    #[test]
2623    fn baseline_aware_inline_groups_contribute_exact_ascent_and_descent() {
2624        use crate::{GroupElement, Transform};
2625
2626        let group = GroupElement {
2627            transform: Transform::IDENTITY,
2628            clip: None,
2629            opacity: 1.0,
2630            effects: Vec::new(),
2631            children: Vec::new(),
2632        };
2633        let lines = break_into_lines(
2634            &[
2635                InlineItem::Group {
2636                    width: 20.0,
2637                    height: 18.0,
2638                    baseline: None,
2639                    group: group.clone(),
2640                },
2641                InlineItem::Group {
2642                    width: 20.0,
2643                    height: 18.0,
2644                    baseline: Some(11.0),
2645                    group,
2646                },
2647            ],
2648            &LineBreakParams {
2649                available_width: 100.0,
2650                ..Default::default()
2651            },
2652            &deterministic_font_manager(),
2653        )
2654        .expect("group line breaking");
2655
2656        assert_eq!(lines.len(), 1);
2657        assert_eq!(lines[0].ascent, 18.0);
2658        assert_eq!(lines[0].descent, 7.0);
2659        let LineItem::Group {
2660            baseline: Some(baseline),
2661            ..
2662        } = &lines[0].items[1]
2663        else {
2664            panic!("baseline should survive line breaking");
2665        };
2666        assert_eq!(*baseline, 11.0);
2667    }
2668
2669    #[test]
2670    fn group_baselines_are_normalized_before_pagination() {
2671        use crate::{GroupElement, Transform};
2672
2673        for (height, baseline, expected) in [
2674            (18.0, Some(-4.0), Some(0.0)),
2675            (18.0, Some(30.0), Some(18.0)),
2676            (18.0, Some(f64::NAN), None),
2677            (-18.0, Some(4.0), Some(0.0)),
2678            (f64::NAN, Some(4.0), None),
2679        ] {
2680            let item = InlineItem::Group {
2681                width: 20.0,
2682                height,
2683                baseline,
2684                group: GroupElement {
2685                    transform: Transform::IDENTITY,
2686                    clip: None,
2687                    opacity: 1.0,
2688                    effects: Vec::new(),
2689                    children: Vec::new(),
2690                },
2691            };
2692            let LineItem::Group { baseline, .. } =
2693                inline_to_line_item(&item, 0.0, &[], &deterministic_font_manager(), None)
2694            else {
2695                panic!("inline group should remain a group line item");
2696            };
2697            assert_eq!(baseline, expected);
2698        }
2699    }
2700}