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