Skip to main content

oxitext_layout/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3//! `oxitext-layout` — Text layout for OxiText.
4//!
5//! Provides [`SimpleLayouter`], a left-to-right cursor-advance layouter that
6//! wraps lines when the current cursor exceeds `max_width`.
7//!
8//! M1: LTR simple layout.
9//! M2: [`bidi`] (UAX #9), [`linebreak`] (UAX #14), [`vertical`] (UAX #50 subset).
10//! M3 (deferred): Parley integration for full rich-text layout.
11//! M4: [`tate_chu_yoko`] — horizontal run detection within vertical CJK lines.
12//! M6: [`engine`] — word-aware ([`linebreak`]-driven) wrapping with horizontal
13//!     [`oxitext_core::TextAlignment`] and structured [`engine::LayoutResult`]
14//!     output (per-line and per-paragraph metrics).
15
16pub mod bidi;
17pub mod engine;
18pub mod hyphenation;
19pub mod knuth_plass;
20pub mod linebreak;
21pub mod options;
22pub mod reorder;
23pub mod ruby;
24pub mod styled;
25pub mod tate_chu_yoko;
26pub mod vertical;
27
28pub use engine::{
29    BreakingStrategy, LayoutEngine, LayoutResult, Line, LineMetrics, ParagraphMetrics,
30};
31pub use hyphenation::soft_hyphen_breaks;
32pub use options::{LayoutOptions, LayoutOptionsBuilder, TabStops, TruncationMode};
33pub use oxitext_core::{
34    DecorationRect, InlineObject, PositionedInlineObject, TextDecoration, VerticalPosition,
35};
36pub use reorder::needs_bidi;
37pub use ruby::{layout_ruby, RubyAnnotation, RubyLayout, RubyPosition};
38pub use styled::StyledRun;
39pub use tate_chu_yoko::{detect_runs, tcy_combined_advance, GlyphEntry, TateChuYokoRun};
40pub use vertical::vmtx_advance_for_glyph;
41
42use oxitext_core::{FlowDirection, LayoutConstraints, OxiTextError, PositionedGlyph, ShapedRun};
43use std::sync::Arc;
44
45/// Simple layouter that supports both horizontal (LTR) and vertical flow.
46///
47/// For horizontal flow, advances a cursor left-to-right, emitting a
48/// [`PositionedGlyph`] for each input glyph and wrapping when the cursor
49/// exceeds `max_width`.
50///
51/// For vertical flow, advances the cursor top-to-bottom using each glyph's
52/// `y_advance` (falling back to `x_advance` when `y_advance` is zero), and
53/// wraps into a new column when `max_width` (treated as max column height) is
54/// exceeded.
55pub struct SimpleLayouter {
56    /// Text flow direction for this layouter instance.
57    pub flow_direction: FlowDirection,
58}
59
60impl SimpleLayouter {
61    /// Creates a new layouter with horizontal flow (the default).
62    pub fn new() -> Self {
63        Self {
64            flow_direction: FlowDirection::Horizontal,
65        }
66    }
67
68    /// Returns a copy of this layouter with the given flow direction.
69    pub fn with_flow_direction(mut self, dir: FlowDirection) -> Self {
70        self.flow_direction = dir;
71        self
72    }
73
74    /// Positions glyphs from the shaped runs according to constraints.
75    ///
76    /// Dispatches to `Self::layout_horizontal` or `Self::layout_vertical`
77    /// based on [`Self::flow_direction`].
78    ///
79    /// # Errors
80    /// Currently infallible; returns `Err` only for forward compatibility.
81    pub fn layout(
82        &self,
83        runs: &[ShapedRun],
84        constraints: &LayoutConstraints,
85    ) -> Result<Vec<PositionedGlyph>, OxiTextError> {
86        match self.flow_direction {
87            FlowDirection::Horizontal => self.layout_horizontal(runs, constraints),
88            FlowDirection::Vertical => self.layout_vertical(runs, constraints),
89        }
90    }
91
92    /// Horizontal (LTR) cursor-advance layout.
93    ///
94    /// Wraps to a new line when advancing would exceed `constraints.max_width`.
95    fn layout_horizontal(
96        &self,
97        runs: &[ShapedRun],
98        constraints: &LayoutConstraints,
99    ) -> Result<Vec<PositionedGlyph>, OxiTextError> {
100        let mut positioned = Vec::new();
101        let mut cursor_x: f32 = 0.0;
102        // Place baseline one line-height below the top of the canvas.
103        let line_height = constraints.font_size * 1.4;
104        let mut cursor_y: f32 = constraints.font_size * 1.2;
105
106        for run in runs {
107            let font_data = Arc::clone(&run.font_data);
108            for glyph in &run.glyphs {
109                // Word-wrap: if advancing would push us past max_width, newline.
110                if constraints.max_width > 0.0 && cursor_x + glyph.x_advance > constraints.max_width
111                {
112                    cursor_x = 0.0;
113                    cursor_y += line_height;
114                }
115                positioned.push(PositionedGlyph {
116                    gid: glyph.gid,
117                    font_data: Arc::clone(&font_data),
118                    pos: (cursor_x + glyph.x_offset, cursor_y + glyph.y_offset),
119                    font_size: constraints.font_size,
120                    advance_x: glyph.x_advance,
121                    cluster: glyph.cluster,
122                });
123                cursor_x += glyph.x_advance;
124            }
125        }
126
127        Ok(positioned)
128    }
129
130    /// Vertical (top-to-bottom) cursor-advance layout.
131    ///
132    /// Advances the cursor downward using each glyph's `y_advance`; when
133    /// `y_advance` is 0 (as is common for glyphs shaped with only horizontal
134    /// metrics), `x_advance` is used as the vertical advance instead.
135    ///
136    /// When `constraints.max_width > 0` (treated as the maximum column height),
137    /// a new column is started `font_size * 1.2` to the right when the cursor
138    /// would overflow.
139    fn layout_vertical(
140        &self,
141        runs: &[ShapedRun],
142        constraints: &LayoutConstraints,
143    ) -> Result<Vec<PositionedGlyph>, OxiTextError> {
144        let mut positioned = Vec::new();
145        let column_width = constraints.font_size * 1.2;
146        let mut column_x: f32 = 0.0;
147        let mut cursor_y: f32 = 0.0;
148        let max_col_h = constraints.max_width; // semantically: max column height
149
150        for run in runs {
151            let font_data = Arc::clone(&run.font_data);
152            for glyph in &run.glyphs {
153                // Use y_advance when available; fall back to x_advance.
154                let v_adv = if glyph.y_advance > 0.0 {
155                    glyph.y_advance
156                } else {
157                    glyph.x_advance
158                };
159
160                // Wrap to next column when column height would be exceeded.
161                if max_col_h > 0.0 && cursor_y + v_adv > max_col_h && cursor_y > 0.0 {
162                    column_x += column_width;
163                    cursor_y = 0.0;
164                }
165
166                positioned.push(PositionedGlyph {
167                    gid: glyph.gid,
168                    font_data: Arc::clone(&font_data),
169                    pos: (column_x + glyph.x_offset, cursor_y + glyph.y_offset),
170                    font_size: constraints.font_size,
171                    advance_x: glyph.x_advance,
172                    cluster: glyph.cluster,
173                });
174                cursor_y += v_adv;
175            }
176        }
177
178        Ok(positioned)
179    }
180}
181
182impl Default for SimpleLayouter {
183    fn default() -> Self {
184        Self::new()
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use oxitext_core::{LayoutConstraints, ShapedGlyph, ShapedRun};
192    use std::sync::Arc;
193
194    fn make_run(advances: &[f32]) -> ShapedRun {
195        let glyphs = advances
196            .iter()
197            .enumerate()
198            .map(|(i, &adv)| ShapedGlyph {
199                gid: (i + 1) as u16,
200                x_advance: adv,
201                cluster: i as u32,
202                ..Default::default()
203            })
204            .collect();
205        ShapedRun {
206            glyphs,
207            font_data: Arc::from(&[][..]),
208        }
209    }
210
211    #[test]
212    fn layout_positions_are_monotonically_increasing_x() {
213        let run = make_run(&[10.0, 10.0, 10.0, 10.0, 10.0]);
214        let constraints = LayoutConstraints {
215            max_width: 800.0,
216            font_size: 16.0,
217        };
218        let layouter = SimpleLayouter::new();
219        let positioned = layouter
220            .layout(&[run], &constraints)
221            .expect("layout failed");
222        assert_eq!(positioned.len(), 5);
223        // Each successive glyph should be 10px further right.
224        for window in positioned.windows(2) {
225            assert!(
226                window[1].pos.0 > window[0].pos.0,
227                "x should increase: {} <= {}",
228                window[1].pos.0,
229                window[0].pos.0
230            );
231        }
232    }
233
234    #[test]
235    fn layout_wraps_when_max_width_exceeded() {
236        // 5 glyphs × 200px advance; max_width = 800px → wraps at glyph 5
237        let run = make_run(&[200.0, 200.0, 200.0, 200.0, 200.0]);
238        let constraints = LayoutConstraints {
239            max_width: 800.0,
240            font_size: 16.0,
241        };
242        let layouter = SimpleLayouter::new();
243        let positioned = layouter
244            .layout(&[run], &constraints)
245            .expect("layout failed");
246        assert_eq!(positioned.len(), 5);
247        // The 5th glyph (index 4) needs to wrap because 4×200=800 exactly, and
248        // attempting to place 200 more would exceed 800.
249        // Glyph 0..3 are on the first line; glyph 4 wraps.
250        let y_first = positioned[0].pos.1;
251        let y_wrap = positioned[4].pos.1;
252        assert!(
253            y_wrap > y_first,
254            "wrapped glyph should be on a lower line: y_first={y_first}, y_wrap={y_wrap}"
255        );
256    }
257}