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.
55///
56/// This is the M1 cursor-advance layouter. For word-aware wrapping with UAX
57/// #14 line breaking, alignment, and structured per-line metrics, use
58/// [`LayoutEngine`] instead (see its [`crate::engine`] module docs).
59///
60/// # Example
61///
62/// ```rust
63/// use oxitext_core::{LayoutConstraints, ShapedGlyph, ShapedRun};
64/// use oxitext_layout::SimpleLayouter;
65/// use std::sync::Arc;
66///
67/// // A run of 5 glyphs, each advancing the cursor by 10px (a real shaper
68/// // would produce this from actual text + a font).
69/// let glyphs: Vec<ShapedGlyph> = (0u32..5)
70///     .map(|i| ShapedGlyph {
71///         gid: (i + 1) as u16,
72///         x_advance: 10.0,
73///         cluster: i,
74///         ..Default::default()
75///     })
76///     .collect();
77/// let run = ShapedRun {
78///     glyphs: glyphs.into(),
79///     font_data: Arc::from(&[][..]),
80/// };
81///
82/// let constraints = LayoutConstraints {
83///     max_width: 800.0,
84///     font_size: 16.0,
85/// };
86/// let positioned = SimpleLayouter::new()
87///     .layout(&[run], &constraints)
88///     .expect("layout is currently infallible");
89///
90/// assert_eq!(positioned.len(), 5);
91/// // Cursor advances left-to-right within the (wide) max_width.
92/// for pair in positioned.windows(2) {
93///     assert!(pair[1].pos.0 > pair[0].pos.0);
94/// }
95/// ```
96pub struct SimpleLayouter {
97    /// Text flow direction for this layouter instance.
98    pub flow_direction: FlowDirection,
99}
100
101impl SimpleLayouter {
102    /// Creates a new layouter with horizontal flow (the default).
103    pub fn new() -> Self {
104        Self {
105            flow_direction: FlowDirection::Horizontal,
106        }
107    }
108
109    /// Returns a copy of this layouter with the given flow direction.
110    pub fn with_flow_direction(mut self, dir: FlowDirection) -> Self {
111        self.flow_direction = dir;
112        self
113    }
114
115    /// Positions glyphs from the shaped runs according to constraints.
116    ///
117    /// Dispatches to `Self::layout_horizontal` or `Self::layout_vertical`
118    /// based on [`Self::flow_direction`].
119    ///
120    /// # Errors
121    /// Currently infallible; returns `Err` only for forward compatibility.
122    pub fn layout(
123        &self,
124        runs: &[ShapedRun],
125        constraints: &LayoutConstraints,
126    ) -> Result<Vec<PositionedGlyph>, OxiTextError> {
127        match self.flow_direction {
128            FlowDirection::Horizontal => self.layout_horizontal(runs, constraints),
129            FlowDirection::Vertical => self.layout_vertical(runs, constraints),
130        }
131    }
132
133    /// Horizontal (LTR) cursor-advance layout.
134    ///
135    /// Wraps to a new line when advancing would exceed `constraints.max_width`.
136    fn layout_horizontal(
137        &self,
138        runs: &[ShapedRun],
139        constraints: &LayoutConstraints,
140    ) -> Result<Vec<PositionedGlyph>, OxiTextError> {
141        let mut positioned = Vec::new();
142        let mut cursor_x: f32 = 0.0;
143        // Place baseline one line-height below the top of the canvas.
144        let line_height = constraints.font_size * 1.4;
145        let mut cursor_y: f32 = constraints.font_size * 1.2;
146
147        for run in runs {
148            let font_data = Arc::clone(&run.font_data);
149            for glyph in &run.glyphs {
150                // Word-wrap: if advancing would push us past max_width, newline.
151                if constraints.max_width > 0.0 && cursor_x + glyph.x_advance > constraints.max_width
152                {
153                    cursor_x = 0.0;
154                    cursor_y += line_height;
155                }
156                positioned.push(PositionedGlyph {
157                    gid: glyph.gid,
158                    font_data: Arc::clone(&font_data),
159                    pos: (cursor_x + glyph.x_offset, cursor_y + glyph.y_offset),
160                    font_size: constraints.font_size,
161                    advance_x: glyph.x_advance,
162                    cluster: glyph.cluster,
163                });
164                cursor_x += glyph.x_advance;
165            }
166        }
167
168        Ok(positioned)
169    }
170
171    /// Vertical (top-to-bottom) cursor-advance layout.
172    ///
173    /// Advances the cursor downward using each glyph's `y_advance`; when
174    /// `y_advance` is 0 (as is common for glyphs shaped with only horizontal
175    /// metrics), `x_advance` is used as the vertical advance instead.
176    ///
177    /// When `constraints.max_width > 0` (treated as the maximum column height),
178    /// a new column is started `font_size * 1.2` to the right when the cursor
179    /// would overflow.
180    fn layout_vertical(
181        &self,
182        runs: &[ShapedRun],
183        constraints: &LayoutConstraints,
184    ) -> Result<Vec<PositionedGlyph>, OxiTextError> {
185        let mut positioned = Vec::new();
186        let column_width = constraints.font_size * 1.2;
187        let mut column_x: f32 = 0.0;
188        let mut cursor_y: f32 = 0.0;
189        let max_col_h = constraints.max_width; // semantically: max column height
190
191        for run in runs {
192            let font_data = Arc::clone(&run.font_data);
193            for glyph in &run.glyphs {
194                // Use y_advance when available; fall back to x_advance.
195                let v_adv = if glyph.y_advance > 0.0 {
196                    glyph.y_advance
197                } else {
198                    glyph.x_advance
199                };
200
201                // Wrap to next column when column height would be exceeded.
202                if max_col_h > 0.0 && cursor_y + v_adv > max_col_h && cursor_y > 0.0 {
203                    column_x += column_width;
204                    cursor_y = 0.0;
205                }
206
207                positioned.push(PositionedGlyph {
208                    gid: glyph.gid,
209                    font_data: Arc::clone(&font_data),
210                    pos: (column_x + glyph.x_offset, cursor_y + glyph.y_offset),
211                    font_size: constraints.font_size,
212                    advance_x: glyph.x_advance,
213                    cluster: glyph.cluster,
214                });
215                cursor_y += v_adv;
216            }
217        }
218
219        Ok(positioned)
220    }
221}
222
223impl Default for SimpleLayouter {
224    fn default() -> Self {
225        Self::new()
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use oxitext_core::{LayoutConstraints, ShapedGlyph, ShapedRun};
233    use std::sync::Arc;
234
235    fn make_run(advances: &[f32]) -> ShapedRun {
236        let glyphs = advances
237            .iter()
238            .enumerate()
239            .map(|(i, &adv)| ShapedGlyph {
240                gid: (i + 1) as u16,
241                x_advance: adv,
242                cluster: i as u32,
243                ..Default::default()
244            })
245            .collect();
246        ShapedRun {
247            glyphs,
248            font_data: Arc::from(&[][..]),
249        }
250    }
251
252    #[test]
253    fn layout_positions_are_monotonically_increasing_x() {
254        let run = make_run(&[10.0, 10.0, 10.0, 10.0, 10.0]);
255        let constraints = LayoutConstraints {
256            max_width: 800.0,
257            font_size: 16.0,
258        };
259        let layouter = SimpleLayouter::new();
260        let positioned = layouter
261            .layout(&[run], &constraints)
262            .expect("layout failed");
263        assert_eq!(positioned.len(), 5);
264        // Each successive glyph should be 10px further right.
265        for window in positioned.windows(2) {
266            assert!(
267                window[1].pos.0 > window[0].pos.0,
268                "x should increase: {} <= {}",
269                window[1].pos.0,
270                window[0].pos.0
271            );
272        }
273    }
274
275    #[test]
276    fn layout_wraps_when_max_width_exceeded() {
277        // 5 glyphs × 200px advance; max_width = 800px → wraps at glyph 5
278        let run = make_run(&[200.0, 200.0, 200.0, 200.0, 200.0]);
279        let constraints = LayoutConstraints {
280            max_width: 800.0,
281            font_size: 16.0,
282        };
283        let layouter = SimpleLayouter::new();
284        let positioned = layouter
285            .layout(&[run], &constraints)
286            .expect("layout failed");
287        assert_eq!(positioned.len(), 5);
288        // The 5th glyph (index 4) needs to wrap because 4×200=800 exactly, and
289        // attempting to place 200 more would exceed 800.
290        // Glyph 0..3 are on the first line; glyph 4 wraps.
291        let y_first = positioned[0].pos.1;
292        let y_wrap = positioned[4].pos.1;
293        assert!(
294            y_wrap > y_first,
295            "wrapped glyph should be on a lower line: y_first={y_first}, y_wrap={y_wrap}"
296        );
297    }
298}