Skip to main content

oxitext_layout/
styled.rs

1//! Multi-style layout: baseline-aligned layout across [`StyledRun`]s.
2//!
3//! [`LayoutEngine::layout_styled_runs`] accepts a slice of pre-shaped
4//! [`StyledRun`]s each with its own font metrics and pixel size, and positions
5//! them so that all glyphs on the same line share a common baseline.  The
6//! common baseline is determined by the maximum ascender among all runs
7//! contributing to that line (UAX #14-aware greedy line-breaking is applied
8//! first).
9
10use crate::{
11    engine::{LayoutEngine, LayoutResult, Line, LineMetrics, ParagraphMetrics},
12    linebreak::{LineBreak, LineBreaker},
13    options::LayoutOptions,
14};
15use oxitext_core::{FontVerticalMetrics, PositionedGlyph, Rgba8, ShapedGlyph, VerticalPosition};
16use std::sync::Arc;
17
18/// A pre-shaped run of glyphs with its own font metrics and pixel size.
19///
20/// Used for mixed-font/size layouts where each run may have a different face.
21/// Each `StyledRun` carries a shared reference to the font bytes so that the
22/// rasteriser downstream can look up the correct face without re-parsing.
23pub struct StyledRun {
24    /// Pre-shaped glyphs for this run.
25    pub glyphs: Vec<ShapedGlyph>,
26    /// Font vertical metrics for this run's face.
27    pub metrics: FontVerticalMetrics,
28    /// Font size in pixels for this run.
29    pub px_size: f32,
30    /// Text colour (RGBA). Stored for downstream use; layout does not render.
31    pub color: Rgba8,
32    /// Raw font bytes backing the glyphs in this run.
33    pub font_data: Arc<[u8]>,
34    /// Vertical positioning for subscript/superscript effects.
35    /// Defaults to [`VerticalPosition::Normal`].
36    pub vertical_position: VerticalPosition,
37}
38
39/// Per-glyph bookkeeping during multi-style layout.
40struct FlatEntry<'a> {
41    glyph: &'a ShapedGlyph,
42    font_data: &'a Arc<[u8]>,
43    run_ascent: f32,
44    run_descent: f32,
45    px_size: f32,
46    vertical_position: VerticalPosition,
47}
48
49impl LayoutEngine {
50    /// Lays out a slice of pre-shaped [`StyledRun`]s with baseline alignment.
51    ///
52    /// # Algorithm
53    ///
54    /// 1. Flatten all glyphs from all runs into a single sequence, tagging each
55    ///    with its run's vertical metrics and pixel size.
56    /// 2. Compute UAX #14 line-break opportunities from a synthetic "cluster
57    ///    string" built from the cluster byte offsets; use greedy wrapping
58    ///    against `max_width`.
59    /// 3. Group glyphs into lines.
60    /// 4. For each line compute:
61    ///    - `line_ascender = max(run.ascent_px)` across all runs on the line.
62    ///    - `line_descender = max(run.descent_px)` across all runs on the line.
63    /// 5. Assign `baseline_y = cursor_y + line_ascender`.  Each glyph's `y` is
64    ///    `baseline_y + (line_ascender − run_ascender)`, lifting shorter-ascender
65    ///    glyphs up so their own ascender aligns with the tallest.
66    /// 6. Advance `cursor_y += line_ascender + line_descender + line_gap +
67    ///    options.paragraph_spacing`.
68    ///
69    /// # Parameters
70    /// - `runs` – pre-shaped runs in logical order.
71    /// - `source_text` – the original string the runs were shaped from (required
72    ///   for UAX #14 linebreak analysis; cluster offsets in each
73    ///   [`ShapedGlyph`] index into this string).
74    /// - `max_width` – maximum line width in pixels; `0.0` disables wrapping.
75    /// - `options` – paragraph options (paragraph_spacing is used as extra
76    ///   leading after each line).
77    ///
78    /// # Errors
79    /// Currently infallible for well-formed input; returns `Err` only for
80    /// forward compatibility.
81    pub fn layout_styled_runs(
82        &mut self,
83        runs: &[StyledRun],
84        source_text: &str,
85        max_width: f32,
86        options: &LayoutOptions,
87    ) -> Result<LayoutResult, oxitext_core::OxiTextError> {
88        // ------------------------------------------------------------------
89        // Step 1: Flatten all glyphs from all runs, recording per-run metrics.
90        // ------------------------------------------------------------------
91        let mut flat: Vec<FlatEntry<'_>> = Vec::new();
92        for run in runs {
93            let run_ascent = run.metrics.ascent_px(run.px_size);
94            let run_descent = run.metrics.descent_px(run.px_size);
95            for g in &run.glyphs {
96                flat.push(FlatEntry {
97                    glyph: g,
98                    font_data: &run.font_data,
99                    run_ascent,
100                    run_descent,
101                    px_size: run.px_size,
102                    vertical_position: run.vertical_position,
103                });
104            }
105        }
106
107        // ------------------------------------------------------------------
108        // Step 2: Compute break opportunities (reuse engine cache if text
109        // is identical to the last call).
110        // ------------------------------------------------------------------
111        if source_text != self.break_cache_text {
112            let b = LineBreaker::new(source_text).breaks().to_vec();
113            self.break_cache_text = source_text.to_owned();
114            self.break_cache_ops = b;
115        }
116        let breaks = &self.break_cache_ops;
117
118        let break_at = |off: usize| -> Option<LineBreak> {
119            breaks
120                .iter()
121                .find(|(pos, _)| *pos == off)
122                .map(|(_, kind)| kind.clone())
123        };
124
125        // ------------------------------------------------------------------
126        // Step 3: Greedy line-breaking to produce (start, end) glyph ranges.
127        // ------------------------------------------------------------------
128        let wrap = max_width > 0.0;
129        let mut line_ranges: Vec<(usize, usize)> = Vec::new();
130        let mut line_start = 0usize;
131        let mut cursor_x = 0.0f32;
132        let mut last_safe: Option<usize> = None;
133        let mut width_at_break = 0.0f32;
134
135        let mut i = 0usize;
136        while i < flat.len() {
137            let adv = flat[i].glyph.x_advance;
138            let cluster_off = flat[i].glyph.cluster as usize;
139
140            if i > line_start {
141                // Determine effective break class.
142                let current_char = source_text
143                    .get(cluster_off..)
144                    .and_then(|s| s.chars().next());
145                let preceding_char = if cluster_off == 0 {
146                    None
147                } else {
148                    (1..=4usize).find_map(|back| {
149                        let start = cluster_off.checked_sub(back)?;
150                        if source_text.is_char_boundary(start) {
151                            source_text[start..cluster_off].chars().next_back()
152                        } else {
153                            None
154                        }
155                    })
156                };
157                let zwj_precedes = preceding_char == Some('\u{200D}');
158                let is_zwnj = current_char == Some('\u{200C}');
159
160                let effective_break: Option<LineBreak> = if zwj_precedes {
161                    None
162                } else if is_zwnj {
163                    Some(LineBreak::Allowed)
164                } else {
165                    break_at(cluster_off)
166                };
167
168                if let Some(kind) = effective_break {
169                    if kind == LineBreak::Mandatory {
170                        line_ranges.push((line_start, i));
171                        line_start = i;
172                        cursor_x = 0.0;
173                        last_safe = None;
174                        width_at_break = 0.0;
175                        continue;
176                    } else {
177                        last_safe = Some(i);
178                        width_at_break = cursor_x;
179                    }
180                }
181            }
182
183            if wrap && cursor_x + adv > max_width && i > line_start {
184                if let Some(brk) = last_safe {
185                    if brk > line_start {
186                        line_ranges.push((line_start, brk));
187                        line_start = brk;
188                        cursor_x -= width_at_break;
189                        last_safe = None;
190                        width_at_break = 0.0;
191                        continue;
192                    }
193                }
194                // Hard-break fallback.
195                line_ranges.push((line_start, i));
196                line_start = i;
197                cursor_x = 0.0;
198                last_safe = None;
199                width_at_break = 0.0;
200                continue;
201            }
202
203            cursor_x += adv;
204            i += 1;
205        }
206        if line_start < flat.len() {
207            line_ranges.push((line_start, flat.len()));
208        } else if line_ranges.is_empty() {
209            line_ranges.push((0, 0));
210        }
211        if line_ranges.is_empty() {
212            line_ranges.push((0, 0));
213        }
214
215        // ------------------------------------------------------------------
216        // Step 4-6: Position glyphs with baseline alignment per line.
217        // ------------------------------------------------------------------
218        let mut positioned: Vec<PositionedGlyph> = Vec::with_capacity(flat.len());
219        let mut lines: Vec<Line> = Vec::with_capacity(line_ranges.len());
220        let mut cursor_y = 0.0f32;
221        let mut total_width = 0.0f32;
222
223        for &(start, end) in &line_ranges {
224            let line_slice = &flat[start..end];
225
226            // Compute line-wide max ascender and descender.
227            let line_ascender = line_slice
228                .iter()
229                .map(|fe| fe.run_ascent)
230                .fold(0.0f32, f32::max);
231            let line_descender = line_slice
232                .iter()
233                .map(|fe| fe.run_descent)
234                .fold(0.0f32, f32::max);
235            let line_gap = if line_slice.is_empty() {
236                0.0
237            } else {
238                // Take the max line-gap across runs; use first run's metrics for gap.
239                runs.iter()
240                    .map(|r| r.metrics.line_gap_px(r.px_size))
241                    .fold(0.0f32, f32::max)
242            };
243
244            let baseline_y = cursor_y + line_ascender;
245            let glyph_start = positioned.len();
246            let mut pen_x = 0.0f32;
247            let mut line_width = 0.0f32;
248
249            for fe in line_slice {
250                // Shift glyph up/down so its own ascender aligns with line ascender.
251                let y_shift = line_ascender - fe.run_ascent;
252                // Apply sub/superscript baseline adjustment (positive = upward, so subtract from y).
253                let vp_adjust = fe.vertical_position.baseline_adjustment(fe.px_size);
254                let glyph_y = baseline_y + y_shift + fe.glyph.y_offset - vp_adjust;
255                let effective_font_size = fe.vertical_position.effective_size(fe.px_size);
256
257                positioned.push(PositionedGlyph {
258                    gid: fe.glyph.gid,
259                    font_data: Arc::clone(fe.font_data),
260                    pos: (pen_x + fe.glyph.x_offset, glyph_y),
261                    font_size: effective_font_size,
262                    advance_x: fe.glyph.x_advance,
263                    cluster: fe.glyph.cluster,
264                });
265
266                pen_x += fe.glyph.x_advance;
267                if !fe.glyph.is_whitespace {
268                    line_width = pen_x;
269                }
270            }
271
272            total_width = total_width.max(line_width);
273
274            lines.push(Line {
275                glyph_start,
276                glyph_end: positioned.len(),
277                metrics: LineMetrics {
278                    ascent: line_ascender,
279                    descent: line_descender,
280                    leading: line_gap,
281                    baseline_y,
282                    width: line_width,
283                },
284            });
285
286            cursor_y += line_ascender + line_descender + line_gap + options.paragraph_spacing;
287        }
288
289        let total_height = cursor_y;
290
291        Ok(LayoutResult {
292            glyphs: positioned,
293            lines,
294            metrics: ParagraphMetrics {
295                total_height,
296                total_width,
297                line_count: line_ranges.len(),
298                overflow: false,
299                truncated: false,
300            },
301            decorations: Vec::new(),
302            inline_objects: Vec::new(),
303        })
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use oxitext_core::{FontVerticalMetrics, Rgba8, ShapedGlyph, VerticalPosition};
311    use std::sync::Arc;
312
313    fn make_shaped_glyph(cluster: u32, x_advance: f32, is_whitespace: bool) -> ShapedGlyph {
314        ShapedGlyph {
315            gid: 1,
316            x_advance,
317            y_advance: 0.0,
318            x_offset: 0.0,
319            y_offset: 0.0,
320            cluster,
321            is_whitespace,
322            unsafe_to_break: false,
323        }
324    }
325
326    /// Produce font metrics with a specific ascender ratio.
327    fn make_metrics(ascender: i16, descender: i16) -> FontVerticalMetrics {
328        FontVerticalMetrics {
329            units_per_em: 1000,
330            ascender,
331            descender,
332            line_gap: 0,
333        }
334    }
335
336    #[test]
337    fn test_multistyle_baseline_alignment() {
338        // Two runs with different sizes but identical normalised metrics.
339        // big_run: px_size=20  → ascent = 800/1000 * 20 = 16.0 px, descent = 200/1000 * 20 = 4.0 px
340        // small_run: px_size=10 → ascent = 8.0 px, descent = 2.0 px
341        // On a shared line: line_ascender = max(16.0, 8.0) = 16.0.
342        // big_run glyphs: y_shift = 16.0 - 16.0 = 0.0  → baseline_y + 0
343        // small_run glyphs: y_shift = 16.0 - 8.0 = 8.0 → baseline_y + 8.0
344        // Both conceptual "tops" land at baseline_y + y_shift - run_ascent = baseline_y - run_ascent:
345        //   big:   baseline_y + 0.0 - 16.0 = baseline_y - 16.0
346        //   small: baseline_y + 8.0 - 8.0  = baseline_y - 8.0 ← correctly lifts smaller text up
347        //
348        // More concisely: the *bottom* of each run's ascent zone lines up at baseline_y.
349
350        let big_metrics = make_metrics(800, -200);
351        let small_metrics = make_metrics(800, -200);
352
353        let big_run = StyledRun {
354            glyphs: vec![
355                make_shaped_glyph(0, 20.0, false),
356                make_shaped_glyph(1, 20.0, false),
357            ],
358            metrics: big_metrics,
359            px_size: 20.0,
360            color: Rgba8::BLACK,
361            font_data: Arc::from(&[][..]),
362            vertical_position: VerticalPosition::Normal,
363        };
364        let small_run = StyledRun {
365            glyphs: vec![
366                make_shaped_glyph(2, 10.0, false),
367                make_shaped_glyph(3, 10.0, false),
368            ],
369            metrics: small_metrics,
370            px_size: 10.0,
371            color: Rgba8::new(255, 0, 0, 255),
372            font_data: Arc::from(&[][..]),
373            vertical_position: VerticalPosition::Normal,
374        };
375
376        let runs = [big_run, small_run];
377        let source_text = "abcd";
378        let mut engine = LayoutEngine::new();
379        let opts = LayoutOptions::default();
380        let result = engine
381            .layout_styled_runs(&runs, source_text, 1000.0, &opts)
382            .expect("layout_styled_runs");
383
384        // All 4 glyphs should be placed on a single line.
385        assert_eq!(result.glyphs.len(), 4);
386        assert_eq!(result.lines.len(), 1);
387
388        // Line ascender = 16.0 (big run), line descender = 4.0.
389        let line = &result.lines[0];
390        assert!(
391            (line.metrics.ascent - 16.0).abs() < 1e-3,
392            "line ascent should be 16.0, got {}",
393            line.metrics.ascent
394        );
395
396        // baseline_y = 0 (cursor_y) + 16.0 = 16.0.
397        let baseline_y = line.metrics.baseline_y;
398
399        // Big-run glyphs (indices 0,1): y_shift=0, so pos.y = baseline_y.
400        for gi in 0..2 {
401            assert!(
402                (result.glyphs[gi].pos.1 - baseline_y).abs() < 1e-3,
403                "big-run glyph {} y should equal baseline_y={}, got {}",
404                gi,
405                baseline_y,
406                result.glyphs[gi].pos.1
407            );
408        }
409
410        // Small-run glyphs (indices 2,3): ascent=8, y_shift=8, pos.y = baseline_y+8.
411        let expected_small_y = baseline_y + 8.0;
412        for gi in 2..4 {
413            assert!(
414                (result.glyphs[gi].pos.1 - expected_small_y).abs() < 1e-3,
415                "small-run glyph {} y should be baseline_y+8={}, got {}",
416                gi,
417                expected_small_y,
418                result.glyphs[gi].pos.1
419            );
420        }
421    }
422
423    #[test]
424    fn test_multistyle_single_run_no_shift() {
425        // A single run should produce y == baseline_y (zero shift).
426        let m = make_metrics(800, -200);
427        let run = StyledRun {
428            glyphs: vec![
429                make_shaped_glyph(0, 10.0, false),
430                make_shaped_glyph(1, 10.0, false),
431                make_shaped_glyph(2, 10.0, false),
432            ],
433            metrics: m,
434            px_size: 16.0,
435            color: Rgba8::BLACK,
436            font_data: Arc::from(&[][..]),
437            vertical_position: VerticalPosition::Normal,
438        };
439
440        let source = "abc";
441        let mut engine = LayoutEngine::new();
442        let opts = LayoutOptions::default();
443        let result = engine
444            .layout_styled_runs(&[run], source, 1000.0, &opts)
445            .expect("layout_styled_runs single");
446
447        let baseline_y = result.lines[0].metrics.baseline_y;
448        for g in &result.glyphs {
449            assert!(
450                (g.pos.1 - baseline_y).abs() < 1e-3,
451                "single-run glyph y should equal baseline_y={}, got {}",
452                baseline_y,
453                g.pos.1
454            );
455        }
456    }
457
458    #[test]
459    fn test_multistyle_wraps_at_max_width() {
460        // 4 glyphs × 10px; max_width=25 → wraps after 2 glyphs.
461        let m = make_metrics(800, -200);
462        let run = StyledRun {
463            glyphs: (0..4)
464                .map(|i| make_shaped_glyph(i as u32, 10.0, false))
465                .collect(),
466            metrics: m,
467            px_size: 12.0,
468            color: Rgba8::BLACK,
469            font_data: Arc::from(&[][..]),
470            vertical_position: VerticalPosition::Normal,
471        };
472
473        let source = "abcd";
474        let mut engine = LayoutEngine::new();
475        let opts = LayoutOptions::default();
476        let result = engine
477            .layout_styled_runs(&[run], source, 25.0, &opts)
478            .expect("layout_styled_runs wrap");
479
480        assert!(result.lines.len() >= 2, "expected wrapping");
481        assert_eq!(result.glyphs.len(), 4);
482    }
483
484    #[test]
485    fn test_multistyle_empty_runs() {
486        // Zero runs should produce an empty result with one (empty) line.
487        let mut engine = LayoutEngine::new();
488        let opts = LayoutOptions::default();
489        let result = engine
490            .layout_styled_runs(&[], "", 400.0, &opts)
491            .expect("layout_styled_runs empty");
492        assert_eq!(result.glyphs.len(), 0);
493        assert_eq!(result.lines.len(), 1);
494    }
495}