Skip to main content

oxitext_layout/
ruby.rs

1//! Ruby annotation layout for CJK furigana and phonetic glosses.
2//!
3//! Ruby (ルビ) is a typographic convention used in East Asian text to place
4//! small pronunciation or reading guides above (or below) base characters.
5//! This module provides the data types and positioning algorithm for rendering
6//! ruby annotations on top of an already-shaped base text.
7//!
8//! # Usage
9//!
10//! 1. Shape both the base text and the ruby text (using any shaper).
11//! 2. Call [`layout_ruby`] with the base glyphs and ruby shaped glyphs.
12//! 3. Render [`RubyLayout::base_glyphs`] normally, then render
13//!    [`RubyLayout::ruby_glyphs`] with the y-offset from
14//!    [`RubyLayout::ruby_y_offset`].
15//!
16//! The calling code is responsible for increasing the line height by
17//! [`RubyLayout::extra_line_height`] to avoid overlapping adjacent lines.
18
19use oxitext_core::{PositionedGlyph, ShapedGlyph};
20use std::sync::Arc;
21
22/// Where the ruby annotation is placed relative to the base text.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum RubyPosition {
25    /// Place the annotation above the base text (default for horizontal CJK).
26    Above,
27    /// Place the annotation below the base text.
28    Below,
29}
30
31/// A ruby (furigana) annotation attached to a span of base text.
32#[derive(Debug, Clone)]
33pub struct RubyAnnotation {
34    /// Byte range in the base text this annotation applies to.
35    ///
36    /// These byte offsets index into the original UTF-8 source string and
37    /// correspond to [`oxitext_core::PositionedGlyph::cluster`] values.
38    pub base_range: std::ops::Range<usize>,
39    /// The annotation text (e.g. a furigana pronunciation reading).
40    pub ruby_text: String,
41    /// Whether to place the annotation above or below the base text.
42    pub position: RubyPosition,
43}
44
45/// The result of a ruby layout pass.
46///
47/// Contains the base glyphs (unchanged from input) alongside newly positioned
48/// ruby glyphs that are centered over their base span.
49#[derive(Debug, Clone)]
50pub struct RubyLayout {
51    /// Base glyphs positioned as normal (same as the input `base_glyphs`).
52    pub base_glyphs: Vec<PositionedGlyph>,
53    /// Ruby glyphs centered horizontally over the base span.
54    ///
55    /// These glyphs should be rendered at `(x, y + ruby_y_offset)` relative
56    /// to the line baseline.
57    pub ruby_glyphs: Vec<PositionedGlyph>,
58    /// Y offset for ruby glyphs from the line baseline.
59    ///
60    /// Negative for [`RubyPosition::Above`] (ruby is above the baseline),
61    /// positive for [`RubyPosition::Below`].
62    pub ruby_y_offset: f32,
63    /// Extra vertical space needed above (or below) the line to accommodate
64    /// the ruby text without overlapping neighbouring lines.
65    pub extra_line_height: f32,
66}
67
68/// Compute ruby layout given pre-shaped base glyphs and ruby glyphs.
69///
70/// # Arguments
71///
72/// - `base_glyphs` — already-shaped and positioned glyphs for the base text.
73///   Must not be empty when `annotation.base_range` is non-empty; the function
74///   will return an empty ruby result if `base_glyphs` is empty.
75/// - `ruby_shaped` — already-shaped (but not yet positioned) glyphs for the
76///   ruby text.  `x_advance` values are interpreted as pixel advances scaled
77///   by `ruby_px_size` (i.e. the shaper was called with `ruby_px_size` as the
78///   font size).
79/// - `annotation` — the annotation metadata (base byte range + position).
80/// - `ruby_px_size` — font size in pixels used to shape the ruby text.
81///   Typically `base_px_size * 0.5`.
82/// - `base_line_height` — height of the base line in pixels.  Used to compute
83///   the y offset so that the ruby does not overlap the base glyphs.
84///
85/// # Returns
86///
87/// A [`RubyLayout`] containing the base glyphs unchanged and the ruby glyphs
88/// positioned horizontally centred over the base span.
89pub fn layout_ruby(
90    base_glyphs: &[PositionedGlyph],
91    ruby_shaped: &[ShapedGlyph],
92    annotation: &RubyAnnotation,
93    ruby_px_size: f32,
94    base_line_height: f32,
95) -> RubyLayout {
96    // --- Step 1: find base glyphs that belong to annotation.base_range ---
97    let range_start = annotation.base_range.start as u32;
98    let range_end = annotation.base_range.end as u32;
99
100    // Collect base glyphs whose cluster falls within [base_range.start, base_range.end)
101    let span_glyphs: Vec<&PositionedGlyph> = base_glyphs
102        .iter()
103        .filter(|g| g.cluster >= range_start && g.cluster < range_end)
104        .collect();
105
106    // --- Step 2: compute x span of the base ---
107    let (x_start, x_end) = if span_glyphs.is_empty() {
108        // Fallback: use the start of the first base glyph or 0.0
109        let fallback_x = base_glyphs.first().map_or(0.0, |g| g.pos.0);
110        (fallback_x, fallback_x)
111    } else {
112        let x_start = span_glyphs.iter().map(|g| g.pos.0).fold(f32::MAX, f32::min);
113        let x_end = span_glyphs
114            .iter()
115            .map(|g| g.pos.0 + g.advance_x)
116            .fold(f32::MIN, f32::max);
117        (x_start, x_end)
118    };
119    let span_width = x_end - x_start;
120
121    // --- Step 3: compute total ruby width ---
122    // x_advance values are already in pixels (shaped at ruby_px_size)
123    let ruby_width: f32 = ruby_shaped.iter().map(|g| g.x_advance).sum();
124
125    // --- Step 4: centre the ruby glyphs over the base span ---
126    let ruby_start_x = x_start + (span_width - ruby_width) * 0.5;
127
128    // --- Step 5 & 6: compute y offset based on position ---
129    let (ruby_y_offset, extra_line_height) = match annotation.position {
130        RubyPosition::Above => {
131            let offset = -(base_line_height + ruby_px_size * 0.2);
132            let extra = ruby_px_size * 1.2;
133            (offset, extra)
134        }
135        RubyPosition::Below => {
136            let offset = base_line_height + ruby_px_size * 0.2;
137            let extra = ruby_px_size * 1.2;
138            (offset, extra)
139        }
140    };
141
142    // --- Step 7 & 8: position each ruby glyph ---
143    // Derive font_data from the base glyphs if available, otherwise use an
144    // empty placeholder (the caller should ensure base_glyphs is non-empty
145    // for meaningful output).
146    let ruby_font_data: Arc<[u8]> = base_glyphs
147        .first()
148        .map_or_else(|| Arc::from(&[][..]), |g| Arc::clone(&g.font_data));
149
150    let mut ruby_glyphs = Vec::with_capacity(ruby_shaped.len());
151    let mut ruby_x = ruby_start_x;
152
153    for g in ruby_shaped {
154        let advance = g.x_advance;
155        ruby_glyphs.push(PositionedGlyph {
156            gid: g.gid,
157            font_data: Arc::clone(&ruby_font_data),
158            pos: (ruby_x, ruby_y_offset),
159            font_size: ruby_px_size,
160            advance_x: advance,
161            cluster: annotation.base_range.start as u32,
162        });
163        ruby_x += advance;
164    }
165
166    RubyLayout {
167        base_glyphs: base_glyphs.to_vec(),
168        ruby_glyphs,
169        ruby_y_offset,
170        extra_line_height,
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use oxitext_core::ShapedGlyph;
178
179    fn make_base_glyph(gid: u16, x: f32, advance: f32, cluster: u32) -> PositionedGlyph {
180        PositionedGlyph {
181            gid,
182            font_data: Arc::from(&[][..]),
183            pos: (x, 0.0),
184            font_size: 16.0,
185            advance_x: advance,
186            cluster,
187        }
188    }
189
190    fn make_ruby_shaped(gid: u16, x_advance: f32) -> ShapedGlyph {
191        ShapedGlyph {
192            gid,
193            x_advance,
194            ..Default::default()
195        }
196    }
197
198    #[test]
199    fn ruby_above_single_glyph_centered() {
200        // Base: glyph at x=10, advance=20 (x span 10..30)
201        // Ruby: glyph with advance=10 → should centre at x=15
202        let base = vec![make_base_glyph(1, 10.0, 20.0, 0)];
203        let ruby = vec![make_ruby_shaped(2, 10.0)];
204        let ann = RubyAnnotation {
205            base_range: 0..1,
206            ruby_text: "ふ".into(),
207            position: RubyPosition::Above,
208        };
209        let result = layout_ruby(&base, &ruby, &ann, 8.0, 20.0);
210
211        assert_eq!(result.ruby_glyphs.len(), 1);
212        // Expected: ruby_start_x = 10 + (20 - 10) / 2 = 15
213        let rx = result.ruby_glyphs[0].pos.0;
214        assert!(
215            (rx - 15.0).abs() < 1e-4,
216            "ruby x should be centred at 15.0, got {rx}"
217        );
218    }
219
220    #[test]
221    fn ruby_above_y_offset_is_negative() {
222        let base = vec![make_base_glyph(1, 0.0, 20.0, 0)];
223        let ruby = vec![make_ruby_shaped(2, 10.0)];
224        let ann = RubyAnnotation {
225            base_range: 0..1,
226            ruby_text: "あ".into(),
227            position: RubyPosition::Above,
228        };
229        let result = layout_ruby(&base, &ruby, &ann, 8.0, 20.0);
230        assert!(
231            result.ruby_y_offset < 0.0,
232            "Above position should have negative y_offset, got {}",
233            result.ruby_y_offset
234        );
235    }
236
237    #[test]
238    fn ruby_below_y_offset_is_positive() {
239        let base = vec![make_base_glyph(1, 0.0, 20.0, 0)];
240        let ruby = vec![make_ruby_shaped(2, 10.0)];
241        let ann = RubyAnnotation {
242            base_range: 0..1,
243            ruby_text: "あ".into(),
244            position: RubyPosition::Below,
245        };
246        let result = layout_ruby(&base, &ruby, &ann, 8.0, 20.0);
247        assert!(
248            result.ruby_y_offset > 0.0,
249            "Below position should have positive y_offset, got {}",
250            result.ruby_y_offset
251        );
252    }
253
254    #[test]
255    fn extra_line_height_always_positive() {
256        let base = vec![make_base_glyph(1, 0.0, 20.0, 0)];
257        let ruby = vec![make_ruby_shaped(2, 10.0)];
258
259        for pos in [RubyPosition::Above, RubyPosition::Below] {
260            let ann = RubyAnnotation {
261                base_range: 0..1,
262                ruby_text: "x".into(),
263                position: pos,
264            };
265            let result = layout_ruby(&base, &ruby, &ann, 8.0, 20.0);
266            assert!(
267                result.extra_line_height > 0.0,
268                "extra_line_height should be positive"
269            );
270        }
271    }
272
273    #[test]
274    fn ruby_glyphs_preserve_cluster() {
275        let base = vec![make_base_glyph(1, 5.0, 30.0, 3)];
276        let ruby = vec![make_ruby_shaped(2, 15.0), make_ruby_shaped(3, 15.0)];
277        let ann = RubyAnnotation {
278            base_range: 3..6,
279            ruby_text: "ふに".into(),
280            position: RubyPosition::Above,
281        };
282        let result = layout_ruby(&base, &ruby, &ann, 8.0, 20.0);
283        for rg in &result.ruby_glyphs {
284            assert_eq!(rg.cluster, 3, "ruby cluster should match base_range.start");
285        }
286    }
287
288    #[test]
289    fn ruby_glyphs_advance_incrementally() {
290        // Two ruby glyphs of equal width → second should be advance further right
291        let base = vec![make_base_glyph(1, 0.0, 40.0, 0)];
292        let ruby = vec![make_ruby_shaped(2, 10.0), make_ruby_shaped(3, 10.0)];
293        let ann = RubyAnnotation {
294            base_range: 0..1,
295            ruby_text: "ab".into(),
296            position: RubyPosition::Above,
297        };
298        let result = layout_ruby(&base, &ruby, &ann, 8.0, 20.0);
299        assert_eq!(result.ruby_glyphs.len(), 2);
300        let x0 = result.ruby_glyphs[0].pos.0;
301        let x1 = result.ruby_glyphs[1].pos.0;
302        assert!(
303            (x1 - x0 - 10.0).abs() < 1e-4,
304            "second ruby glyph should be 10px right of first; got x0={x0}, x1={x1}"
305        );
306    }
307
308    #[test]
309    fn base_glyphs_unchanged() {
310        let base = vec![
311            make_base_glyph(1, 0.0, 20.0, 0),
312            make_base_glyph(2, 20.0, 20.0, 2),
313        ];
314        let ruby = vec![make_ruby_shaped(3, 10.0)];
315        let ann = RubyAnnotation {
316            base_range: 0..2,
317            ruby_text: "x".into(),
318            position: RubyPosition::Above,
319        };
320        let result = layout_ruby(&base, &ruby, &ann, 8.0, 20.0);
321        assert_eq!(result.base_glyphs.len(), 2);
322        assert_eq!(result.base_glyphs[0].pos.0, 0.0);
323        assert_eq!(result.base_glyphs[1].pos.0, 20.0);
324    }
325
326    #[test]
327    fn empty_ruby_shaped() {
328        // No ruby glyphs → empty ruby_glyphs, but layout should not panic
329        let base = vec![make_base_glyph(1, 0.0, 20.0, 0)];
330        let ruby: Vec<ShapedGlyph> = vec![];
331        let ann = RubyAnnotation {
332            base_range: 0..1,
333            ruby_text: String::new(),
334            position: RubyPosition::Above,
335        };
336        let result = layout_ruby(&base, &ruby, &ann, 8.0, 20.0);
337        assert!(result.ruby_glyphs.is_empty());
338    }
339}