1use oxitext_core::{PositionedGlyph, ShapedGlyph};
20use std::sync::Arc;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum RubyPosition {
25 Above,
27 Below,
29}
30
31#[derive(Debug, Clone)]
33pub struct RubyAnnotation {
34 pub base_range: std::ops::Range<usize>,
39 pub ruby_text: String,
41 pub position: RubyPosition,
43}
44
45#[derive(Debug, Clone)]
50pub struct RubyLayout {
51 pub base_glyphs: Vec<PositionedGlyph>,
53 pub ruby_glyphs: Vec<PositionedGlyph>,
58 pub ruby_y_offset: f32,
63 pub extra_line_height: f32,
66}
67
68pub 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 let range_start = annotation.base_range.start as u32;
98 let range_end = annotation.base_range.end as u32;
99
100 let span_glyphs: Vec<&PositionedGlyph> = base_glyphs
102 .iter()
103 .filter(|g| g.cluster >= range_start && g.cluster < range_end)
104 .collect();
105
106 let (x_start, x_end) = if span_glyphs.is_empty() {
108 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 let ruby_width: f32 = ruby_shaped.iter().map(|g| g.x_advance).sum();
124
125 let ruby_start_x = x_start + (span_width - ruby_width) * 0.5;
127
128 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 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 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 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 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 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}