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}