Skip to main content

kozan_core/layout/inline/
line_breaker.rs

1//! Line breaker — splits inline items into lines.
2//!
3//! Chrome equivalent: `NGLineBreaker` + `NGInlineLayoutAlgorithm`.
4//!
5//! # Algorithm
6//!
7//! 1. Initialize the **strut** — the parent block container's font metrics
8//!    that set the minimum height for every line (even empty ones).
9//!    Chrome: `InlineBoxState::InitializeStrut()`.
10//! 2. Walk inline items left to right, accumulating width.
11//! 3. When accumulated width exceeds available width:
12//!    a. Find the last whitespace break opportunity in the text.
13//!    b. Split the text there: first part on current line, remainder on next.
14//!    c. If no whitespace fits: overflow the word (CSS `overflow-wrap: normal`).
15//! 4. For each line: compute height from tallest item + strut, align baselines.
16//! 5. Produce a `Line` for each line box.
17
18use kozan_primitives::geometry::{Point, Size};
19use smallvec::SmallVec;
20use std::sync::Arc;
21
22use super::item::InlineItem;
23use super::measurer::{FontHeight, TextMeasurer};
24use crate::layout::fragment::{
25    BoxFragmentData, ChildFragment, Fragment, LineFragmentData, TextFragmentData,
26};
27use style::properties::longhands::text_wrap_mode::computed_value::T as TextWrapMode;
28use style::values::computed::box_::AlignmentBaseline;
29
30/// A completed line from the line breaker.
31#[derive(Debug)]
32pub struct Line {
33    pub fragments: Vec<ChildFragment>,
34    pub width: f32,
35    pub height: f32,
36    pub baseline: f32,
37}
38
39/// Break inline items into lines that fit within `available_width`.
40///
41/// `strut` is the parent block container's line box contribution —
42/// sets the minimum height/baseline for every line, even empty ones.
43/// Chrome: `InlineBoxState::InitializeStrut()`.
44///
45/// `measurer` is needed for re-measuring text substrings when splitting
46/// at word boundaries.
47pub fn break_into_lines(
48    items: &[InlineItem],
49    available_width: f32,
50    text_wrap_mode: TextWrapMode,
51    strut: &FontHeight,
52    measurer: &dyn TextMeasurer,
53) -> Vec<Line> {
54    let allow_wrap = text_wrap_mode == TextWrapMode::Wrap;
55
56    let mut lines: Vec<Line> = Vec::new();
57    let mut current_line = LineBuilder::new(strut);
58
59    for item in items {
60        match item {
61            InlineItem::ForcedBreak => {
62                lines.push(current_line.finish());
63                current_line = LineBuilder::new(strut);
64            }
65
66            InlineItem::Text {
67                content,
68                style,
69                measured_width,
70                measured_height,
71                baseline,
72                ..
73            } => {
74                let remaining_width = available_width - current_line.width;
75                let valign = style.clone_alignment_baseline();
76
77                if !allow_wrap || *measured_width <= remaining_width {
78                    // Fits or no-wrap — add whole item.
79                    current_line.add_text(
80                        content.clone(),
81                        *measured_width,
82                        *measured_height,
83                        *baseline,
84                        valign,
85                    );
86                } else {
87                    // Text overflows — try to split at word boundaries.
88                    let font_size = style.clone_font_size().computed_size().px();
89                    let mut text: &str = content;
90                    let height = *measured_height;
91                    let bl = *baseline;
92
93                    loop {
94                        let space_left = (available_width - current_line.width).max(0.0);
95
96                        if let Some(split) = find_break_point(text, font_size, space_left, measurer)
97                        {
98                            // Found a break point — add first part to current line.
99                            let first = &text[..split];
100                            let first_metrics = measurer.measure(first, font_size);
101                            if !first.is_empty() {
102                                current_line.add_text(
103                                    Arc::from(first),
104                                    first_metrics.width,
105                                    height,
106                                    bl,
107                                    valign,
108                                );
109                            }
110                            lines.push(current_line.finish());
111                            current_line = LineBuilder::new(strut);
112
113                            // Skip the whitespace at the break point.
114                            text = text[split..].trim_start();
115                            if text.is_empty() {
116                                break;
117                            }
118                            // Continue loop — remainder may need further splitting.
119                        } else {
120                            // No break point fits.
121                            if current_line.width > 0.0 {
122                                // Line not empty — break to new line and retry.
123                                lines.push(current_line.finish());
124                                current_line = LineBuilder::new(strut);
125                                // Don't advance text — retry on the fresh line.
126                            } else {
127                                // Line is empty — overflow the whole text onto this line.
128                                // CSS `overflow-wrap: normal`: word doesn't break.
129                                let full_metrics = measurer.measure(text, font_size);
130                                current_line.add_text(
131                                    Arc::from(text),
132                                    full_metrics.width,
133                                    height,
134                                    bl,
135                                    valign,
136                                );
137                                break;
138                            }
139                        }
140                    }
141                }
142            }
143
144            InlineItem::AtomicInline {
145                width,
146                height,
147                baseline,
148                layout_id,
149                style,
150            } => {
151                if allow_wrap
152                    && current_line.width + width > available_width
153                    && current_line.width > 0.0
154                {
155                    lines.push(current_line.finish());
156                    current_line = LineBuilder::new(strut);
157                }
158
159                current_line.add_atomic(
160                    *width,
161                    *height,
162                    *baseline,
163                    *layout_id,
164                    style.clone_alignment_baseline(),
165                );
166            }
167
168            InlineItem::OpenTag {
169                margin_inline_start,
170                border_inline_start,
171                padding_inline_start,
172                ..
173            } => {
174                current_line.width +=
175                    margin_inline_start + border_inline_start + padding_inline_start;
176            }
177
178            InlineItem::CloseTag {
179                margin_inline_end,
180                border_inline_end,
181                padding_inline_end,
182            } => {
183                current_line.width += margin_inline_end + border_inline_end + padding_inline_end;
184            }
185        }
186    }
187
188    // Don't forget the last line.
189    if current_line.width > 0.0 || current_line.items.is_empty() {
190        lines.push(current_line.finish());
191    }
192
193    lines
194}
195
196/// Find the byte offset of the last whitespace break point that fits
197/// within `available_width`.
198///
199/// Chrome equivalent: part of `NGLineBreaker::HandleText()` — scanning
200/// for break opportunities using ICU line break rules. We use whitespace
201/// as the break opportunity (CSS `word-break: normal`).
202///
203/// Returns `Some(byte_offset)` if a break point fits, `None` if no
204/// whitespace-based break fits within the available width.
205fn find_break_point(
206    text: &str,
207    font_size: f32,
208    available_width: f32,
209    measurer: &dyn TextMeasurer,
210) -> Option<usize> {
211    // Walk left to right, measuring each word segment once and accumulating.
212    // This is O(n) in character count rather than O(k·n) from re-measuring
213    // each prefix from the start on every whitespace encounter.
214    let mut last_break: Option<usize> = None;
215    let mut segment_start = 0;
216    let mut running_width = 0.0_f32;
217
218    for (i, ch) in text.char_indices() {
219        if ch.is_whitespace() {
220            // Measure only the segment since the last measured point.
221            let segment = &text[segment_start..i];
222            running_width += measurer.measure(segment, font_size).width;
223            if running_width <= available_width {
224                last_break = Some(i);
225                segment_start = i;
226            } else {
227                break;
228            }
229        }
230    }
231
232    last_break
233}
234
235/// Convert lines to line box fragments.
236#[must_use]
237pub fn lines_to_fragments(lines: Vec<Line>, available_width: f32) -> Vec<ChildFragment> {
238    let mut result = Vec::with_capacity(lines.len());
239    let mut block_offset: f32 = 0.0;
240
241    for line in lines {
242        let line_fragment = Fragment::new_line(
243            Size::new(available_width, line.height),
244            LineFragmentData {
245                children: line.fragments,
246                baseline: line.baseline,
247            },
248        );
249
250        result.push(ChildFragment {
251            offset: Point::new(0.0, block_offset),
252            fragment: line_fragment,
253        });
254
255        block_offset += line.height;
256    }
257
258    result
259}
260
261/// The kind of content a line item represents.
262///
263/// Chrome equivalent: `NGInlineItem::Type` — text vs atomic inline.
264enum LineItemKind {
265    /// A text run with its string content.
266    Text(Arc<str>),
267    /// An atomic inline (inline-block, img, etc.) with its layout tree ID.
268    Atomic(u32),
269}
270
271/// A single item positioned on a line.
272///
273/// Chrome equivalent: fields on `NGInlineItemResult`.
274struct LineItem {
275    x: f32,
276    width: f32,
277    height: f32,
278    baseline: f32,
279    alignment_baseline: AlignmentBaseline,
280    kind: LineItemKind,
281}
282
283/// Builder for a single line.
284///
285/// Chrome equivalent: part of `NGLineBoxFragmentBuilder`.
286struct LineBuilder {
287    items: SmallVec<[LineItem; 8]>,
288    width: f32,
289    max_ascent: f32,
290    max_descent: f32,
291}
292
293impl LineBuilder {
294    fn new(strut: &FontHeight) -> Self {
295        Self {
296            items: SmallVec::new(),
297            width: 0.0,
298            max_ascent: strut.ascent,
299            max_descent: strut.descent,
300        }
301    }
302
303    fn add_text(
304        &mut self,
305        content: Arc<str>,
306        width: f32,
307        height: f32,
308        baseline: f32,
309        alignment_baseline: AlignmentBaseline,
310    ) {
311        self.items.push(LineItem {
312            x: self.width,
313            width,
314            height,
315            baseline,
316            alignment_baseline,
317            kind: LineItemKind::Text(content),
318        });
319        self.width += width;
320        self.update_line_metrics(height, baseline, alignment_baseline);
321    }
322
323    fn add_atomic(
324        &mut self,
325        width: f32,
326        height: f32,
327        baseline: f32,
328        layout_id: u32,
329        alignment_baseline: AlignmentBaseline,
330    ) {
331        self.items.push(LineItem {
332            x: self.width,
333            width,
334            height,
335            baseline,
336            alignment_baseline,
337            kind: LineItemKind::Atomic(layout_id),
338        });
339        self.width += width;
340        self.update_line_metrics(height, baseline, alignment_baseline);
341    }
342
343    /// Update ascent/descent tracking for a new item.
344    ///
345    /// In CSS Inline 3 (Stylo 0.14), the old `vertical-align` is decomposed into:
346    /// - `alignment-baseline` (keyword: Baseline, `TextTop`, `TextBottom`, Middle, etc.)
347    /// - `baseline-shift` (length offset for super/sub)
348    ///
349    /// Currently we only handle `alignment-baseline` keywords. Super/Sub shift
350    /// is handled via `baseline-shift` which requires separate resolution.
351    fn update_line_metrics(&mut self, height: f32, baseline: f32, align: AlignmentBaseline) {
352        match align {
353            // TextTop/TextBottom-aligned items don't shift the baseline —
354            // they are positioned after the line height is known.
355            // Note: CSS Inline 3 doesn't have Top/Bottom on alignment-baseline.
356            // TextTop and TextBottom are the closest equivalents.
357            AlignmentBaseline::TextTop | AlignmentBaseline::TextBottom => {
358                // They still contribute to minimum line height.
359                let total = self.max_ascent + self.max_descent;
360                if height > total {
361                    self.max_descent = self.max_descent.max(height - self.max_ascent);
362                }
363            }
364            // Baseline, Middle, and all others contribute normally
365            // to the ascent/descent envelope.
366            _ => {
367                let descent = height - baseline;
368                self.max_ascent = self.max_ascent.max(baseline);
369                self.max_descent = self.max_descent.max(descent);
370            }
371        }
372    }
373
374    fn finish(self) -> Line {
375        let height = self.max_ascent + self.max_descent;
376        let baseline = self.max_ascent;
377
378        let mut fragments = Vec::with_capacity(self.items.len());
379
380        for item in &self.items {
381            let offset_y = match item.alignment_baseline {
382                AlignmentBaseline::Baseline => (baseline - item.baseline).max(0.0),
383                AlignmentBaseline::Middle => ((height - item.height) / 2.0).max(0.0),
384                AlignmentBaseline::TextTop => {
385                    // Align top of element with top of parent font (strut).
386                    0.0
387                }
388                AlignmentBaseline::TextBottom => {
389                    // Align bottom of element with bottom of parent font.
390                    (height - item.height).max(0.0)
391                }
392                // All other alignment-baseline values (Alphabetic, Central,
393                // Mathematical, Hanging, Ideographic) — treat as baseline for now.
394                _ => (baseline - item.baseline).max(0.0),
395            };
396
397            let fragment = match &item.kind {
398                LineItemKind::Text(content) => Fragment::new_text(
399                    Size::new(item.width, item.height),
400                    TextFragmentData {
401                        text_range: 0..content.len(),
402                        baseline: item.baseline,
403                        text: Some(content.clone()),
404                        shaped_runs: Vec::new(),
405                    },
406                ),
407                LineItemKind::Atomic(_layout_id) => Fragment::new_box(
408                    Size::new(item.width, item.height),
409                    BoxFragmentData {
410                        scrollable_overflow: Size::new(item.width, item.height),
411                        ..Default::default()
412                    },
413                ),
414            };
415
416            fragments.push(ChildFragment {
417                offset: Point::new(item.x, offset_y),
418                fragment,
419            });
420        }
421
422        Line {
423            fragments,
424            width: self.width,
425            height,
426            baseline,
427        }
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use crate::layout::inline::FontSystem;
435    use crate::layout::inline::measurer::resolve_line_height;
436    use style::properties::ComputedValues;
437
438    fn initial_style() -> servo_arc::Arc<ComputedValues> {
439        crate::styling::initial_values_arc().clone()
440    }
441
442    fn default_strut() -> FontHeight {
443        let m = FontSystem::new();
444        let fm = m.font_metrics(16.0);
445        let style = initial_style();
446        let lh = resolve_line_height(&style.clone_line_height(), 16.0, &fm);
447        FontHeight::from_metrics_and_line_height(&fm, lh)
448    }
449
450    fn text_item(text: &str, width: f32) -> InlineItem {
451        let m = FontSystem::new();
452        let fm = m.font_metrics(16.0);
453        let style = initial_style();
454        let lh = resolve_line_height(&style.clone_line_height(), 16.0, &fm);
455        let fh = FontHeight::from_metrics_and_line_height(&fm, lh);
456
457        InlineItem::Text {
458            content: Arc::from(text),
459            style,
460            measured_width: width,
461            measured_height: fh.height(),
462            baseline: fh.ascent,
463        }
464    }
465
466    /// Build a text item with width from the default measurer.
467    fn measured_text_item(text: &str) -> InlineItem {
468        let m = FontSystem::new();
469        let fm = m.font_metrics(16.0);
470        let style = initial_style();
471        let lh = resolve_line_height(&style.clone_line_height(), 16.0, &fm);
472        let fh = FontHeight::from_metrics_and_line_height(&fm, lh);
473        let width = m.measure(text, 16.0).width;
474
475        InlineItem::Text {
476            content: Arc::from(text),
477            style,
478            measured_width: width,
479            measured_height: fh.height(),
480            baseline: fh.ascent,
481        }
482    }
483
484    fn m() -> FontSystem {
485        FontSystem::new()
486    }
487
488    #[test]
489    fn single_line_fits() {
490        let strut = default_strut();
491        let items = vec![text_item("Hello", 50.0), text_item("World", 50.0)];
492        let lines = break_into_lines(&items, 200.0, TextWrapMode::Wrap, &strut, &m());
493        assert_eq!(lines.len(), 1);
494        assert_eq!(lines[0].width, 100.0);
495    }
496
497    #[test]
498    fn multiple_items_overflow() {
499        let strut = default_strut();
500        // Use measured_text_item for consistent widths with the measurer.
501        // Each word width: "Hello" = 5*16*0.5=40, "World"=40, "Foo"=24.
502        // Available: 50px. "Hello"(40) fits. "World"(40): 40+40=80 > 50 → break.
503        let items = vec![
504            measured_text_item("Hello"),
505            measured_text_item("World"),
506            measured_text_item("Foo"),
507        ];
508        let lines = break_into_lines(&items, 50.0, TextWrapMode::Wrap, &strut, &m());
509        // "Hello" on line 1, "World" on line 2, "Foo" on line 3.
510        assert_eq!(lines.len(), 3);
511    }
512
513    #[test]
514    fn no_wrap_single_line() {
515        let strut = default_strut();
516        let items = vec![text_item("Hello", 200.0), text_item("World", 200.0)];
517        let lines = break_into_lines(&items, 100.0, TextWrapMode::Nowrap, &strut, &m());
518        assert_eq!(lines.len(), 1);
519        assert_eq!(lines[0].width, 400.0);
520    }
521
522    #[test]
523    fn forced_break() {
524        let strut = default_strut();
525        let items = vec![
526            text_item("Line1", 50.0),
527            InlineItem::ForcedBreak,
528            text_item("Line2", 50.0),
529        ];
530        let lines = break_into_lines(&items, 200.0, TextWrapMode::Wrap, &strut, &m());
531        assert_eq!(lines.len(), 2);
532    }
533
534    #[test]
535    fn baseline_alignment() {
536        let strut = default_strut();
537        let items = vec![
538            InlineItem::Text {
539                content: Arc::from("Big"),
540                style: initial_style(),
541                measured_width: 50.0,
542                measured_height: 24.0,
543                baseline: 20.0,
544            },
545            InlineItem::Text {
546                content: Arc::from("Small"),
547                style: initial_style(),
548                measured_width: 30.0,
549                measured_height: 12.0,
550                baseline: 10.0,
551            },
552        ];
553        let lines = break_into_lines(&items, 200.0, TextWrapMode::Wrap, &strut, &m());
554        assert_eq!(lines.len(), 1);
555        // Line height = max_ascent + max_descent, where both are max of strut
556        // and item contributions. The strut (from real font metrics) participates.
557        let expected_ascent = strut.ascent.max(20.0).max(10.0);
558        let expected_descent = strut.descent.max(4.0).max(2.0);
559        assert_eq!(lines[0].height, expected_ascent + expected_descent);
560        assert_eq!(lines[0].baseline, expected_ascent);
561    }
562
563    #[test]
564    fn empty_line_uses_strut() {
565        let strut = FontHeight {
566            ascent: 14.0,
567            descent: 6.0,
568        };
569        let items = vec![InlineItem::ForcedBreak];
570        let lines = break_into_lines(&items, 200.0, TextWrapMode::Wrap, &strut, &m());
571        assert_eq!(lines.len(), 2);
572        assert_eq!(lines[0].height, 20.0);
573        assert_eq!(lines[0].baseline, 14.0);
574    }
575
576    #[test]
577    fn strut_sets_minimum_line_height() {
578        let strut = FontHeight {
579            ascent: 20.0,
580            descent: 10.0,
581        };
582        let items = vec![InlineItem::Text {
583            content: Arc::from("tiny"),
584            style: initial_style(),
585            measured_width: 30.0,
586            measured_height: 12.0,
587            baseline: 10.0,
588        }];
589        let lines = break_into_lines(&items, 200.0, TextWrapMode::Wrap, &strut, &m());
590        assert_eq!(lines.len(), 1);
591        assert_eq!(lines[0].height, 30.0);
592        assert_eq!(lines[0].baseline, 20.0);
593    }
594
595    #[test]
596    fn lines_to_fragments_stacks_vertically() {
597        let strut = default_strut();
598        let items = vec![
599            text_item("Line1", 50.0),
600            InlineItem::ForcedBreak,
601            text_item("Line2", 50.0),
602        ];
603        let lines = break_into_lines(&items, 200.0, TextWrapMode::Wrap, &strut, &m());
604        let line_height = lines[0].height;
605        let frags = lines_to_fragments(lines, 200.0);
606        assert_eq!(frags.len(), 2);
607        assert_eq!(frags[0].offset.y, 0.0);
608        assert_eq!(frags[1].offset.y, line_height);
609    }
610
611    // ---- Word-boundary breaking tests ----
612
613    #[test]
614    fn word_break_splits_at_space() {
615        // "Hello World" as a single text item. Available width fits "Hello" but not "Hello World".
616        let strut = default_strut();
617        let items = vec![measured_text_item("Hello World")];
618        // "Hello" and "Hello World" widths come from real font shaping.
619        // Available: 50px — fits "Hello" (40) but not "Hello " (48) + "World".
620        let lines = break_into_lines(&items, 50.0, TextWrapMode::Wrap, &strut, &m());
621        assert_eq!(lines.len(), 2, "should split at space");
622        // First line has "Hello", second has "World".
623        assert!(lines[0].width > 0.0);
624        assert!(lines[1].width > 0.0);
625    }
626
627    #[test]
628    fn word_break_no_space_overflows() {
629        // "Superlongword" — no whitespace, must overflow.
630        let strut = default_strut();
631        let items = vec![measured_text_item("Superlongword")];
632        let lines = break_into_lines(&items, 30.0, TextWrapMode::Wrap, &strut, &m());
633        assert_eq!(lines.len(), 1, "no break point → overflow on single line");
634        assert!(lines[0].width > 30.0);
635    }
636
637    #[test]
638    fn word_break_multiple_words() {
639        // "The quick brown fox" — should break into multiple lines.
640        let strut = default_strut();
641        let measurer = m();
642        let items = vec![measured_text_item("The quick brown fox")];
643        // Use a width that fits ~1 word but not the full text.
644        let one_word_w = measurer.measure("quick", 16.0).width;
645        let lines = break_into_lines(
646            &items,
647            one_word_w * 1.5,
648            TextWrapMode::Wrap,
649            &strut,
650            &measurer,
651        );
652        assert!(
653            lines.len() >= 2,
654            "should break into at least 2 lines, got {}",
655            lines.len()
656        );
657    }
658
659    #[test]
660    fn word_break_exact_fit() {
661        // "AB CD" where available width exactly fits "AB".
662        let strut = default_strut();
663        let measurer = m();
664        let items = vec![measured_text_item("AB CD")];
665        // Use the real measured width of "AB" — no hardcoded values.
666        let ab_width = measurer.measure("AB", 16.0).width;
667        let lines = break_into_lines(&items, ab_width, TextWrapMode::Wrap, &strut, &measurer);
668        assert_eq!(lines.len(), 2);
669    }
670
671    // ---- White-space mode tests ----
672
673    #[test]
674    fn nowrap_no_wrapping() {
675        // TextWrapMode::Nowrap disables wrapping — even overflowing text stays on one line.
676        let strut = default_strut();
677        let items = vec![measured_text_item(
678            "This is a long line that exceeds the width",
679        )];
680        let lines = break_into_lines(&items, 50.0, TextWrapMode::Nowrap, &strut, &m());
681        assert_eq!(
682            lines.len(),
683            1,
684            "nowrap should not wrap, got {} lines",
685            lines.len()
686        );
687    }
688
689    #[test]
690    fn wrap_wraps_at_space() {
691        // TextWrapMode::Wrap allows wrapping at whitespace.
692        let strut = default_strut();
693        let items = vec![measured_text_item("Hello World")];
694        let lines = break_into_lines(&items, 50.0, TextWrapMode::Wrap, &strut, &m());
695        assert_eq!(
696            lines.len(),
697            2,
698            "wrap should wrap at space, got {} lines",
699            lines.len()
700        );
701    }
702
703    #[test]
704    fn wrap_preserves_forced_breaks() {
705        // TextWrapMode::Wrap preserves forced breaks.
706        let strut = default_strut();
707        let items = vec![
708            measured_text_item("First"),
709            InlineItem::ForcedBreak,
710            measured_text_item("Second"),
711        ];
712        let lines = break_into_lines(&items, 800.0, TextWrapMode::Wrap, &strut, &m());
713        assert_eq!(
714            lines.len(),
715            2,
716            "wrap should preserve forced break, got {} lines",
717            lines.len()
718        );
719    }
720
721    // ---- Alignment-baseline tests ----
722
723    #[test]
724    fn vertical_align_baseline_default() {
725        // The default style has AlignmentBaseline::Auto (treated as Baseline).
726        // An item whose baseline matches the strut baseline needs no vertical shift.
727        let strut = default_strut();
728        let items = vec![text_item("x", strut.height())];
729        let lines = break_into_lines(&items, 200.0, TextWrapMode::Wrap, &strut, &m());
730        assert_eq!(lines.len(), 1);
731        // Item baseline matches strut baseline → no vertical shift.
732        let frag = &lines[0].fragments[0];
733        assert!(
734            frag.offset.y.abs() < 0.5,
735            "baseline-aligned item should have offset_y ≈ 0, got {}",
736            frag.offset.y,
737        );
738    }
739
740    #[test]
741    fn taller_item_increases_line_height() {
742        // An item taller than the strut forces the line to grow to accommodate it.
743        let strut = default_strut();
744        let strut_height = strut.height();
745        // Taller item: twice the strut height with matching ascent proportion.
746        let tall_height = strut_height * 2.0;
747        let tall_baseline = strut.ascent * 2.0;
748        let items = vec![InlineItem::Text {
749            content: Arc::from("tall"),
750            style: initial_style(),
751            measured_width: 50.0,
752            measured_height: tall_height,
753            baseline: tall_baseline,
754        }];
755        let lines = break_into_lines(&items, 200.0, TextWrapMode::Wrap, &strut, &m());
756        assert_eq!(lines.len(), 1);
757        assert!(
758            lines[0].height >= tall_height,
759            "line height must accommodate tall item: line={}, item={}",
760            lines[0].height,
761            tall_height,
762        );
763    }
764
765    #[test]
766    fn mixed_height_items_share_line() {
767        // Two items of different heights on the same line align to the tallest baseline.
768        let strut = default_strut();
769        let small_baseline = strut.ascent * 0.5;
770        let large_baseline = strut.ascent * 1.5;
771        let items = vec![
772            InlineItem::Text {
773                content: Arc::from("small"),
774                style: initial_style(),
775                measured_width: 30.0,
776                measured_height: small_baseline + 5.0,
777                baseline: small_baseline,
778            },
779            InlineItem::Text {
780                content: Arc::from("large"),
781                style: initial_style(),
782                measured_width: 30.0,
783                measured_height: large_baseline + 8.0,
784                baseline: large_baseline,
785            },
786        ];
787        let lines = break_into_lines(&items, 200.0, TextWrapMode::Wrap, &strut, &m());
788        assert_eq!(lines.len(), 1, "both items should fit on one line");
789        // Line baseline is the max of all item baselines (clamped to strut minimum).
790        assert!(
791            lines[0].baseline >= large_baseline,
792            "line baseline must accommodate the tallest item: {}",
793            lines[0].baseline,
794        );
795    }
796
797    #[test]
798    #[ignore] // TODO: needs StyleEngine to construct styled ComputedValues
799    fn vertical_align_middle() {}
800
801    #[test]
802    #[ignore] // TODO: needs StyleEngine to construct styled ComputedValues
803    fn vertical_align_super_shifts_up() {}
804
805    #[test]
806    #[ignore] // TODO: needs StyleEngine to construct styled ComputedValues
807    fn vertical_align_sub_shifts_down() {}
808}