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