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