Skip to main content

rdocx_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 rdocx_oxml::borders::CT_TabStop;
6use rdocx_oxml::shared::{ST_Jc, ST_TabJc, ST_Underline};
7use rdocx_oxml::units::Twips;
8
9use crate::error::Result;
10use crate::font::FontManager;
11use crate::output::{Color, FieldKind, FontId};
12
13/// An inline item to be placed on a line.
14#[derive(Debug, Clone)]
15pub enum InlineItem {
16    /// A shaped text segment.
17    Text(TextSegment),
18    /// A tab character.
19    Tab,
20    /// A forced line break.
21    LineBreak,
22    /// A forced page break.
23    PageBreak,
24    /// A forced column break.
25    ColumnBreak,
26    /// An inline image.
27    Image {
28        width: f64,
29        height: f64,
30        embed_id: String,
31    },
32    /// A numbering marker (rendered before the first line).
33    Marker(TextSegment),
34}
35
36/// A shaped text segment with associated formatting.
37#[derive(Debug, Clone)]
38pub struct TextSegment {
39    pub text: String,
40    pub font_id: FontId,
41    pub font_size: f64,
42    pub glyph_ids: Vec<u16>,
43    pub advances: Vec<f64>,
44    pub width: f64,
45    pub ascent: f64,
46    pub descent: f64,
47    pub color: Color,
48    pub bold: bool,
49    pub italic: bool,
50    /// Underline style (None = no underline).
51    pub underline: Option<ST_Underline>,
52    /// Single strikethrough.
53    pub strike: bool,
54    /// Double strikethrough.
55    pub dstrike: bool,
56    /// Highlight/background color for the run.
57    pub highlight: Option<Color>,
58    /// Baseline offset in points (positive = raise, negative = lower).
59    pub baseline_offset: f64,
60    /// Hyperlink URL if this segment is inside a hyperlink.
61    pub hyperlink_url: Option<String>,
62    /// If this segment is a field placeholder, the kind of field.
63    pub field_kind: Option<FieldKind>,
64    /// If this segment is a footnote/endnote reference marker, its ID.
65    pub footnote_id: Option<i32>,
66}
67
68/// A single item positioned on a line.
69#[derive(Debug, Clone)]
70pub enum LineItem {
71    Text(TextSegment),
72    Tab {
73        width: f64,
74        /// Pre-shaped leader text to fill the tab gap (e.g., dots, hyphens).
75        leader: Option<TextSegment>,
76    },
77    Image {
78        width: f64,
79        height: f64,
80        embed_id: String,
81    },
82    Marker(TextSegment),
83}
84
85impl LineItem {
86    pub fn width(&self) -> f64 {
87        match self {
88            LineItem::Text(seg) => seg.width,
89            LineItem::Tab { width, .. } => *width,
90            LineItem::Image { width, .. } => *width,
91            LineItem::Marker(seg) => seg.width,
92        }
93    }
94}
95
96/// A laid-out line within a paragraph.
97#[derive(Debug, Clone)]
98pub struct LayoutLine {
99    pub items: Vec<LineItem>,
100    /// Total content width of the line.
101    pub width: f64,
102    /// Maximum ascent on this line (above baseline).
103    pub ascent: f64,
104    /// Maximum descent on this line (below baseline).
105    pub descent: f64,
106    /// Total line height.
107    pub height: f64,
108    /// Left indent for this line.
109    pub indent_left: f64,
110    /// Available width this line was laid out against.
111    pub available_width: f64,
112    /// Whether this is the last line of the paragraph.
113    pub is_last: bool,
114}
115
116/// Parameters for line breaking.
117pub struct LineBreakParams {
118    /// Total available width (page width minus margins).
119    pub available_width: f64,
120    /// Left indentation in points.
121    pub ind_left: f64,
122    /// Right indentation in points.
123    pub ind_right: f64,
124    /// First line indent in points (positive = indent, 0 if hanging).
125    pub ind_first_line: f64,
126    /// Hanging indent in points (positive = text lines indented relative to first).
127    pub ind_hanging: f64,
128    /// Tab stops.
129    pub tab_stops: Vec<CT_TabStop>,
130    /// Line spacing value.
131    pub line_spacing: Option<Twips>,
132    /// Line spacing rule.
133    pub line_rule: Option<String>,
134    /// Paragraph justification.
135    pub jc: Option<ST_Jc>,
136}
137
138impl Default for LineBreakParams {
139    fn default() -> Self {
140        LineBreakParams {
141            available_width: 468.0, // US Letter with 1" margins
142            ind_left: 0.0,
143            ind_right: 0.0,
144            ind_first_line: 0.0,
145            ind_hanging: 0.0,
146            tab_stops: Vec::new(),
147            line_spacing: None,
148            line_rule: None,
149            jc: None,
150        }
151    }
152}
153
154/// Break inline items into lines using a greedy algorithm.
155pub fn break_into_lines(
156    items: &[InlineItem],
157    params: &LineBreakParams,
158    fm: &FontManager,
159) -> Result<Vec<LayoutLine>> {
160    if items.is_empty() {
161        // Empty paragraph still gets one empty line
162        return Ok(vec![LayoutLine {
163            items: Vec::new(),
164            width: 0.0,
165            ascent: 0.0,
166            descent: 0.0,
167            height: compute_line_height(0.0, 0.0, params),
168            indent_left: params.ind_left + params.ind_first_line,
169            available_width: params.available_width,
170            is_last: true,
171        }]);
172    }
173
174    let mut lines: Vec<LayoutLine> = Vec::new();
175    let mut current_items: Vec<LineItem> = Vec::new();
176    let mut current_width: f64 = 0.0;
177    let mut current_ascent: f64 = 0.0;
178    let mut current_descent: f64 = 0.0;
179    let mut is_first_line = true;
180
181    let first_line_width = compute_first_line_width(params);
182    let subsequent_line_width = compute_subsequent_line_width(params);
183
184    let mut line_avail = first_line_width;
185
186    // Track the most recent font context for shaping tab leaders
187    let mut font_ctx: Option<(FontId, f64)> = None;
188    // Initialize from the first text segment if available
189    for item in items {
190        if let InlineItem::Text(seg) | InlineItem::Marker(seg) = item {
191            font_ctx = Some((seg.font_id, seg.font_size));
192            break;
193        }
194    }
195
196    // Build breakable segments from inline items
197    let segments = build_breakable_segments(items);
198
199    for seg in &segments {
200        match seg {
201            BreakableSegment::Items(seg_items) => {
202                let seg_width: f64 = seg_items.iter().map(inline_item_width).sum();
203
204                if !current_items.is_empty() && current_width + seg_width > line_avail + 0.01 {
205                    // Finish current line
206                    let indent = if is_first_line {
207                        first_line_indent(params)
208                    } else {
209                        subsequent_line_indent(params)
210                    };
211                    lines.push(LayoutLine {
212                        items: std::mem::take(&mut current_items),
213                        width: current_width,
214                        ascent: current_ascent,
215                        descent: current_descent,
216                        height: compute_line_height(current_ascent, current_descent, params),
217                        indent_left: indent,
218                        available_width: line_avail,
219                        is_last: false,
220                    });
221                    current_width = 0.0;
222                    current_ascent = 0.0;
223                    current_descent = 0.0;
224                    is_first_line = false;
225                    line_avail = subsequent_line_width;
226                }
227
228                // Add segment items to current line
229                for item in seg_items {
230                    let (w, a, d) = item_metrics(item);
231                    current_width += w;
232                    if a > current_ascent {
233                        current_ascent = a;
234                    }
235                    if d > current_descent {
236                        current_descent = d;
237                    }
238                    // Update font context from text segments
239                    if let InlineItem::Text(seg) | InlineItem::Marker(seg) = item {
240                        font_ctx = Some((seg.font_id, seg.font_size));
241                    }
242                    current_items.push(inline_to_line_item(
243                        item,
244                        current_width,
245                        &params.tab_stops,
246                        fm,
247                        font_ctx,
248                    ));
249                }
250            }
251            BreakableSegment::ForcedBreak(break_type) => {
252                let indent = if is_first_line {
253                    first_line_indent(params)
254                } else {
255                    subsequent_line_indent(params)
256                };
257                lines.push(LayoutLine {
258                    items: std::mem::take(&mut current_items),
259                    width: current_width,
260                    ascent: current_ascent,
261                    descent: current_descent,
262                    height: compute_line_height(current_ascent, current_descent, params),
263                    indent_left: indent,
264                    available_width: line_avail,
265                    is_last: matches!(break_type, ForcedBreakType::Page | ForcedBreakType::Column),
266                });
267                current_width = 0.0;
268                current_ascent = 0.0;
269                current_descent = 0.0;
270                is_first_line = false;
271                line_avail = subsequent_line_width;
272            }
273        }
274    }
275
276    // Flush remaining items as the last line
277    let indent = if is_first_line {
278        first_line_indent(params)
279    } else {
280        subsequent_line_indent(params)
281    };
282    lines.push(LayoutLine {
283        items: current_items,
284        width: current_width,
285        ascent: current_ascent,
286        descent: current_descent,
287        height: compute_line_height(current_ascent, current_descent, params),
288        indent_left: indent,
289        available_width: line_avail,
290        is_last: true,
291    });
292
293    Ok(lines)
294}
295
296// ---- Internal helpers ----
297
298#[derive(Debug)]
299enum BreakableSegment {
300    /// A group of items that should be kept together (word or cluster).
301    Items(Vec<InlineItem>),
302    /// A forced break.
303    ForcedBreak(ForcedBreakType),
304}
305
306#[derive(Debug)]
307enum ForcedBreakType {
308    Line,
309    Page,
310    Column,
311}
312
313/// Build breakable segments by finding break opportunities in text.
314///
315/// Text items are split at unicode line-break opportunities (word boundaries,
316/// hyphens, etc.). Non-text items (tabs, images, markers) are treated as
317/// atomic units with break opportunities around them.
318fn build_breakable_segments(items: &[InlineItem]) -> Vec<BreakableSegment> {
319    let mut segments = Vec::new();
320    let mut current_group: Vec<InlineItem> = Vec::new();
321
322    for item in items {
323        match item {
324            InlineItem::LineBreak => {
325                if !current_group.is_empty() {
326                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
327                }
328                segments.push(BreakableSegment::ForcedBreak(ForcedBreakType::Line));
329            }
330            InlineItem::PageBreak => {
331                if !current_group.is_empty() {
332                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
333                }
334                segments.push(BreakableSegment::ForcedBreak(ForcedBreakType::Page));
335            }
336            InlineItem::ColumnBreak => {
337                if !current_group.is_empty() {
338                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
339                }
340                segments.push(BreakableSegment::ForcedBreak(ForcedBreakType::Column));
341            }
342            InlineItem::Tab => {
343                // Tab is a break opportunity
344                if !current_group.is_empty() {
345                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
346                }
347                segments.push(BreakableSegment::Items(vec![item.clone()]));
348            }
349            InlineItem::Text(seg) => {
350                // Use unicode-linebreak to find break opportunities within text
351                let breaks = split_text_at_break_opportunities(seg);
352
353                for tb in &breaks {
354                    let chunk = &seg.text[tb.start..tb.end];
355                    if chunk.is_empty() {
356                        continue;
357                    }
358
359                    // If this chunk starts with whitespace, treat as a break opportunity
360                    if !current_group.is_empty() && chunk.starts_with(|c: char| c.is_whitespace()) {
361                        segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
362                    }
363
364                    // Create a sub-segment for just this chunk (not the entire text)
365                    let sub_item = split_text_subsegment(seg, tb.start, tb.end);
366                    current_group.push(sub_item);
367
368                    if tb.is_break {
369                        segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
370                    }
371                }
372
373                // Flush any remaining
374                if !current_group.is_empty() {
375                    segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
376                }
377            }
378            InlineItem::Marker(_) | InlineItem::Image { .. } => {
379                current_group.push(item.clone());
380            }
381        }
382    }
383
384    if !current_group.is_empty() {
385        segments.push(BreakableSegment::Items(current_group));
386    }
387
388    segments
389}
390
391/// Create a sub-segment InlineItem from a byte range within a TextSegment.
392///
393/// Slices the text, glyph_ids, and advances to the specified byte range,
394/// preserving all formatting properties from the parent segment.
395fn split_text_subsegment(seg: &TextSegment, byte_start: usize, byte_end: usize) -> InlineItem {
396    // If this is the full segment, just clone it
397    if byte_start == 0 && byte_end == seg.text.len() {
398        return InlineItem::Text(seg.clone());
399    }
400
401    let sub_text = seg.text[byte_start..byte_end].to_string();
402    let total_chars = seg.text.chars().count();
403    let char_start = seg.text[..byte_start].chars().count();
404    let char_count = sub_text.chars().count();
405
406    let (sub_glyphs, sub_advances, sub_width) = if seg.glyph_ids.len() == total_chars {
407        // 1:1 char-to-glyph mapping (common for Latin text)
408        let end = (char_start + char_count).min(seg.glyph_ids.len());
409        let glyphs = seg.glyph_ids[char_start..end].to_vec();
410        let advances = seg.advances[char_start..end].to_vec();
411        let width: f64 = advances.iter().sum();
412        (glyphs, advances, width)
413    } else if seg.glyph_ids.is_empty() {
414        // No glyphs (shouldn't happen, but handle gracefully)
415        (vec![], vec![], 0.0)
416    } else {
417        // Non-1:1 mapping (ligatures, complex scripts) — proportional estimate
418        let byte_frac = (byte_end - byte_start) as f64 / seg.text.len() as f64;
419        let est_glyphs = (seg.glyph_ids.len() as f64 * byte_frac).round() as usize;
420        let glyph_start = (seg.glyph_ids.len() as f64 * byte_start as f64 / seg.text.len() as f64)
421            .round() as usize;
422        let glyph_end = (glyph_start + est_glyphs).min(seg.glyph_ids.len());
423        let glyphs = seg.glyph_ids[glyph_start..glyph_end].to_vec();
424        let advances = seg.advances[glyph_start..glyph_end].to_vec();
425        let width: f64 = advances.iter().sum();
426        (glyphs, advances, width)
427    };
428
429    InlineItem::Text(TextSegment {
430        text: sub_text,
431        font_id: seg.font_id,
432        font_size: seg.font_size,
433        glyph_ids: sub_glyphs,
434        advances: sub_advances,
435        width: sub_width,
436        ascent: seg.ascent,
437        descent: seg.descent,
438        color: seg.color,
439        bold: seg.bold,
440        italic: seg.italic,
441        underline: seg.underline,
442        strike: seg.strike,
443        dstrike: seg.dstrike,
444        highlight: seg.highlight,
445        baseline_offset: seg.baseline_offset,
446        hyperlink_url: seg.hyperlink_url.clone(),
447        field_kind: seg.field_kind,
448        footnote_id: seg.footnote_id,
449    })
450}
451
452struct TextBreakInfo {
453    /// Byte range within the original text.
454    start: usize,
455    end: usize,
456    /// Whether a line break is allowed after this segment.
457    is_break: bool,
458}
459
460fn split_text_at_break_opportunities(seg: &TextSegment) -> Vec<TextBreakInfo> {
461    use unicode_linebreak::{BreakOpportunity, linebreaks};
462
463    let text = &seg.text;
464    if text.is_empty() {
465        return vec![];
466    }
467
468    let mut breaks = Vec::new();
469    let mut last_start = 0;
470
471    for (byte_pos, opportunity) in linebreaks(text) {
472        if byte_pos == 0 {
473            continue;
474        }
475
476        let is_break = matches!(
477            opportunity,
478            BreakOpportunity::Allowed | BreakOpportunity::Mandatory
479        );
480
481        breaks.push(TextBreakInfo {
482            start: last_start,
483            end: byte_pos,
484            is_break,
485        });
486        last_start = byte_pos;
487    }
488
489    // If unicode-linebreak didn't produce any breaks, treat as one chunk
490    if breaks.is_empty() {
491        breaks.push(TextBreakInfo {
492            start: 0,
493            end: text.len(),
494            is_break: true,
495        });
496    }
497
498    breaks
499}
500
501fn inline_item_width(item: &InlineItem) -> f64 {
502    match item {
503        InlineItem::Text(seg) => seg.width,
504        InlineItem::Tab => 36.0, // Default tab width, will be resolved
505        InlineItem::Image { width, .. } => *width,
506        InlineItem::Marker(seg) => seg.width,
507        InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => 0.0,
508    }
509}
510
511fn item_metrics(item: &InlineItem) -> (f64, f64, f64) {
512    // Returns (width, ascent, descent)
513    match item {
514        InlineItem::Text(seg) => (seg.width, seg.ascent, seg.descent),
515        InlineItem::Marker(seg) => (seg.width, seg.ascent, seg.descent),
516        InlineItem::Tab => (36.0, 0.0, 0.0),
517        InlineItem::Image { width, height, .. } => (*width, *height, 0.0),
518        InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => (0.0, 0.0, 0.0),
519    }
520}
521
522fn inline_to_line_item(
523    item: &InlineItem,
524    current_x: f64,
525    tab_stops: &[CT_TabStop],
526    fm: &FontManager,
527    font_ctx: Option<(FontId, f64)>,
528) -> LineItem {
529    match item {
530        InlineItem::Text(seg) => LineItem::Text(seg.clone()),
531        InlineItem::Marker(seg) => LineItem::Marker(seg.clone()),
532        InlineItem::Tab => {
533            let (tab_width, leader_char) = resolve_tab_width(current_x, tab_stops);
534            let leader = leader_char.and_then(|ch| shape_leader(fm, font_ctx, ch, tab_width));
535            LineItem::Tab {
536                width: tab_width,
537                leader,
538            }
539        }
540        InlineItem::Image {
541            width,
542            height,
543            embed_id,
544        } => LineItem::Image {
545            width: *width,
546            height: *height,
547            embed_id: embed_id.clone(),
548        },
549        InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => LineItem::Tab {
550            width: 0.0,
551            leader: None,
552        },
553    }
554}
555
556/// Shape a leader character repeated to fill the given width.
557fn shape_leader(
558    fm: &FontManager,
559    font_ctx: Option<(FontId, f64)>,
560    leader_char: char,
561    tab_width: f64,
562) -> Option<TextSegment> {
563    let (font_id, font_size) = font_ctx?;
564    if tab_width < 1.0 {
565        return None;
566    }
567
568    // Shape a single leader character to get its advance width
569    let single = String::from(leader_char);
570    let shaped = fm.shape_text(font_id, &single, font_size).ok()?;
571    if shaped.glyph_ids.is_empty() {
572        return None;
573    }
574    let char_advance = shaped.advances[0];
575    if char_advance < 0.5 {
576        return None;
577    }
578
579    // Add a small gap between leader chars (about 50% of char width for dots, less for others)
580    let spacing = match leader_char {
581        '.' | '\u{00B7}' => char_advance * 0.5,
582        _ => char_advance * 0.15,
583    };
584    let step = char_advance + spacing;
585    let count = ((tab_width - spacing) / step).floor() as usize;
586    if count == 0 {
587        return None;
588    }
589
590    // Build the repeated leader text and glyph arrays
591    let leader_text: String = std::iter::repeat_n(leader_char, count).collect();
592    let mut glyph_ids = Vec::with_capacity(count);
593    let mut advances = Vec::with_capacity(count);
594    for i in 0..count {
595        glyph_ids.push(shaped.glyph_ids[0]);
596        if i + 1 < count {
597            advances.push(char_advance + spacing);
598        } else {
599            advances.push(char_advance);
600        }
601    }
602
603    let metrics = fm.metrics(font_id, font_size).ok()?;
604
605    Some(TextSegment {
606        text: leader_text,
607        font_id,
608        font_size,
609        glyph_ids,
610        advances,
611        width: tab_width, // fill the entire tab gap
612        ascent: metrics.ascent,
613        descent: metrics.descent,
614        color: Color::BLACK,
615        bold: false,
616        italic: false,
617        underline: None,
618        strike: false,
619        dstrike: false,
620        highlight: None,
621        baseline_offset: 0.0,
622        hyperlink_url: None,
623        field_kind: None,
624        footnote_id: None,
625    })
626}
627
628/// Resolve tab stop width and leader character based on current x position and defined stops.
629fn resolve_tab_width(current_x: f64, tab_stops: &[CT_TabStop]) -> (f64, Option<char>) {
630    use rdocx_oxml::shared::ST_TabLeader;
631
632    // Find the next tab stop after the current position
633    for stop in tab_stops {
634        let stop_pos = stop.pos.to_pt();
635        if stop_pos > current_x {
636            let width = match stop.val {
637                ST_TabJc::Left => stop_pos - current_x,
638                ST_TabJc::Center => (stop_pos - current_x).max(0.0),
639                ST_TabJc::Right => (stop_pos - current_x).max(0.0),
640                _ => stop_pos - current_x,
641            };
642            let leader = stop.leader.and_then(|l| match l {
643                ST_TabLeader::Dot => Some('.'),
644                ST_TabLeader::Hyphen => Some('-'),
645                ST_TabLeader::Underscore => Some('_'),
646                ST_TabLeader::MiddleDot => Some('\u{00B7}'),
647                ST_TabLeader::Heavy => Some('_'),
648                ST_TabLeader::None => None,
649            });
650            return (width, leader);
651        }
652    }
653    // Default tab stops every 0.5 inches (36pt)
654    let default_interval = 36.0;
655    let next_stop = ((current_x / default_interval).floor() + 1.0) * default_interval;
656    (next_stop - current_x, None)
657}
658
659fn compute_first_line_width(params: &LineBreakParams) -> f64 {
660    if params.ind_hanging > 0.0 {
661        // Hanging indent: first line has MORE width (extends left)
662        params.available_width - params.ind_left - params.ind_right + params.ind_hanging
663    } else {
664        params.available_width - params.ind_left - params.ind_right - params.ind_first_line
665    }
666}
667
668fn compute_subsequent_line_width(params: &LineBreakParams) -> f64 {
669    params.available_width - params.ind_left - params.ind_right
670}
671
672fn first_line_indent(params: &LineBreakParams) -> f64 {
673    if params.ind_hanging > 0.0 {
674        params.ind_left - params.ind_hanging
675    } else {
676        params.ind_left + params.ind_first_line
677    }
678}
679
680fn subsequent_line_indent(params: &LineBreakParams) -> f64 {
681    params.ind_left
682}
683
684/// Compute line height based on spacing rules.
685fn compute_line_height(ascent: f64, descent: f64, params: &LineBreakParams) -> f64 {
686    let natural = ascent + descent;
687    let natural = if natural < 1.0 { 12.0 } else { natural }; // minimum for empty lines
688
689    match (params.line_spacing, params.line_rule.as_deref()) {
690        (Some(spacing), Some("exact")) => {
691            // Exact: use the specified value
692            spacing.to_pt()
693        }
694        (Some(spacing), Some("atLeast")) => {
695            // At least: max of natural and specified
696            natural.max(spacing.to_pt())
697        }
698        (Some(spacing), _) => {
699            // "auto" or default: spacing is in 240ths of a line
700            // 240 = single spacing, 480 = double, etc.
701            let factor = spacing.0 as f64 / 240.0;
702            natural * factor
703        }
704        (None, _) => {
705            // Default: single spacing
706            natural
707        }
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    fn make_text_segment(text: &str, width: f64) -> TextSegment {
716        TextSegment {
717            text: text.to_string(),
718            font_id: FontId(0),
719            font_size: 12.0,
720            glyph_ids: vec![],
721            advances: vec![],
722            width,
723            ascent: 10.0,
724            descent: 3.0,
725            color: Color::BLACK,
726            bold: false,
727            italic: false,
728            underline: None,
729            strike: false,
730            dstrike: false,
731            highlight: None,
732            baseline_offset: 0.0,
733            hyperlink_url: None,
734            field_kind: None,
735            footnote_id: None,
736        }
737    }
738
739    #[test]
740    fn empty_paragraph_gets_one_line() {
741        let fm = FontManager::new();
742        let lines = break_into_lines(&[], &LineBreakParams::default(), &fm).unwrap();
743        assert_eq!(lines.len(), 1);
744        assert!(lines[0].is_last);
745        assert!(lines[0].items.is_empty());
746    }
747
748    #[test]
749    fn single_word_fits_one_line() {
750        let fm = FontManager::new();
751        let items = vec![InlineItem::Text(make_text_segment("Hello", 50.0))];
752        let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
753        assert_eq!(lines.len(), 1);
754        assert!(lines[0].is_last);
755    }
756
757    #[test]
758    fn words_wrap_to_multiple_lines() {
759        let fm = FontManager::new();
760        let mut items = Vec::new();
761        // Each word is 200pt wide, line is 468pt → should wrap
762        items.push(InlineItem::Text(make_text_segment("Word1", 200.0)));
763        items.push(InlineItem::Text(make_text_segment("Word2", 200.0)));
764        items.push(InlineItem::Text(make_text_segment("Word3", 200.0)));
765
766        let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
767        assert!(lines.len() >= 2);
768    }
769
770    #[test]
771    fn forced_line_break() {
772        let fm = FontManager::new();
773        let items = vec![
774            InlineItem::Text(make_text_segment("Before", 50.0)),
775            InlineItem::LineBreak,
776            InlineItem::Text(make_text_segment("After", 50.0)),
777        ];
778        let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
779        assert!(lines.len() >= 2);
780    }
781
782    #[test]
783    fn line_height_exact() {
784        let params = LineBreakParams {
785            line_spacing: Some(Twips::from_pt(24.0)),
786            line_rule: Some("exact".to_string()),
787            ..Default::default()
788        };
789        let h = compute_line_height(10.0, 3.0, &params);
790        assert!((h - 24.0).abs() < 0.01);
791    }
792
793    #[test]
794    fn line_height_auto() {
795        let params = LineBreakParams {
796            line_spacing: Some(Twips(480)), // double spacing
797            line_rule: Some("auto".to_string()),
798            ..Default::default()
799        };
800        let h = compute_line_height(10.0, 3.0, &params);
801        assert!((h - 26.0).abs() < 0.01); // 13 * 2.0
802    }
803
804    #[test]
805    fn first_line_indent() {
806        let params = LineBreakParams {
807            ind_first_line: 36.0,
808            ..Default::default()
809        };
810        let first_w = compute_first_line_width(&params);
811        let subseq_w = compute_subsequent_line_width(&params);
812        assert!(first_w < subseq_w);
813    }
814
815    #[test]
816    fn hanging_indent() {
817        let params = LineBreakParams {
818            ind_left: 36.0,
819            ind_hanging: 36.0,
820            ..Default::default()
821        };
822        let first_indent = super::first_line_indent(&params);
823        let subseq_indent = super::subsequent_line_indent(&params);
824        assert!(first_indent < subseq_indent);
825    }
826
827    #[test]
828    fn tab_stop_resolution() {
829        let stops = vec![CT_TabStop::new(ST_TabJc::Left, Twips::from_pt(72.0))];
830        let (w, leader) = resolve_tab_width(36.0, &stops);
831        assert!((w - 36.0).abs() < 0.01);
832        assert!(leader.is_none());
833    }
834
835    #[test]
836    fn default_tab_stops() {
837        let (w, _) = resolve_tab_width(10.0, &[]);
838        assert!((w - 26.0).abs() < 0.01); // next stop at 36pt
839    }
840
841    #[test]
842    fn tab_stop_with_dot_leader() {
843        use rdocx_oxml::shared::ST_TabLeader;
844        let stops = vec![CT_TabStop {
845            val: ST_TabJc::Right,
846            pos: Twips::from_pt(400.0),
847            leader: Some(ST_TabLeader::Dot),
848        }];
849        let (w, leader) = resolve_tab_width(100.0, &stops);
850        assert!((w - 300.0).abs() < 0.01);
851        assert_eq!(leader, Some('.'));
852    }
853}