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