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::Result;
6use crate::font::FontManager;
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 tab character.
79    Tab,
80    /// A forced line break.
81    LineBreak,
82    /// A forced page break.
83    PageBreak,
84    /// A forced column break.
85    ColumnBreak,
86    /// An inline image.
87    Image {
88        width: f64,
89        height: f64,
90        media_id: MediaId,
91    },
92    /// A backend-neutral group with child-local coordinates.
93    Group {
94        width: f64,
95        height: f64,
96        group: GroupElement,
97    },
98    /// An informative drawing carried to a semantic output container.
99    Figure {
100        item: Box<InlineItem>,
101        alternate_text: String,
102        structure_id: Option<StructureId>,
103    },
104    /// A numbering marker (rendered before the first line).
105    Marker(TextSegment),
106}
107
108/// Which stream a note reference belongs to.
109///
110/// A reference carries only a number in the markup, and the two streams
111/// number independently, so a document can hold a footnote and an endnote
112/// that share a number. Without the stream the two are indistinguishable and
113/// one silently shadows the other.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
115pub enum NoteStream {
116    /// Rendered at the foot of the page carrying the reference.
117    Footnote,
118    /// Rendered at the end of the document.
119    Endnote,
120}
121
122/// A reference to one note, unique across both streams.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
124pub struct NoteRef {
125    pub stream: NoteStream,
126    pub id: i32,
127}
128
129/// A shaped text segment with associated formatting.
130#[derive(Debug, Clone)]
131pub struct TextSegment {
132    pub text: String,
133    /// Exact source range for this segment, when directly attributable.
134    pub source: Option<SourceSpan>,
135    pub font_id: FontId,
136    pub font_size: f64,
137    pub glyph_ids: Vec<u16>,
138    pub advances: Vec<f64>,
139    pub width: f64,
140    pub ascent: f64,
141    pub descent: f64,
142    /// Additional font leading included in the natural line advance.
143    pub line_gap: f64,
144    pub color: Color,
145    pub bold: bool,
146    pub italic: bool,
147    /// Underline style (None = no underline).
148    pub underline: Option<Underline>,
149    /// Single strikethrough.
150    pub strike: bool,
151    /// Double strikethrough.
152    pub dstrike: bool,
153    /// Highlight/background color for the run.
154    pub highlight: Option<Color>,
155    /// Baseline offset in points (positive = raise, negative = lower).
156    pub baseline_offset: f64,
157    /// Hyperlink URL if this segment is inside a hyperlink.
158    pub hyperlink_url: Option<String>,
159    /// If this segment is a field placeholder, the kind of field.
160    pub field_kind: Option<FieldKind>,
161    /// If this segment is a note reference marker, which note it points at.
162    pub note: Option<NoteRef>,
163}
164
165/// A single item positioned on a line.
166#[derive(Debug, Clone)]
167#[non_exhaustive]
168pub enum LineItem {
169    Text(TextSegment),
170    Tab {
171        width: f64,
172        /// Pre-shaped leader text to fill the tab gap (e.g., dots, hyphens).
173        leader: Option<TextSegment>,
174    },
175    Image {
176        width: f64,
177        height: f64,
178        media_id: MediaId,
179    },
180    Group {
181        width: f64,
182        height: f64,
183        group: GroupElement,
184    },
185    /// An informative drawing carried to a semantic output container.
186    Figure {
187        item: Box<LineItem>,
188        alternate_text: String,
189        structure_id: Option<StructureId>,
190    },
191    Marker(TextSegment),
192}
193
194impl LineItem {
195    pub fn width(&self) -> f64 {
196        match self {
197            LineItem::Text(seg) => seg.width,
198            LineItem::Tab { width, .. } => *width,
199            LineItem::Image { width, .. } => *width,
200            LineItem::Group { width, .. } => *width,
201            LineItem::Figure { item, .. } => item.width(),
202            LineItem::Marker(seg) => seg.width,
203        }
204    }
205}
206
207/// A laid-out line within a paragraph.
208#[derive(Debug, Clone)]
209pub struct LayoutLine {
210    pub items: Vec<LineItem>,
211    /// Total content width of the line.
212    pub width: f64,
213    /// Maximum ascent on this line (above baseline).
214    pub ascent: f64,
215    /// Maximum descent on this line (below baseline).
216    pub descent: f64,
217    /// Effective leading needed to preserve the tallest run's natural advance.
218    pub line_gap: f64,
219    /// Total line height.
220    pub height: f64,
221    /// Left indent for this line.
222    pub indent_left: f64,
223    /// Available width this line was laid out against.
224    pub available_width: f64,
225    /// Whether this is the last line of the paragraph.
226    pub is_last: bool,
227}
228
229impl LayoutLine {
230    /// Distance from the line-box top to its text baseline.
231    pub fn baseline_offset(&self) -> f64 {
232        let leading = self.height - self.ascent - self.descent;
233        self.ascent + if leading >= 0.0 { leading / 2.0 } else { 0.0 }
234    }
235}
236
237/// Parameters for line breaking.
238#[derive(Debug, Clone)]
239pub struct LineBreakParams {
240    /// Total available width (page width minus margins).
241    pub available_width: f64,
242    /// Left indentation in points.
243    pub ind_left: f64,
244    /// Right indentation in points.
245    pub ind_right: f64,
246    /// First line indent in points (positive = indent, 0 if hanging).
247    pub ind_first_line: f64,
248    /// Hanging indent in points (positive = text lines indented relative to first).
249    pub ind_hanging: f64,
250    /// Tab stops.
251    pub tab_stops: Vec<TabStop>,
252    /// Line spacing rule and value.
253    pub line_spacing: LineSpacing,
254    /// Paragraph justification.
255    pub jc: Option<Align>,
256    /// Whether width overflow may create automatic line breaks.
257    pub wrap: bool,
258    /// Extra width kept clear at the start of individual lines, by line index.
259    ///
260    /// This is how a floating drawing pushes text aside. An empty vector, the
261    /// default, reserves nothing and reproduces unwrapped line breaking
262    /// exactly.
263    pub line_prefix_widths: Vec<f64>,
264    /// Extra width kept clear at the end of individual lines, by line index.
265    pub line_suffix_widths: Vec<f64>,
266}
267
268impl Default for LineBreakParams {
269    fn default() -> Self {
270        LineBreakParams {
271            available_width: 468.0, // US Letter with 1" margins
272            line_prefix_widths: Vec::new(),
273            line_suffix_widths: Vec::new(),
274            ind_left: 0.0,
275            ind_right: 0.0,
276            ind_first_line: 0.0,
277            ind_hanging: 0.0,
278            tab_stops: Vec::new(),
279            line_spacing: LineSpacing::Single,
280            jc: None,
281            wrap: true,
282        }
283    }
284}
285
286/// Break inline items into lines using a greedy algorithm.
287pub fn break_into_lines(
288    items: &[InlineItem],
289    params: &LineBreakParams,
290    fm: &FontManager,
291) -> Result<Vec<LayoutLine>> {
292    if items.is_empty() {
293        // Empty paragraph still gets one empty line
294        return Ok(vec![LayoutLine {
295            items: Vec::new(),
296            width: 0.0,
297            ascent: 0.0,
298            descent: 0.0,
299            line_gap: 0.0,
300            height: compute_line_height(0.0, 0.0, 0.0, 0.0, params),
301            indent_left: line_indent_at(params, 0, true),
302            available_width: line_width_at(params, 0, true),
303            is_last: true,
304        }]);
305    }
306
307    let mut lines: Vec<LayoutLine> = Vec::new();
308    let mut current_items: Vec<LineItem> = Vec::new();
309    let mut current_width: f64 = 0.0;
310    let mut current_ascent: f64 = 0.0;
311    let mut current_descent: f64 = 0.0;
312    let mut current_natural_height: f64 = 0.0;
313    let mut current_font_size: f64 = 0.0;
314    // The line index drives the per-line reservations a floating drawing
315    // creates, so it is tracked rather than a plain first-or-not flag.
316    let mut line_index = 0usize;
317    let mut is_first_line = true;
318
319    let first_line_width = line_width_at(params, 0, true);
320
321    let mut line_avail = first_line_width;
322
323    // Track the most recent font context for shaping tab leaders
324    let mut font_ctx: Option<(FontId, f64)> = None;
325    // Initialize from the first text segment if available
326    for item in items {
327        if let InlineItem::Text(seg) | InlineItem::Marker(seg) = item {
328            font_ctx = Some((seg.font_id, seg.font_size));
329            break;
330        }
331    }
332
333    // Build breakable segments from inline items
334    let segments = build_breakable_segments(items, fm)?;
335
336    for seg in &segments {
337        match seg {
338            BreakableSegment::Items(seg_items) => {
339                let seg_width: f64 = seg_items.iter().map(inline_item_width).sum();
340
341                if params.wrap
342                    && !current_items.is_empty()
343                    && current_width + seg_width > line_avail + 0.01
344                {
345                    // Finish current line
346                    let indent = line_indent_at(params, line_index, is_first_line);
347                    let line_gap =
348                        effective_line_gap(current_ascent, current_descent, current_natural_height);
349                    lines.push(LayoutLine {
350                        items: std::mem::take(&mut current_items),
351                        width: current_width,
352                        ascent: current_ascent,
353                        descent: current_descent,
354                        line_gap,
355                        height: compute_line_height(
356                            current_ascent,
357                            current_descent,
358                            line_gap,
359                            current_font_size,
360                            params,
361                        ),
362                        indent_left: indent,
363                        available_width: line_avail,
364                        is_last: false,
365                    });
366                    current_width = 0.0;
367                    current_ascent = 0.0;
368                    current_descent = 0.0;
369                    current_natural_height = 0.0;
370                    current_font_size = 0.0;
371                    is_first_line = false;
372                    line_index += 1;
373                    line_avail = line_width_at(params, line_index, false);
374                }
375
376                // Add segment items to current line
377                for item in seg_items {
378                    let (w, a, d, natural_height, font_size) = item_metrics(item);
379                    current_width += w;
380                    if a > current_ascent {
381                        current_ascent = a;
382                    }
383                    if d > current_descent {
384                        current_descent = d;
385                    }
386                    current_natural_height = current_natural_height.max(natural_height);
387                    current_font_size = current_font_size.max(font_size);
388                    // Update font context from text segments
389                    if let InlineItem::Text(seg) | InlineItem::Marker(seg) = item {
390                        font_ctx = Some((seg.font_id, seg.font_size));
391                    }
392                    current_items.push(inline_to_line_item(
393                        item,
394                        current_width,
395                        &params.tab_stops,
396                        fm,
397                        font_ctx,
398                    ));
399                }
400            }
401            BreakableSegment::ForcedBreak(break_type) => {
402                let indent = line_indent_at(params, line_index, is_first_line);
403                let line_gap =
404                    effective_line_gap(current_ascent, current_descent, current_natural_height);
405                lines.push(LayoutLine {
406                    items: std::mem::take(&mut current_items),
407                    width: current_width,
408                    ascent: current_ascent,
409                    descent: current_descent,
410                    line_gap,
411                    height: compute_line_height(
412                        current_ascent,
413                        current_descent,
414                        line_gap,
415                        current_font_size,
416                        params,
417                    ),
418                    indent_left: indent,
419                    available_width: line_avail,
420                    is_last: matches!(break_type, ForcedBreakType::Page | ForcedBreakType::Column),
421                });
422                current_width = 0.0;
423                current_ascent = 0.0;
424                current_descent = 0.0;
425                current_natural_height = 0.0;
426                current_font_size = 0.0;
427                is_first_line = false;
428                line_index += 1;
429                line_avail = line_width_at(params, line_index, false);
430            }
431        }
432    }
433
434    // Flush remaining items as the last line
435    let indent = line_indent_at(params, line_index, is_first_line);
436    let line_gap = effective_line_gap(current_ascent, current_descent, current_natural_height);
437    lines.push(LayoutLine {
438        items: current_items,
439        width: current_width,
440        ascent: current_ascent,
441        descent: current_descent,
442        line_gap,
443        height: compute_line_height(
444            current_ascent,
445            current_descent,
446            line_gap,
447            current_font_size,
448            params,
449        ),
450        indent_left: indent,
451        available_width: line_avail,
452        is_last: true,
453    });
454
455    Ok(lines)
456}
457
458// ---- Internal helpers ----
459
460#[derive(Debug)]
461enum BreakableSegment {
462    /// A group of items that should be kept together (word or cluster).
463    Items(Vec<InlineItem>),
464    /// A forced break.
465    ForcedBreak(ForcedBreakType),
466}
467
468#[derive(Debug)]
469enum ForcedBreakType {
470    Line,
471    Page,
472    Column,
473}
474
475/// Build breakable segments by finding break opportunities in text.
476///
477/// Text items are split at unicode line-break opportunities (word boundaries,
478/// hyphens, etc.). Non-text items (tabs, images, markers) are treated as
479/// atomic units with break opportunities around them.
480fn build_breakable_segments(
481    items: &[InlineItem],
482    fm: &FontManager,
483) -> Result<Vec<BreakableSegment>> {
484    let mut segments = Vec::new();
485    let mut current_group: Vec<InlineItem> = Vec::new();
486
487    for item in items {
488        match item {
489            InlineItem::LineBreak => {
490                if !current_group.is_empty() {
491                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
492                }
493                segments.push(BreakableSegment::ForcedBreak(ForcedBreakType::Line));
494            }
495            InlineItem::PageBreak => {
496                if !current_group.is_empty() {
497                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
498                }
499                segments.push(BreakableSegment::ForcedBreak(ForcedBreakType::Page));
500            }
501            InlineItem::ColumnBreak => {
502                if !current_group.is_empty() {
503                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
504                }
505                segments.push(BreakableSegment::ForcedBreak(ForcedBreakType::Column));
506            }
507            InlineItem::Tab => {
508                // Tab is a break opportunity
509                if !current_group.is_empty() {
510                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
511                }
512                segments.push(BreakableSegment::Items(vec![item.clone()]));
513            }
514            InlineItem::Text(seg) => {
515                if seg.text.is_empty() {
516                    if !current_group.is_empty() {
517                        segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
518                    }
519                    segments.push(BreakableSegment::Items(vec![item.clone()]));
520                    continue;
521                }
522                // Use unicode-linebreak to find break opportunities within text
523                let breaks = split_text_at_break_opportunities(seg);
524                let spacing =
525                    if breaks.len() == 1 && breaks[0].start == 0 && breaks[0].end == seg.text.len()
526                    {
527                        0.0
528                    } else {
529                        let original = fm.shape_text(seg.font_id, &seg.text, seg.font_size)?;
530                        if original.advances.len() == seg.advances.len()
531                            && !original.advances.is_empty()
532                        {
533                            (seg.width - original.width) / original.advances.len() as f64
534                        } else {
535                            0.0
536                        }
537                    };
538
539                for tb in &breaks {
540                    let chunk = &seg.text[tb.start..tb.end];
541                    if chunk.is_empty() {
542                        continue;
543                    }
544
545                    // If this chunk starts with whitespace, treat as a break opportunity
546                    if !current_group.is_empty() && chunk.starts_with(|c: char| c.is_whitespace()) {
547                        segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
548                    }
549
550                    // Create a sub-segment for just this chunk (not the entire text)
551                    let sub_item = split_text_subsegment(seg, tb.start, tb.end, spacing, fm)?;
552                    current_group.push(sub_item);
553
554                    if tb.is_break {
555                        segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
556                    }
557                }
558
559                // Flush any remaining
560                if !current_group.is_empty() {
561                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
562                }
563            }
564            InlineItem::Marker(_)
565            | InlineItem::Image { .. }
566            | InlineItem::Group { .. }
567            | InlineItem::Figure { .. } => {
568                current_group.push(item.clone());
569            }
570        }
571    }
572
573    if !current_group.is_empty() {
574        segments.push(BreakableSegment::Items(current_group));
575    }
576
577    Ok(segments)
578}
579
580/// Create a sub-segment InlineItem from a byte range within a TextSegment.
581///
582/// Reshapes the selected text and preserves formatting from the parent segment.
583fn split_text_subsegment(
584    seg: &TextSegment,
585    byte_start: usize,
586    byte_end: usize,
587    spacing: f64,
588    fm: &FontManager,
589) -> Result<InlineItem> {
590    // If this is the full segment, just clone it
591    if byte_start == 0 && byte_end == seg.text.len() {
592        return Ok(InlineItem::Text(seg.clone()));
593    }
594
595    let sub_text = seg.text[byte_start..byte_end].to_string();
596    let mut shaped = fm.shape_text(seg.font_id, &sub_text, seg.font_size)?;
597    for advance in &mut shaped.advances {
598        *advance += spacing;
599    }
600    shaped.width += spacing * shaped.advances.len() as f64;
601
602    let source = seg.source.map(|source| {
603        let start = seg.text[..byte_start].chars().count() as u32;
604        let end = seg.text[..byte_end].chars().count() as u32;
605        SourceSpan {
606            node: source.node,
607            char_start: source.char_start + start,
608            char_end: source.char_start + end,
609        }
610    });
611
612    Ok(InlineItem::Text(TextSegment {
613        text: sub_text,
614        source,
615        font_id: seg.font_id,
616        font_size: seg.font_size,
617        glyph_ids: shaped.glyph_ids,
618        advances: shaped.advances,
619        width: shaped.width,
620        ascent: seg.ascent,
621        descent: seg.descent,
622        line_gap: seg.line_gap,
623        color: seg.color,
624        bold: seg.bold,
625        italic: seg.italic,
626        underline: seg.underline,
627        strike: seg.strike,
628        dstrike: seg.dstrike,
629        highlight: seg.highlight,
630        baseline_offset: seg.baseline_offset,
631        hyperlink_url: seg.hyperlink_url.clone(),
632        field_kind: seg.field_kind,
633        note: seg.note,
634    }))
635}
636
637struct TextBreakInfo {
638    /// Byte range within the original text.
639    start: usize,
640    end: usize,
641    /// Whether a line break is allowed after this segment.
642    is_break: bool,
643}
644
645fn split_text_at_break_opportunities(seg: &TextSegment) -> Vec<TextBreakInfo> {
646    use unicode_linebreak::{BreakOpportunity, linebreaks};
647
648    let text = &seg.text;
649    if text.is_empty() {
650        return vec![];
651    }
652
653    let mut breaks = Vec::new();
654    let mut last_start = 0;
655
656    for (byte_pos, opportunity) in linebreaks(text) {
657        if byte_pos == 0 {
658            continue;
659        }
660
661        let is_break = matches!(
662            opportunity,
663            BreakOpportunity::Allowed | BreakOpportunity::Mandatory
664        );
665
666        breaks.push(TextBreakInfo {
667            start: last_start,
668            end: byte_pos,
669            is_break,
670        });
671        last_start = byte_pos;
672    }
673
674    // If unicode-linebreak didn't produce any breaks, treat as one chunk
675    if breaks.is_empty() {
676        breaks.push(TextBreakInfo {
677            start: 0,
678            end: text.len(),
679            is_break: true,
680        });
681    }
682
683    breaks
684}
685
686fn inline_item_width(item: &InlineItem) -> f64 {
687    match item {
688        InlineItem::Text(seg) => seg.width,
689        InlineItem::Tab => 36.0, // Default tab width, will be resolved
690        InlineItem::Image { width, .. } => *width,
691        InlineItem::Group { width, .. } => *width,
692        InlineItem::Figure { item, .. } => inline_item_width(item),
693        InlineItem::Marker(seg) => seg.width,
694        InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => 0.0,
695    }
696}
697
698fn item_metrics(item: &InlineItem) -> (f64, f64, f64, f64, f64) {
699    // Returns (width, ascent, descent, natural height, text font size)
700    match item {
701        InlineItem::Text(seg) => (
702            seg.width,
703            seg.ascent,
704            seg.descent,
705            seg.ascent + seg.descent + seg.line_gap,
706            seg.font_size,
707        ),
708        InlineItem::Marker(seg) => (
709            seg.width,
710            seg.ascent,
711            seg.descent,
712            seg.ascent + seg.descent + seg.line_gap,
713            0.0,
714        ),
715        InlineItem::Tab => (36.0, 0.0, 0.0, 0.0, 0.0),
716        InlineItem::Image { width, height, .. } => (*width, *height, 0.0, *height, 0.0),
717        InlineItem::Group { width, height, .. } => (*width, *height, 0.0, *height, 0.0),
718        InlineItem::Figure { item, .. } => item_metrics(item),
719        InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => {
720            (0.0, 0.0, 0.0, 0.0, 0.0)
721        }
722    }
723}
724
725fn inline_to_line_item(
726    item: &InlineItem,
727    current_x: f64,
728    tab_stops: &[TabStop],
729    fm: &FontManager,
730    font_ctx: Option<(FontId, f64)>,
731) -> LineItem {
732    match item {
733        InlineItem::Text(seg) => LineItem::Text(seg.clone()),
734        InlineItem::Marker(seg) => LineItem::Marker(seg.clone()),
735        InlineItem::Tab => {
736            let (tab_width, leader_char) = resolve_tab_width(current_x, tab_stops);
737            let leader = leader_char.and_then(|ch| shape_leader(fm, font_ctx, ch, tab_width));
738            LineItem::Tab {
739                width: tab_width,
740                leader,
741            }
742        }
743        InlineItem::Image {
744            width,
745            height,
746            media_id,
747        } => LineItem::Image {
748            width: *width,
749            height: *height,
750            media_id: *media_id,
751        },
752        InlineItem::Group {
753            width,
754            height,
755            group,
756        } => LineItem::Group {
757            width: *width,
758            height: *height,
759            group: group.clone(),
760        },
761        InlineItem::Figure {
762            item,
763            alternate_text,
764            structure_id,
765        } => LineItem::Figure {
766            item: Box::new(inline_to_line_item(
767                item, current_x, tab_stops, fm, font_ctx,
768            )),
769            alternate_text: alternate_text.clone(),
770            structure_id: *structure_id,
771        },
772        InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => LineItem::Tab {
773            width: 0.0,
774            leader: None,
775        },
776    }
777}
778
779/// Shape a leader character repeated to fill the given width.
780fn shape_leader(
781    fm: &FontManager,
782    font_ctx: Option<(FontId, f64)>,
783    leader_char: char,
784    tab_width: f64,
785) -> Option<TextSegment> {
786    let (font_id, font_size) = font_ctx?;
787    if tab_width < 1.0 {
788        return None;
789    }
790
791    // Shape a single leader character to get its advance width
792    let single = String::from(leader_char);
793    let shaped = fm.shape_text(font_id, &single, font_size).ok()?;
794    if shaped.glyph_ids.is_empty() {
795        return None;
796    }
797    let char_advance = shaped.advances[0];
798    if char_advance < 0.5 {
799        return None;
800    }
801
802    // Add a small gap between leader chars (about 50% of char width for dots, less for others)
803    let spacing = match leader_char {
804        '.' | '\u{00B7}' => char_advance * 0.5,
805        _ => char_advance * 0.15,
806    };
807    let step = char_advance + spacing;
808    let count = ((tab_width - spacing) / step).floor() as usize;
809    if count == 0 {
810        return None;
811    }
812
813    // Build the repeated leader text and glyph arrays
814    let leader_text: String = std::iter::repeat_n(leader_char, count).collect();
815    let mut glyph_ids = Vec::with_capacity(count);
816    let mut advances = Vec::with_capacity(count);
817    for i in 0..count {
818        glyph_ids.push(shaped.glyph_ids[0]);
819        if i + 1 < count {
820            advances.push(char_advance + spacing);
821        } else {
822            advances.push(char_advance);
823        }
824    }
825
826    let metrics = fm.metrics(font_id, font_size).ok()?;
827
828    Some(TextSegment {
829        text: leader_text,
830        source: None,
831        font_id,
832        font_size,
833        glyph_ids,
834        advances,
835        width: tab_width, // fill the entire tab gap
836        ascent: metrics.ascent,
837        descent: metrics.descent,
838        line_gap: metrics.line_gap,
839        color: Color::BLACK,
840        bold: false,
841        italic: false,
842        underline: None,
843        strike: false,
844        dstrike: false,
845        highlight: None,
846        baseline_offset: 0.0,
847        hyperlink_url: None,
848        field_kind: None,
849        note: None,
850    })
851}
852
853/// Resolve tab stop width and leader character based on current x position and defined stops.
854fn resolve_tab_width(current_x: f64, tab_stops: &[TabStop]) -> (f64, Option<char>) {
855    // Find the next tab stop after the current position
856    for stop in tab_stops {
857        let stop_pos = stop.pos_pt;
858        if stop_pos > current_x {
859            let width = match stop.align {
860                TabAlign::Left => stop_pos - current_x,
861                TabAlign::Center => (stop_pos - current_x).max(0.0),
862                TabAlign::Right => (stop_pos - current_x).max(0.0),
863                _ => stop_pos - current_x,
864            };
865            let leader = stop.leader.and_then(|l| match l {
866                TabLeader::Dot => Some('.'),
867                TabLeader::Hyphen => Some('-'),
868                TabLeader::Underscore => Some('_'),
869                TabLeader::MiddleDot => Some('\u{00B7}'),
870                TabLeader::Heavy => Some('_'),
871                TabLeader::None => None,
872            });
873            return (width, leader);
874        }
875    }
876    // Default tab stops every 0.5 inches (36pt)
877    let default_interval = 36.0;
878    let next_stop = ((current_x / default_interval).floor() + 1.0) * default_interval;
879    (next_stop - current_x, None)
880}
881
882fn compute_first_line_width(params: &LineBreakParams) -> f64 {
883    if params.ind_hanging > 0.0 {
884        // Hanging indent: first line has MORE width (extends left)
885        params.available_width - params.ind_left - params.ind_right + params.ind_hanging
886    } else {
887        params.available_width - params.ind_left - params.ind_right - params.ind_first_line
888    }
889}
890
891fn compute_subsequent_line_width(params: &LineBreakParams) -> f64 {
892    params.available_width - params.ind_left - params.ind_right
893}
894
895/// Width kept clear at the start of a given line.
896fn line_prefix_width(params: &LineBreakParams, line_index: usize) -> f64 {
897    params
898        .line_prefix_widths
899        .get(line_index)
900        .copied()
901        .unwrap_or(0.0)
902}
903
904/// Width kept clear at the end of a given line.
905fn line_suffix_width(params: &LineBreakParams, line_index: usize) -> f64 {
906    params
907        .line_suffix_widths
908        .get(line_index)
909        .copied()
910        .unwrap_or(0.0)
911}
912
913/// Usable width of a line, once anything floating beside it is taken out.
914fn line_width_at(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 {
915    let base = if is_first_line {
916        compute_first_line_width(params)
917    } else {
918        compute_subsequent_line_width(params)
919    };
920    (base - line_prefix_width(params, line_index) - line_suffix_width(params, line_index)).max(0.0)
921}
922
923/// Where a line starts, once anything floating to its left is taken out.
924fn line_indent_at(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 {
925    let base = if is_first_line {
926        first_line_indent(params)
927    } else {
928        subsequent_line_indent(params)
929    };
930    base + line_prefix_width(params, line_index)
931}
932
933fn first_line_indent(params: &LineBreakParams) -> f64 {
934    if params.ind_hanging > 0.0 {
935        params.ind_left - params.ind_hanging
936    } else {
937        params.ind_left + params.ind_first_line
938    }
939}
940
941fn subsequent_line_indent(params: &LineBreakParams) -> f64 {
942    params.ind_left
943}
944
945/// Compute line height based on spacing rules.
946fn effective_line_gap(ascent: f64, descent: f64, natural_height: f64) -> f64 {
947    (natural_height - ascent - descent).max(0.0)
948}
949
950fn compute_line_height(
951    ascent: f64,
952    descent: f64,
953    line_gap: f64,
954    font_size: f64,
955    params: &LineBreakParams,
956) -> f64 {
957    let natural = ascent + descent + line_gap;
958    let natural = if natural < 1.0 { 12.0 } else { natural }; // minimum for empty lines
959    let font_size = if font_size < 1.0 { 12.0 } else { font_size };
960
961    match params.line_spacing {
962        LineSpacing::Single => natural,
963        LineSpacing::Multiple(factor) => font_size * factor,
964        LineSpacing::Exact(points) => points,
965        LineSpacing::AtLeast(points) => natural.max(points),
966    }
967}
968
969#[cfg(test)]
970mod tests {
971    use super::*;
972
973    fn make_text_segment(text: &str, width: f64) -> TextSegment {
974        TextSegment {
975            text: text.to_string(),
976            source: None,
977            font_id: FontId(0),
978            font_size: 12.0,
979            glyph_ids: vec![],
980            advances: vec![],
981            width,
982            ascent: 10.0,
983            descent: 3.0,
984            line_gap: 0.0,
985            color: Color::BLACK,
986            bold: false,
987            italic: false,
988            underline: None,
989            strike: false,
990            dstrike: false,
991            highlight: None,
992            baseline_offset: 0.0,
993            hyperlink_url: None,
994            field_kind: None,
995            note: None,
996        }
997    }
998
999    fn deterministic_font_manager() -> FontManager {
1000        FontManager::new_deterministic().expect("bundled fonts should load")
1001    }
1002
1003    fn shaped_text_segment(fm: &mut FontManager, text: &str, spacing: f64) -> TextSegment {
1004        let font_id = fm
1005            .resolve_font(Some("Carlito"), false, false)
1006            .expect("bundled Carlito should resolve");
1007        let metrics = fm.metrics(font_id, 30.0).expect("Carlito metrics");
1008        let mut shaped = fm.shape_text(font_id, text, 30.0).expect("shape text");
1009        for advance in &mut shaped.advances {
1010            *advance += spacing;
1011        }
1012        shaped.width += spacing * shaped.advances.len() as f64;
1013        TextSegment {
1014            text: text.to_owned(),
1015            source: None,
1016            font_id,
1017            font_size: 30.0,
1018            glyph_ids: shaped.glyph_ids,
1019            advances: shaped.advances,
1020            width: shaped.width,
1021            ascent: metrics.ascent,
1022            descent: metrics.descent,
1023            line_gap: metrics.line_gap,
1024            color: Color::BLACK,
1025            bold: false,
1026            italic: false,
1027            underline: None,
1028            strike: false,
1029            dstrike: false,
1030            highlight: None,
1031            baseline_offset: 0.0,
1032            hyperlink_url: None,
1033            field_kind: None,
1034            note: None,
1035        }
1036    }
1037
1038    #[test]
1039    fn empty_paragraph_gets_one_line() {
1040        let fm = deterministic_font_manager();
1041        let lines = break_into_lines(&[], &LineBreakParams::default(), &fm).unwrap();
1042        assert_eq!(lines.len(), 1);
1043        assert!(lines[0].is_last);
1044        assert!(lines[0].items.is_empty());
1045    }
1046
1047    #[test]
1048    fn single_word_fits_one_line() {
1049        let fm = deterministic_font_manager();
1050        let items = vec![InlineItem::Text(make_text_segment("Hello", 50.0))];
1051        let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
1052        assert_eq!(lines.len(), 1);
1053        assert!(lines[0].is_last);
1054    }
1055
1056    #[test]
1057    fn words_wrap_to_multiple_lines() {
1058        let fm = deterministic_font_manager();
1059        // Each word is 200pt wide, line is 468pt → should wrap
1060        let mut items = vec![
1061            InlineItem::Text(make_text_segment("Word1", 200.0)),
1062            InlineItem::Text(make_text_segment("Word2", 200.0)),
1063        ];
1064        items.push(InlineItem::Text(make_text_segment("Word3", 200.0)));
1065
1066        let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
1067        assert!(lines.len() >= 2);
1068    }
1069
1070    #[test]
1071    fn ligature_runs_reshape_each_break_chunk_without_duplicate_glyphs() {
1072        let mut fm = deterministic_font_manager();
1073        let text = "by providing opportunities to crawl in cluttered spaces and handle 3-dimensional objects";
1074        let spacing = 0.4;
1075        let segment = shaped_text_segment(&mut fm, text, spacing);
1076        assert_ne!(segment.glyph_ids.len(), text.chars().count());
1077
1078        let lines = break_into_lines(
1079            &[InlineItem::Text(segment)],
1080            &LineBreakParams {
1081                available_width: 260.0,
1082                ..LineBreakParams::default()
1083            },
1084            &fm,
1085        )
1086        .expect("wrap ligature-bearing text");
1087        assert!(lines.len() > 1);
1088
1089        let mut rendered_text = String::new();
1090        for text_segment in lines.iter().flat_map(|line| {
1091            line.items.iter().filter_map(|item| match item {
1092                LineItem::Text(segment) => Some(segment),
1093                _ => None,
1094            })
1095        }) {
1096            rendered_text.push_str(&text_segment.text);
1097            let exact = fm
1098                .shape_text(
1099                    text_segment.font_id,
1100                    &text_segment.text,
1101                    text_segment.font_size,
1102                )
1103                .expect("reshape emitted chunk");
1104            assert_eq!(text_segment.glyph_ids, exact.glyph_ids);
1105            assert_eq!(text_segment.advances.len(), exact.advances.len());
1106            for (actual, unspaced) in text_segment.advances.iter().zip(exact.advances) {
1107                assert!((actual - (unspaced + spacing)).abs() < 1.0e-10);
1108            }
1109        }
1110        assert_eq!(rendered_text, text);
1111    }
1112
1113    #[test]
1114    fn line_splitting_preserves_contiguous_unicode_source_ranges() {
1115        let mut fm = deterministic_font_manager();
1116        let node = crate::SourceNodeId::new(7).expect("a non-zero source id");
1117        let mut segment = shaped_text_segment(&mut fm, "ab 🚀界 cd", 0.0);
1118        segment.source = Some(crate::SourceSpan {
1119            node,
1120            char_start: 11,
1121            char_end: 19,
1122        });
1123
1124        let lines = break_into_lines(
1125            &[InlineItem::Text(segment)],
1126            &LineBreakParams {
1127                available_width: 55.0,
1128                ..LineBreakParams::default()
1129            },
1130            &fm,
1131        )
1132        .expect("split mixed Unicode text");
1133        let sourced = lines
1134            .iter()
1135            .flat_map(|line| &line.items)
1136            .filter_map(|item| match item {
1137                LineItem::Text(segment) => segment.source,
1138                _ => None,
1139            })
1140            .collect::<Vec<_>>();
1141
1142        assert!(sourced.len() > 1, "the fixture must cross a line boundary");
1143        assert_eq!(sourced.first().expect("first range").char_start, 11);
1144        assert_eq!(sourced.last().expect("last range").char_end, 19);
1145        for pair in sourced.windows(2) {
1146            assert_eq!(pair[0].node, node);
1147            assert_eq!(pair[0].char_end, pair[1].char_start);
1148        }
1149    }
1150
1151    #[test]
1152    fn forced_line_break() {
1153        let fm = deterministic_font_manager();
1154        let items = vec![
1155            InlineItem::Text(make_text_segment("Before", 50.0)),
1156            InlineItem::LineBreak,
1157            InlineItem::Text(make_text_segment("After", 50.0)),
1158        ];
1159        let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
1160        assert!(lines.len() >= 2);
1161    }
1162
1163    #[test]
1164    fn line_height_exact() {
1165        let params = LineBreakParams {
1166            line_spacing: LineSpacing::Exact(24.0),
1167            ..Default::default()
1168        };
1169        let h = compute_line_height(10.0, 3.0, 5.0, 12.0, &params);
1170        assert!((h - 24.0).abs() < 0.01);
1171    }
1172
1173    #[test]
1174    fn line_height_auto() {
1175        let params = LineBreakParams {
1176            line_spacing: LineSpacing::Multiple(2.0),
1177            ..Default::default()
1178        };
1179        let h = compute_line_height(10.0, 3.0, 2.0, 15.0, &params);
1180        assert!((h - 30.0).abs() < 0.01); // 15 * 2.0
1181    }
1182
1183    #[test]
1184    fn first_line_indent() {
1185        let params = LineBreakParams {
1186            ind_first_line: 36.0,
1187            ..Default::default()
1188        };
1189        let first_w = compute_first_line_width(&params);
1190        let subseq_w = compute_subsequent_line_width(&params);
1191        assert!(first_w < subseq_w);
1192    }
1193
1194    #[test]
1195    fn hanging_indent() {
1196        let params = LineBreakParams {
1197            ind_left: 36.0,
1198            ind_hanging: 36.0,
1199            ..Default::default()
1200        };
1201        let first_indent = super::first_line_indent(&params);
1202        let subseq_indent = super::subsequent_line_indent(&params);
1203        assert!(first_indent < subseq_indent);
1204    }
1205
1206    #[test]
1207    fn tab_stop_resolution() {
1208        let stops = vec![TabStop {
1209            pos_pt: 72.0,
1210            align: TabAlign::Left,
1211            leader: None,
1212        }];
1213        let (w, leader) = resolve_tab_width(36.0, &stops);
1214        assert!((w - 36.0).abs() < 0.01);
1215        assert!(leader.is_none());
1216    }
1217
1218    #[test]
1219    fn default_tab_stops() {
1220        let (w, _) = resolve_tab_width(10.0, &[]);
1221        assert!((w - 26.0).abs() < 0.01); // next stop at 36pt
1222    }
1223
1224    #[test]
1225    fn tab_stop_with_dot_leader() {
1226        let stops = vec![TabStop {
1227            pos_pt: 400.0,
1228            align: TabAlign::Right,
1229            leader: Some(TabLeader::Dot),
1230        }];
1231        let (w, leader) = resolve_tab_width(100.0, &stops);
1232        assert!((w - 300.0).abs() < 0.01);
1233        assert_eq!(leader, Some('.'));
1234    }
1235
1236    #[test]
1237    fn the_eleven_line_tests_pass_with_owned_types() {
1238        assert!(LineBreakParams::default().wrap);
1239        empty_paragraph_gets_one_line();
1240        single_word_fits_one_line();
1241        words_wrap_to_multiple_lines();
1242        forced_line_break();
1243        line_height_exact();
1244        line_height_auto();
1245        first_line_indent();
1246        hanging_indent();
1247        tab_stop_resolution();
1248        default_tab_stops();
1249        tab_stop_with_dot_leader();
1250    }
1251
1252    #[test]
1253    fn line_spacing_variants_preserve_existing_height_rules() {
1254        let height = |line_spacing| {
1255            compute_line_height(
1256                10.0,
1257                3.0,
1258                2.0,
1259                11.0,
1260                &LineBreakParams {
1261                    line_spacing,
1262                    ..Default::default()
1263                },
1264            )
1265        };
1266
1267        assert!((height(LineSpacing::Single) - 15.0).abs() < 0.01);
1268        assert!((height(LineSpacing::Multiple(1.5)) - 16.5).abs() < 0.01);
1269        assert!((height(LineSpacing::Exact(8.25)) - 8.25).abs() < 0.01);
1270        assert!((height(LineSpacing::AtLeast(8.25)) - 15.0).abs() < 0.01);
1271        assert!((height(LineSpacing::AtLeast(18.5)) - 18.5).abs() < 0.01);
1272    }
1273
1274    #[test]
1275    fn mixed_font_line_uses_tallest_full_natural_advance() {
1276        let fm = deterministic_font_manager();
1277        let mut first = make_text_segment("first", 20.0);
1278        first.ascent = 10.0;
1279        first.descent = 2.0;
1280        first.line_gap = 4.0;
1281        let mut second = make_text_segment("second", 20.0);
1282        second.ascent = 8.0;
1283        second.descent = 5.0;
1284        second.line_gap = 1.0;
1285
1286        let lines = break_into_lines(
1287            &[InlineItem::Text(first), InlineItem::Text(second)],
1288            &LineBreakParams::default(),
1289            &fm,
1290        )
1291        .expect("lay out mixed-font line");
1292
1293        assert_eq!(lines.len(), 1);
1294        assert!((lines[0].ascent - 10.0).abs() < 0.01);
1295        assert!((lines[0].descent - 5.0).abs() < 0.01);
1296        assert!((lines[0].line_gap - 1.0).abs() < 0.01);
1297        assert!((lines[0].height - 16.0).abs() < 0.01);
1298        assert!((lines[0].baseline_offset() - 10.5).abs() < 0.01);
1299    }
1300
1301    #[test]
1302    fn multiple_spacing_uses_largest_text_point_size_on_each_line() {
1303        let fm = deterministic_font_manager();
1304        let mut first = make_text_segment("first", 20.0);
1305        first.font_size = 12.0;
1306        first.line_gap = 4.0;
1307        let mut second = make_text_segment("second", 20.0);
1308        second.font_size = 20.0;
1309        second.line_gap = 1.0;
1310
1311        let lines = break_into_lines(
1312            &[InlineItem::Text(first), InlineItem::Text(second)],
1313            &LineBreakParams {
1314                line_spacing: LineSpacing::Multiple(1.25),
1315                ..LineBreakParams::default()
1316            },
1317            &fm,
1318        )
1319        .expect("lay out percentage-spaced mixed-size line");
1320
1321        assert_eq!(lines.len(), 1);
1322        assert!((lines[0].height - 25.0).abs() < 0.01);
1323    }
1324
1325    #[test]
1326    fn positive_leading_is_split_and_below_natural_exact_spacing_is_not_clamped() {
1327        let positive = LayoutLine {
1328            items: Vec::new(),
1329            width: 0.0,
1330            ascent: 10.0,
1331            descent: 3.0,
1332            line_gap: 5.0,
1333            height: 18.0,
1334            indent_left: 0.0,
1335            available_width: 100.0,
1336            is_last: true,
1337        };
1338        let below_natural = LayoutLine {
1339            height: 8.0,
1340            ..positive.clone()
1341        };
1342
1343        assert!((positive.baseline_offset() - 12.5).abs() < 0.01);
1344        assert!((below_natural.height - 8.0).abs() < 0.01);
1345        assert!((below_natural.baseline_offset() - 10.0).abs() < 0.01);
1346    }
1347
1348    #[test]
1349    fn zero_gap_and_empty_segment_preserve_natural_height_rules() {
1350        let fm = deterministic_font_manager();
1351        let zero_gap = make_text_segment("zero", 20.0);
1352        let mut empty = make_text_segment("", 0.0);
1353        empty.line_gap = 4.0;
1354
1355        let zero_gap_line = break_into_lines(
1356            &[InlineItem::Text(zero_gap)],
1357            &LineBreakParams::default(),
1358            &fm,
1359        )
1360        .expect("lay out zero-gap line");
1361        let empty_line =
1362            break_into_lines(&[InlineItem::Text(empty)], &LineBreakParams::default(), &fm)
1363                .expect("lay out styled empty line");
1364
1365        assert!((zero_gap_line[0].height - 13.0).abs() < 0.01);
1366        assert!((empty_line[0].height - 17.0).abs() < 0.01);
1367    }
1368
1369    #[test]
1370    fn wrap_false_only_breaks_on_an_explicit_break() {
1371        let fm = deterministic_font_manager();
1372        let params = LineBreakParams {
1373            available_width: 100.0,
1374            wrap: false,
1375            ..Default::default()
1376        };
1377
1378        for forced_break in [
1379            InlineItem::LineBreak,
1380            InlineItem::PageBreak,
1381            InlineItem::ColumnBreak,
1382        ] {
1383            let items = vec![
1384                InlineItem::Text(make_text_segment("one", 80.0)),
1385                InlineItem::Text(make_text_segment("two", 80.0)),
1386                forced_break,
1387                InlineItem::Text(make_text_segment("three", 80.0)),
1388                InlineItem::Text(make_text_segment("four", 80.0)),
1389            ];
1390            let lines = break_into_lines(&items, &params, &fm).unwrap();
1391
1392            assert_eq!(lines.len(), 2);
1393            assert!((lines[0].width - 160.0).abs() < 0.01);
1394            assert!((lines[1].width - 160.0).abs() < 0.01);
1395        }
1396    }
1397
1398    #[test]
1399    fn tab_stops_use_point_positions_and_owned_leaders() {
1400        let mut fm = deterministic_font_manager();
1401        let font_id = fm
1402            .resolve_font(Some("Carlito"), false, false)
1403            .expect("bundled Carlito should resolve");
1404        let stop = TabStop {
1405            pos_pt: 72.25,
1406            align: TabAlign::Decimal,
1407            leader: Some(TabLeader::Dot),
1408        };
1409
1410        let item = inline_to_line_item(&InlineItem::Tab, 12.0, &[stop], &fm, Some((font_id, 12.0)));
1411
1412        let LineItem::Tab {
1413            width,
1414            leader: Some(leader),
1415        } = item
1416        else {
1417            panic!("owned dot leader should shape into a tab line item");
1418        };
1419        assert!((width - 60.25).abs() < 0.01);
1420        assert!((leader.width - 60.25).abs() < 0.01);
1421        assert!(!leader.glyph_ids.is_empty());
1422        assert!(leader.text.chars().all(|ch| ch == '.'));
1423    }
1424
1425    #[test]
1426    fn staged_image_types_use_media_id_instead_of_embed_id() {
1427        let media_id = crate::MediaId::from_bytes(b"image");
1428        let item = inline_to_line_item(
1429            &InlineItem::Image {
1430                width: 10.0,
1431                height: 20.0,
1432                media_id,
1433            },
1434            0.0,
1435            &[],
1436            &deterministic_font_manager(),
1437            None,
1438        );
1439        let LineItem::Image {
1440            media_id: actual, ..
1441        } = item
1442        else {
1443            panic!("image should remain an image");
1444        };
1445        assert_eq!(actual, media_id);
1446    }
1447
1448    #[test]
1449    fn group_inline_item_breaks_and_positions_like_an_image() {
1450        use crate::{GroupElement, PositionedElement, Transform};
1451
1452        let group = GroupElement {
1453            transform: Transform::IDENTITY,
1454            clip: None,
1455            opacity: 1.0,
1456            effects: Vec::new(),
1457            children: vec![PositionedElement::FilledRect {
1458                rect: crate::Rect {
1459                    x: 2.0,
1460                    y: 3.0,
1461                    width: 4.0,
1462                    height: 5.0,
1463                },
1464                color: crate::Color::BLACK,
1465            }],
1466        };
1467        let items = vec![InlineItem::Group {
1468            width: 80.0,
1469            height: 40.0,
1470            group: group.clone(),
1471        }];
1472        let lines = break_into_lines(
1473            &items,
1474            &LineBreakParams {
1475                available_width: 80.0,
1476                ..Default::default()
1477            },
1478            &deterministic_font_manager(),
1479        )
1480        .expect("group line breaking");
1481
1482        assert_eq!(lines.len(), 1);
1483        assert_eq!(lines[0].width, 80.0);
1484        assert_eq!(lines[0].height, 40.0);
1485        let LineItem::Group {
1486            width,
1487            height,
1488            group: actual,
1489            ..
1490        } = &lines[0].items[0]
1491        else {
1492            panic!("inline group should remain a group line item");
1493        };
1494        assert_eq!((*width, *height), (80.0, 40.0));
1495        assert_eq!(actual, &group);
1496    }
1497}