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
525                for tb in &breaks {
526                    let chunk = &seg.text[tb.start..tb.end];
527                    if chunk.is_empty() {
528                        continue;
529                    }
530
531                    // If this chunk starts with whitespace, treat as a break opportunity
532                    if !current_group.is_empty() && chunk.starts_with(|c: char| c.is_whitespace()) {
533                        segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
534                    }
535
536                    // Create a sub-segment for just this chunk (not the entire text)
537                    let sub_item = split_text_subsegment(seg, tb.start, tb.end, fm)?;
538                    current_group.push(sub_item);
539
540                    if tb.is_break {
541                        segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
542                    }
543                }
544
545                // Flush any remaining
546                if !current_group.is_empty() {
547                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
548                }
549            }
550            InlineItem::Marker(_)
551            | InlineItem::Image { .. }
552            | InlineItem::Group { .. }
553            | InlineItem::Figure { .. } => {
554                current_group.push(item.clone());
555            }
556        }
557    }
558
559    if !current_group.is_empty() {
560        segments.push(BreakableSegment::Items(current_group));
561    }
562
563    Ok(segments)
564}
565
566/// Create a sub-segment InlineItem from a byte range within a TextSegment.
567///
568/// Reshapes the selected text and preserves formatting from the parent segment.
569fn split_text_subsegment(
570    seg: &TextSegment,
571    byte_start: usize,
572    byte_end: usize,
573    fm: &FontManager,
574) -> Result<InlineItem> {
575    // If this is the full segment, just clone it
576    if byte_start == 0 && byte_end == seg.text.len() {
577        return Ok(InlineItem::Text(seg.clone()));
578    }
579
580    let sub_text = seg.text[byte_start..byte_end].to_string();
581    let mut shaped = fm.shape_text(seg.font_id, &sub_text, seg.font_size)?;
582    let original = fm.shape_text(seg.font_id, &seg.text, seg.font_size)?;
583    let spacing = if original.advances.len() == seg.advances.len() && !original.advances.is_empty()
584    {
585        (seg.width - original.width) / original.advances.len() as f64
586    } else {
587        0.0
588    };
589    for advance in &mut shaped.advances {
590        *advance += spacing;
591    }
592    shaped.width += spacing * shaped.advances.len() as f64;
593
594    let source = seg.source.map(|source| {
595        let start = seg.text[..byte_start].chars().count() as u32;
596        let end = seg.text[..byte_end].chars().count() as u32;
597        SourceSpan {
598            node: source.node,
599            char_start: source.char_start + start,
600            char_end: source.char_start + end,
601        }
602    });
603
604    Ok(InlineItem::Text(TextSegment {
605        text: sub_text,
606        source,
607        font_id: seg.font_id,
608        font_size: seg.font_size,
609        glyph_ids: shaped.glyph_ids,
610        advances: shaped.advances,
611        width: shaped.width,
612        ascent: seg.ascent,
613        descent: seg.descent,
614        line_gap: seg.line_gap,
615        color: seg.color,
616        bold: seg.bold,
617        italic: seg.italic,
618        underline: seg.underline,
619        strike: seg.strike,
620        dstrike: seg.dstrike,
621        highlight: seg.highlight,
622        baseline_offset: seg.baseline_offset,
623        hyperlink_url: seg.hyperlink_url.clone(),
624        field_kind: seg.field_kind,
625        note: seg.note,
626    }))
627}
628
629struct TextBreakInfo {
630    /// Byte range within the original text.
631    start: usize,
632    end: usize,
633    /// Whether a line break is allowed after this segment.
634    is_break: bool,
635}
636
637fn split_text_at_break_opportunities(seg: &TextSegment) -> Vec<TextBreakInfo> {
638    use unicode_linebreak::{BreakOpportunity, linebreaks};
639
640    let text = &seg.text;
641    if text.is_empty() {
642        return vec![];
643    }
644
645    let mut breaks = Vec::new();
646    let mut last_start = 0;
647
648    for (byte_pos, opportunity) in linebreaks(text) {
649        if byte_pos == 0 {
650            continue;
651        }
652
653        let is_break = matches!(
654            opportunity,
655            BreakOpportunity::Allowed | BreakOpportunity::Mandatory
656        );
657
658        breaks.push(TextBreakInfo {
659            start: last_start,
660            end: byte_pos,
661            is_break,
662        });
663        last_start = byte_pos;
664    }
665
666    // If unicode-linebreak didn't produce any breaks, treat as one chunk
667    if breaks.is_empty() {
668        breaks.push(TextBreakInfo {
669            start: 0,
670            end: text.len(),
671            is_break: true,
672        });
673    }
674
675    breaks
676}
677
678fn inline_item_width(item: &InlineItem) -> f64 {
679    match item {
680        InlineItem::Text(seg) => seg.width,
681        InlineItem::Tab => 36.0, // Default tab width, will be resolved
682        InlineItem::Image { width, .. } => *width,
683        InlineItem::Group { width, .. } => *width,
684        InlineItem::Figure { item, .. } => inline_item_width(item),
685        InlineItem::Marker(seg) => seg.width,
686        InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => 0.0,
687    }
688}
689
690fn item_metrics(item: &InlineItem) -> (f64, f64, f64, f64, f64) {
691    // Returns (width, ascent, descent, natural height, text font size)
692    match item {
693        InlineItem::Text(seg) => (
694            seg.width,
695            seg.ascent,
696            seg.descent,
697            seg.ascent + seg.descent + seg.line_gap,
698            seg.font_size,
699        ),
700        InlineItem::Marker(seg) => (
701            seg.width,
702            seg.ascent,
703            seg.descent,
704            seg.ascent + seg.descent + seg.line_gap,
705            0.0,
706        ),
707        InlineItem::Tab => (36.0, 0.0, 0.0, 0.0, 0.0),
708        InlineItem::Image { width, height, .. } => (*width, *height, 0.0, *height, 0.0),
709        InlineItem::Group { width, height, .. } => (*width, *height, 0.0, *height, 0.0),
710        InlineItem::Figure { item, .. } => item_metrics(item),
711        InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => {
712            (0.0, 0.0, 0.0, 0.0, 0.0)
713        }
714    }
715}
716
717fn inline_to_line_item(
718    item: &InlineItem,
719    current_x: f64,
720    tab_stops: &[TabStop],
721    fm: &FontManager,
722    font_ctx: Option<(FontId, f64)>,
723) -> LineItem {
724    match item {
725        InlineItem::Text(seg) => LineItem::Text(seg.clone()),
726        InlineItem::Marker(seg) => LineItem::Marker(seg.clone()),
727        InlineItem::Tab => {
728            let (tab_width, leader_char) = resolve_tab_width(current_x, tab_stops);
729            let leader = leader_char.and_then(|ch| shape_leader(fm, font_ctx, ch, tab_width));
730            LineItem::Tab {
731                width: tab_width,
732                leader,
733            }
734        }
735        InlineItem::Image {
736            width,
737            height,
738            media_id,
739        } => LineItem::Image {
740            width: *width,
741            height: *height,
742            media_id: *media_id,
743        },
744        InlineItem::Group {
745            width,
746            height,
747            group,
748        } => LineItem::Group {
749            width: *width,
750            height: *height,
751            group: group.clone(),
752        },
753        InlineItem::Figure {
754            item,
755            alternate_text,
756            structure_id,
757        } => LineItem::Figure {
758            item: Box::new(inline_to_line_item(
759                item, current_x, tab_stops, fm, font_ctx,
760            )),
761            alternate_text: alternate_text.clone(),
762            structure_id: *structure_id,
763        },
764        InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => LineItem::Tab {
765            width: 0.0,
766            leader: None,
767        },
768    }
769}
770
771/// Shape a leader character repeated to fill the given width.
772fn shape_leader(
773    fm: &FontManager,
774    font_ctx: Option<(FontId, f64)>,
775    leader_char: char,
776    tab_width: f64,
777) -> Option<TextSegment> {
778    let (font_id, font_size) = font_ctx?;
779    if tab_width < 1.0 {
780        return None;
781    }
782
783    // Shape a single leader character to get its advance width
784    let single = String::from(leader_char);
785    let shaped = fm.shape_text(font_id, &single, font_size).ok()?;
786    if shaped.glyph_ids.is_empty() {
787        return None;
788    }
789    let char_advance = shaped.advances[0];
790    if char_advance < 0.5 {
791        return None;
792    }
793
794    // Add a small gap between leader chars (about 50% of char width for dots, less for others)
795    let spacing = match leader_char {
796        '.' | '\u{00B7}' => char_advance * 0.5,
797        _ => char_advance * 0.15,
798    };
799    let step = char_advance + spacing;
800    let count = ((tab_width - spacing) / step).floor() as usize;
801    if count == 0 {
802        return None;
803    }
804
805    // Build the repeated leader text and glyph arrays
806    let leader_text: String = std::iter::repeat_n(leader_char, count).collect();
807    let mut glyph_ids = Vec::with_capacity(count);
808    let mut advances = Vec::with_capacity(count);
809    for i in 0..count {
810        glyph_ids.push(shaped.glyph_ids[0]);
811        if i + 1 < count {
812            advances.push(char_advance + spacing);
813        } else {
814            advances.push(char_advance);
815        }
816    }
817
818    let metrics = fm.metrics(font_id, font_size).ok()?;
819
820    Some(TextSegment {
821        text: leader_text,
822        source: None,
823        font_id,
824        font_size,
825        glyph_ids,
826        advances,
827        width: tab_width, // fill the entire tab gap
828        ascent: metrics.ascent,
829        descent: metrics.descent,
830        line_gap: metrics.line_gap,
831        color: Color::BLACK,
832        bold: false,
833        italic: false,
834        underline: None,
835        strike: false,
836        dstrike: false,
837        highlight: None,
838        baseline_offset: 0.0,
839        hyperlink_url: None,
840        field_kind: None,
841        note: None,
842    })
843}
844
845/// Resolve tab stop width and leader character based on current x position and defined stops.
846fn resolve_tab_width(current_x: f64, tab_stops: &[TabStop]) -> (f64, Option<char>) {
847    // Find the next tab stop after the current position
848    for stop in tab_stops {
849        let stop_pos = stop.pos_pt;
850        if stop_pos > current_x {
851            let width = match stop.align {
852                TabAlign::Left => stop_pos - current_x,
853                TabAlign::Center => (stop_pos - current_x).max(0.0),
854                TabAlign::Right => (stop_pos - current_x).max(0.0),
855                _ => stop_pos - current_x,
856            };
857            let leader = stop.leader.and_then(|l| match l {
858                TabLeader::Dot => Some('.'),
859                TabLeader::Hyphen => Some('-'),
860                TabLeader::Underscore => Some('_'),
861                TabLeader::MiddleDot => Some('\u{00B7}'),
862                TabLeader::Heavy => Some('_'),
863                TabLeader::None => None,
864            });
865            return (width, leader);
866        }
867    }
868    // Default tab stops every 0.5 inches (36pt)
869    let default_interval = 36.0;
870    let next_stop = ((current_x / default_interval).floor() + 1.0) * default_interval;
871    (next_stop - current_x, None)
872}
873
874fn compute_first_line_width(params: &LineBreakParams) -> f64 {
875    if params.ind_hanging > 0.0 {
876        // Hanging indent: first line has MORE width (extends left)
877        params.available_width - params.ind_left - params.ind_right + params.ind_hanging
878    } else {
879        params.available_width - params.ind_left - params.ind_right - params.ind_first_line
880    }
881}
882
883fn compute_subsequent_line_width(params: &LineBreakParams) -> f64 {
884    params.available_width - params.ind_left - params.ind_right
885}
886
887/// Width kept clear at the start of a given line.
888fn line_prefix_width(params: &LineBreakParams, line_index: usize) -> f64 {
889    params
890        .line_prefix_widths
891        .get(line_index)
892        .copied()
893        .unwrap_or(0.0)
894}
895
896/// Width kept clear at the end of a given line.
897fn line_suffix_width(params: &LineBreakParams, line_index: usize) -> f64 {
898    params
899        .line_suffix_widths
900        .get(line_index)
901        .copied()
902        .unwrap_or(0.0)
903}
904
905/// Usable width of a line, once anything floating beside it is taken out.
906fn line_width_at(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 {
907    let base = if is_first_line {
908        compute_first_line_width(params)
909    } else {
910        compute_subsequent_line_width(params)
911    };
912    (base - line_prefix_width(params, line_index) - line_suffix_width(params, line_index)).max(0.0)
913}
914
915/// Where a line starts, once anything floating to its left is taken out.
916fn line_indent_at(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 {
917    let base = if is_first_line {
918        first_line_indent(params)
919    } else {
920        subsequent_line_indent(params)
921    };
922    base + line_prefix_width(params, line_index)
923}
924
925fn first_line_indent(params: &LineBreakParams) -> f64 {
926    if params.ind_hanging > 0.0 {
927        params.ind_left - params.ind_hanging
928    } else {
929        params.ind_left + params.ind_first_line
930    }
931}
932
933fn subsequent_line_indent(params: &LineBreakParams) -> f64 {
934    params.ind_left
935}
936
937/// Compute line height based on spacing rules.
938fn effective_line_gap(ascent: f64, descent: f64, natural_height: f64) -> f64 {
939    (natural_height - ascent - descent).max(0.0)
940}
941
942fn compute_line_height(
943    ascent: f64,
944    descent: f64,
945    line_gap: f64,
946    font_size: f64,
947    params: &LineBreakParams,
948) -> f64 {
949    let natural = ascent + descent + line_gap;
950    let natural = if natural < 1.0 { 12.0 } else { natural }; // minimum for empty lines
951    let font_size = if font_size < 1.0 { 12.0 } else { font_size };
952
953    match params.line_spacing {
954        LineSpacing::Single => natural,
955        LineSpacing::Multiple(factor) => font_size * factor,
956        LineSpacing::Exact(points) => points,
957        LineSpacing::AtLeast(points) => natural.max(points),
958    }
959}
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964
965    fn make_text_segment(text: &str, width: f64) -> TextSegment {
966        TextSegment {
967            text: text.to_string(),
968            source: None,
969            font_id: FontId(0),
970            font_size: 12.0,
971            glyph_ids: vec![],
972            advances: vec![],
973            width,
974            ascent: 10.0,
975            descent: 3.0,
976            line_gap: 0.0,
977            color: Color::BLACK,
978            bold: false,
979            italic: false,
980            underline: None,
981            strike: false,
982            dstrike: false,
983            highlight: None,
984            baseline_offset: 0.0,
985            hyperlink_url: None,
986            field_kind: None,
987            note: None,
988        }
989    }
990
991    fn deterministic_font_manager() -> FontManager {
992        FontManager::new_deterministic().expect("bundled fonts should load")
993    }
994
995    fn shaped_text_segment(fm: &mut FontManager, text: &str, spacing: f64) -> TextSegment {
996        let font_id = fm
997            .resolve_font(Some("Carlito"), false, false)
998            .expect("bundled Carlito should resolve");
999        let metrics = fm.metrics(font_id, 30.0).expect("Carlito metrics");
1000        let mut shaped = fm.shape_text(font_id, text, 30.0).expect("shape text");
1001        for advance in &mut shaped.advances {
1002            *advance += spacing;
1003        }
1004        shaped.width += spacing * shaped.advances.len() as f64;
1005        TextSegment {
1006            text: text.to_owned(),
1007            source: None,
1008            font_id,
1009            font_size: 30.0,
1010            glyph_ids: shaped.glyph_ids,
1011            advances: shaped.advances,
1012            width: shaped.width,
1013            ascent: metrics.ascent,
1014            descent: metrics.descent,
1015            line_gap: metrics.line_gap,
1016            color: Color::BLACK,
1017            bold: false,
1018            italic: false,
1019            underline: None,
1020            strike: false,
1021            dstrike: false,
1022            highlight: None,
1023            baseline_offset: 0.0,
1024            hyperlink_url: None,
1025            field_kind: None,
1026            note: None,
1027        }
1028    }
1029
1030    #[test]
1031    fn empty_paragraph_gets_one_line() {
1032        let fm = deterministic_font_manager();
1033        let lines = break_into_lines(&[], &LineBreakParams::default(), &fm).unwrap();
1034        assert_eq!(lines.len(), 1);
1035        assert!(lines[0].is_last);
1036        assert!(lines[0].items.is_empty());
1037    }
1038
1039    #[test]
1040    fn single_word_fits_one_line() {
1041        let fm = deterministic_font_manager();
1042        let items = vec![InlineItem::Text(make_text_segment("Hello", 50.0))];
1043        let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
1044        assert_eq!(lines.len(), 1);
1045        assert!(lines[0].is_last);
1046    }
1047
1048    #[test]
1049    fn words_wrap_to_multiple_lines() {
1050        let fm = deterministic_font_manager();
1051        // Each word is 200pt wide, line is 468pt → should wrap
1052        let mut items = vec![
1053            InlineItem::Text(make_text_segment("Word1", 200.0)),
1054            InlineItem::Text(make_text_segment("Word2", 200.0)),
1055        ];
1056        items.push(InlineItem::Text(make_text_segment("Word3", 200.0)));
1057
1058        let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
1059        assert!(lines.len() >= 2);
1060    }
1061
1062    #[test]
1063    fn ligature_runs_reshape_each_break_chunk_without_duplicate_glyphs() {
1064        let mut fm = deterministic_font_manager();
1065        let text = "by providing opportunities to crawl in cluttered spaces and handle 3-dimensional objects";
1066        let spacing = 0.4;
1067        let segment = shaped_text_segment(&mut fm, text, spacing);
1068        assert_ne!(segment.glyph_ids.len(), text.chars().count());
1069
1070        let lines = break_into_lines(
1071            &[InlineItem::Text(segment)],
1072            &LineBreakParams {
1073                available_width: 260.0,
1074                ..LineBreakParams::default()
1075            },
1076            &fm,
1077        )
1078        .expect("wrap ligature-bearing text");
1079        assert!(lines.len() > 1);
1080
1081        let mut rendered_text = String::new();
1082        for text_segment in lines.iter().flat_map(|line| {
1083            line.items.iter().filter_map(|item| match item {
1084                LineItem::Text(segment) => Some(segment),
1085                _ => None,
1086            })
1087        }) {
1088            rendered_text.push_str(&text_segment.text);
1089            let exact = fm
1090                .shape_text(
1091                    text_segment.font_id,
1092                    &text_segment.text,
1093                    text_segment.font_size,
1094                )
1095                .expect("reshape emitted chunk");
1096            assert_eq!(text_segment.glyph_ids, exact.glyph_ids);
1097            assert_eq!(text_segment.advances.len(), exact.advances.len());
1098            for (actual, unspaced) in text_segment.advances.iter().zip(exact.advances) {
1099                assert!((actual - (unspaced + spacing)).abs() < 1.0e-10);
1100            }
1101        }
1102        assert_eq!(rendered_text, text);
1103    }
1104
1105    #[test]
1106    fn line_splitting_preserves_contiguous_unicode_source_ranges() {
1107        let mut fm = deterministic_font_manager();
1108        let node = crate::SourceNodeId::new(7).expect("a non-zero source id");
1109        let mut segment = shaped_text_segment(&mut fm, "ab 🚀界 cd", 0.0);
1110        segment.source = Some(crate::SourceSpan {
1111            node,
1112            char_start: 11,
1113            char_end: 19,
1114        });
1115
1116        let lines = break_into_lines(
1117            &[InlineItem::Text(segment)],
1118            &LineBreakParams {
1119                available_width: 55.0,
1120                ..LineBreakParams::default()
1121            },
1122            &fm,
1123        )
1124        .expect("split mixed Unicode text");
1125        let sourced = lines
1126            .iter()
1127            .flat_map(|line| &line.items)
1128            .filter_map(|item| match item {
1129                LineItem::Text(segment) => segment.source,
1130                _ => None,
1131            })
1132            .collect::<Vec<_>>();
1133
1134        assert!(sourced.len() > 1, "the fixture must cross a line boundary");
1135        assert_eq!(sourced.first().expect("first range").char_start, 11);
1136        assert_eq!(sourced.last().expect("last range").char_end, 19);
1137        for pair in sourced.windows(2) {
1138            assert_eq!(pair[0].node, node);
1139            assert_eq!(pair[0].char_end, pair[1].char_start);
1140        }
1141    }
1142
1143    #[test]
1144    fn forced_line_break() {
1145        let fm = deterministic_font_manager();
1146        let items = vec![
1147            InlineItem::Text(make_text_segment("Before", 50.0)),
1148            InlineItem::LineBreak,
1149            InlineItem::Text(make_text_segment("After", 50.0)),
1150        ];
1151        let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
1152        assert!(lines.len() >= 2);
1153    }
1154
1155    #[test]
1156    fn line_height_exact() {
1157        let params = LineBreakParams {
1158            line_spacing: LineSpacing::Exact(24.0),
1159            ..Default::default()
1160        };
1161        let h = compute_line_height(10.0, 3.0, 5.0, 12.0, &params);
1162        assert!((h - 24.0).abs() < 0.01);
1163    }
1164
1165    #[test]
1166    fn line_height_auto() {
1167        let params = LineBreakParams {
1168            line_spacing: LineSpacing::Multiple(2.0),
1169            ..Default::default()
1170        };
1171        let h = compute_line_height(10.0, 3.0, 2.0, 15.0, &params);
1172        assert!((h - 30.0).abs() < 0.01); // 15 * 2.0
1173    }
1174
1175    #[test]
1176    fn first_line_indent() {
1177        let params = LineBreakParams {
1178            ind_first_line: 36.0,
1179            ..Default::default()
1180        };
1181        let first_w = compute_first_line_width(&params);
1182        let subseq_w = compute_subsequent_line_width(&params);
1183        assert!(first_w < subseq_w);
1184    }
1185
1186    #[test]
1187    fn hanging_indent() {
1188        let params = LineBreakParams {
1189            ind_left: 36.0,
1190            ind_hanging: 36.0,
1191            ..Default::default()
1192        };
1193        let first_indent = super::first_line_indent(&params);
1194        let subseq_indent = super::subsequent_line_indent(&params);
1195        assert!(first_indent < subseq_indent);
1196    }
1197
1198    #[test]
1199    fn tab_stop_resolution() {
1200        let stops = vec![TabStop {
1201            pos_pt: 72.0,
1202            align: TabAlign::Left,
1203            leader: None,
1204        }];
1205        let (w, leader) = resolve_tab_width(36.0, &stops);
1206        assert!((w - 36.0).abs() < 0.01);
1207        assert!(leader.is_none());
1208    }
1209
1210    #[test]
1211    fn default_tab_stops() {
1212        let (w, _) = resolve_tab_width(10.0, &[]);
1213        assert!((w - 26.0).abs() < 0.01); // next stop at 36pt
1214    }
1215
1216    #[test]
1217    fn tab_stop_with_dot_leader() {
1218        let stops = vec![TabStop {
1219            pos_pt: 400.0,
1220            align: TabAlign::Right,
1221            leader: Some(TabLeader::Dot),
1222        }];
1223        let (w, leader) = resolve_tab_width(100.0, &stops);
1224        assert!((w - 300.0).abs() < 0.01);
1225        assert_eq!(leader, Some('.'));
1226    }
1227
1228    #[test]
1229    fn the_eleven_line_tests_pass_with_owned_types() {
1230        assert!(LineBreakParams::default().wrap);
1231        empty_paragraph_gets_one_line();
1232        single_word_fits_one_line();
1233        words_wrap_to_multiple_lines();
1234        forced_line_break();
1235        line_height_exact();
1236        line_height_auto();
1237        first_line_indent();
1238        hanging_indent();
1239        tab_stop_resolution();
1240        default_tab_stops();
1241        tab_stop_with_dot_leader();
1242    }
1243
1244    #[test]
1245    fn line_spacing_variants_preserve_existing_height_rules() {
1246        let height = |line_spacing| {
1247            compute_line_height(
1248                10.0,
1249                3.0,
1250                2.0,
1251                11.0,
1252                &LineBreakParams {
1253                    line_spacing,
1254                    ..Default::default()
1255                },
1256            )
1257        };
1258
1259        assert!((height(LineSpacing::Single) - 15.0).abs() < 0.01);
1260        assert!((height(LineSpacing::Multiple(1.5)) - 16.5).abs() < 0.01);
1261        assert!((height(LineSpacing::Exact(8.25)) - 8.25).abs() < 0.01);
1262        assert!((height(LineSpacing::AtLeast(8.25)) - 15.0).abs() < 0.01);
1263        assert!((height(LineSpacing::AtLeast(18.5)) - 18.5).abs() < 0.01);
1264    }
1265
1266    #[test]
1267    fn mixed_font_line_uses_tallest_full_natural_advance() {
1268        let fm = deterministic_font_manager();
1269        let mut first = make_text_segment("first", 20.0);
1270        first.ascent = 10.0;
1271        first.descent = 2.0;
1272        first.line_gap = 4.0;
1273        let mut second = make_text_segment("second", 20.0);
1274        second.ascent = 8.0;
1275        second.descent = 5.0;
1276        second.line_gap = 1.0;
1277
1278        let lines = break_into_lines(
1279            &[InlineItem::Text(first), InlineItem::Text(second)],
1280            &LineBreakParams::default(),
1281            &fm,
1282        )
1283        .expect("lay out mixed-font line");
1284
1285        assert_eq!(lines.len(), 1);
1286        assert!((lines[0].ascent - 10.0).abs() < 0.01);
1287        assert!((lines[0].descent - 5.0).abs() < 0.01);
1288        assert!((lines[0].line_gap - 1.0).abs() < 0.01);
1289        assert!((lines[0].height - 16.0).abs() < 0.01);
1290        assert!((lines[0].baseline_offset() - 10.5).abs() < 0.01);
1291    }
1292
1293    #[test]
1294    fn multiple_spacing_uses_largest_text_point_size_on_each_line() {
1295        let fm = deterministic_font_manager();
1296        let mut first = make_text_segment("first", 20.0);
1297        first.font_size = 12.0;
1298        first.line_gap = 4.0;
1299        let mut second = make_text_segment("second", 20.0);
1300        second.font_size = 20.0;
1301        second.line_gap = 1.0;
1302
1303        let lines = break_into_lines(
1304            &[InlineItem::Text(first), InlineItem::Text(second)],
1305            &LineBreakParams {
1306                line_spacing: LineSpacing::Multiple(1.25),
1307                ..LineBreakParams::default()
1308            },
1309            &fm,
1310        )
1311        .expect("lay out percentage-spaced mixed-size line");
1312
1313        assert_eq!(lines.len(), 1);
1314        assert!((lines[0].height - 25.0).abs() < 0.01);
1315    }
1316
1317    #[test]
1318    fn positive_leading_is_split_and_below_natural_exact_spacing_is_not_clamped() {
1319        let positive = LayoutLine {
1320            items: Vec::new(),
1321            width: 0.0,
1322            ascent: 10.0,
1323            descent: 3.0,
1324            line_gap: 5.0,
1325            height: 18.0,
1326            indent_left: 0.0,
1327            available_width: 100.0,
1328            is_last: true,
1329        };
1330        let below_natural = LayoutLine {
1331            height: 8.0,
1332            ..positive.clone()
1333        };
1334
1335        assert!((positive.baseline_offset() - 12.5).abs() < 0.01);
1336        assert!((below_natural.height - 8.0).abs() < 0.01);
1337        assert!((below_natural.baseline_offset() - 10.0).abs() < 0.01);
1338    }
1339
1340    #[test]
1341    fn zero_gap_and_empty_segment_preserve_natural_height_rules() {
1342        let fm = deterministic_font_manager();
1343        let zero_gap = make_text_segment("zero", 20.0);
1344        let mut empty = make_text_segment("", 0.0);
1345        empty.line_gap = 4.0;
1346
1347        let zero_gap_line = break_into_lines(
1348            &[InlineItem::Text(zero_gap)],
1349            &LineBreakParams::default(),
1350            &fm,
1351        )
1352        .expect("lay out zero-gap line");
1353        let empty_line =
1354            break_into_lines(&[InlineItem::Text(empty)], &LineBreakParams::default(), &fm)
1355                .expect("lay out styled empty line");
1356
1357        assert!((zero_gap_line[0].height - 13.0).abs() < 0.01);
1358        assert!((empty_line[0].height - 17.0).abs() < 0.01);
1359    }
1360
1361    #[test]
1362    fn wrap_false_only_breaks_on_an_explicit_break() {
1363        let fm = deterministic_font_manager();
1364        let params = LineBreakParams {
1365            available_width: 100.0,
1366            wrap: false,
1367            ..Default::default()
1368        };
1369
1370        for forced_break in [
1371            InlineItem::LineBreak,
1372            InlineItem::PageBreak,
1373            InlineItem::ColumnBreak,
1374        ] {
1375            let items = vec![
1376                InlineItem::Text(make_text_segment("one", 80.0)),
1377                InlineItem::Text(make_text_segment("two", 80.0)),
1378                forced_break,
1379                InlineItem::Text(make_text_segment("three", 80.0)),
1380                InlineItem::Text(make_text_segment("four", 80.0)),
1381            ];
1382            let lines = break_into_lines(&items, &params, &fm).unwrap();
1383
1384            assert_eq!(lines.len(), 2);
1385            assert!((lines[0].width - 160.0).abs() < 0.01);
1386            assert!((lines[1].width - 160.0).abs() < 0.01);
1387        }
1388    }
1389
1390    #[test]
1391    fn tab_stops_use_point_positions_and_owned_leaders() {
1392        let mut fm = deterministic_font_manager();
1393        let font_id = fm
1394            .resolve_font(Some("Carlito"), false, false)
1395            .expect("bundled Carlito should resolve");
1396        let stop = TabStop {
1397            pos_pt: 72.25,
1398            align: TabAlign::Decimal,
1399            leader: Some(TabLeader::Dot),
1400        };
1401
1402        let item = inline_to_line_item(&InlineItem::Tab, 12.0, &[stop], &fm, Some((font_id, 12.0)));
1403
1404        let LineItem::Tab {
1405            width,
1406            leader: Some(leader),
1407        } = item
1408        else {
1409            panic!("owned dot leader should shape into a tab line item");
1410        };
1411        assert!((width - 60.25).abs() < 0.01);
1412        assert!((leader.width - 60.25).abs() < 0.01);
1413        assert!(!leader.glyph_ids.is_empty());
1414        assert!(leader.text.chars().all(|ch| ch == '.'));
1415    }
1416
1417    #[test]
1418    fn staged_image_types_use_media_id_instead_of_embed_id() {
1419        let media_id = crate::MediaId::from_bytes(b"image");
1420        let item = inline_to_line_item(
1421            &InlineItem::Image {
1422                width: 10.0,
1423                height: 20.0,
1424                media_id,
1425            },
1426            0.0,
1427            &[],
1428            &deterministic_font_manager(),
1429            None,
1430        );
1431        let LineItem::Image {
1432            media_id: actual, ..
1433        } = item
1434        else {
1435            panic!("image should remain an image");
1436        };
1437        assert_eq!(actual, media_id);
1438    }
1439
1440    #[test]
1441    fn group_inline_item_breaks_and_positions_like_an_image() {
1442        use crate::{GroupElement, PositionedElement, Transform};
1443
1444        let group = GroupElement {
1445            transform: Transform::IDENTITY,
1446            clip: None,
1447            opacity: 1.0,
1448            effects: Vec::new(),
1449            children: vec![PositionedElement::FilledRect {
1450                rect: crate::Rect {
1451                    x: 2.0,
1452                    y: 3.0,
1453                    width: 4.0,
1454                    height: 5.0,
1455                },
1456                color: crate::Color::BLACK,
1457            }],
1458        };
1459        let items = vec![InlineItem::Group {
1460            width: 80.0,
1461            height: 40.0,
1462            group: group.clone(),
1463        }];
1464        let lines = break_into_lines(
1465            &items,
1466            &LineBreakParams {
1467                available_width: 80.0,
1468                ..Default::default()
1469            },
1470            &deterministic_font_manager(),
1471        )
1472        .expect("group line breaking");
1473
1474        assert_eq!(lines.len(), 1);
1475        assert_eq!(lines[0].width, 80.0);
1476        assert_eq!(lines[0].height, 40.0);
1477        let LineItem::Group {
1478            width,
1479            height,
1480            group: actual,
1481            ..
1482        } = &lines[0].items[0]
1483        else {
1484            panic!("inline group should remain a group line item");
1485        };
1486        assert_eq!((*width, *height), (80.0, 40.0));
1487        assert_eq!(actual, &group);
1488    }
1489}