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