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