use oxitext_core::{PositionedGlyph, ShapedGlyph};
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RubyPosition {
Above,
Below,
}
#[derive(Debug, Clone)]
pub struct RubyAnnotation {
pub base_range: std::ops::Range<usize>,
pub ruby_text: String,
pub position: RubyPosition,
}
#[derive(Debug, Clone)]
pub struct RubyLayout {
pub base_glyphs: Vec<PositionedGlyph>,
pub ruby_glyphs: Vec<PositionedGlyph>,
pub ruby_y_offset: f32,
pub extra_line_height: f32,
}
pub fn layout_ruby(
base_glyphs: &[PositionedGlyph],
ruby_shaped: &[ShapedGlyph],
annotation: &RubyAnnotation,
ruby_px_size: f32,
base_line_height: f32,
) -> RubyLayout {
let range_start = annotation.base_range.start as u32;
let range_end = annotation.base_range.end as u32;
let span_glyphs: Vec<&PositionedGlyph> = base_glyphs
.iter()
.filter(|g| g.cluster >= range_start && g.cluster < range_end)
.collect();
let (x_start, x_end) = if span_glyphs.is_empty() {
let fallback_x = base_glyphs.first().map_or(0.0, |g| g.pos.0);
(fallback_x, fallback_x)
} else {
let x_start = span_glyphs.iter().map(|g| g.pos.0).fold(f32::MAX, f32::min);
let x_end = span_glyphs
.iter()
.map(|g| g.pos.0 + g.advance_x)
.fold(f32::MIN, f32::max);
(x_start, x_end)
};
let span_width = x_end - x_start;
let ruby_width: f32 = ruby_shaped.iter().map(|g| g.x_advance).sum();
let ruby_start_x = x_start + (span_width - ruby_width) * 0.5;
let (ruby_y_offset, extra_line_height) = match annotation.position {
RubyPosition::Above => {
let offset = -(base_line_height + ruby_px_size * 0.2);
let extra = ruby_px_size * 1.2;
(offset, extra)
}
RubyPosition::Below => {
let offset = base_line_height + ruby_px_size * 0.2;
let extra = ruby_px_size * 1.2;
(offset, extra)
}
};
let ruby_font_data: Arc<[u8]> = base_glyphs
.first()
.map_or_else(|| Arc::from(&[][..]), |g| Arc::clone(&g.font_data));
let mut ruby_glyphs = Vec::with_capacity(ruby_shaped.len());
let mut ruby_x = ruby_start_x;
for g in ruby_shaped {
let advance = g.x_advance;
ruby_glyphs.push(PositionedGlyph {
gid: g.gid,
font_data: Arc::clone(&ruby_font_data),
pos: (ruby_x, ruby_y_offset),
font_size: ruby_px_size,
advance_x: advance,
cluster: annotation.base_range.start as u32,
});
ruby_x += advance;
}
RubyLayout {
base_glyphs: base_glyphs.to_vec(),
ruby_glyphs,
ruby_y_offset,
extra_line_height,
}
}
#[cfg(test)]
mod tests {
use super::*;
use oxitext_core::ShapedGlyph;
fn make_base_glyph(gid: u16, x: f32, advance: f32, cluster: u32) -> PositionedGlyph {
PositionedGlyph {
gid,
font_data: Arc::from(&[][..]),
pos: (x, 0.0),
font_size: 16.0,
advance_x: advance,
cluster,
}
}
fn make_ruby_shaped(gid: u16, x_advance: f32) -> ShapedGlyph {
ShapedGlyph {
gid,
x_advance,
..Default::default()
}
}
#[test]
fn ruby_above_single_glyph_centered() {
let base = vec![make_base_glyph(1, 10.0, 20.0, 0)];
let ruby = vec![make_ruby_shaped(2, 10.0)];
let ann = RubyAnnotation {
base_range: 0..1,
ruby_text: "ふ".into(),
position: RubyPosition::Above,
};
let result = layout_ruby(&base, &ruby, &ann, 8.0, 20.0);
assert_eq!(result.ruby_glyphs.len(), 1);
let rx = result.ruby_glyphs[0].pos.0;
assert!(
(rx - 15.0).abs() < 1e-4,
"ruby x should be centred at 15.0, got {rx}"
);
}
#[test]
fn ruby_above_y_offset_is_negative() {
let base = vec![make_base_glyph(1, 0.0, 20.0, 0)];
let ruby = vec![make_ruby_shaped(2, 10.0)];
let ann = RubyAnnotation {
base_range: 0..1,
ruby_text: "あ".into(),
position: RubyPosition::Above,
};
let result = layout_ruby(&base, &ruby, &ann, 8.0, 20.0);
assert!(
result.ruby_y_offset < 0.0,
"Above position should have negative y_offset, got {}",
result.ruby_y_offset
);
}
#[test]
fn ruby_below_y_offset_is_positive() {
let base = vec![make_base_glyph(1, 0.0, 20.0, 0)];
let ruby = vec![make_ruby_shaped(2, 10.0)];
let ann = RubyAnnotation {
base_range: 0..1,
ruby_text: "あ".into(),
position: RubyPosition::Below,
};
let result = layout_ruby(&base, &ruby, &ann, 8.0, 20.0);
assert!(
result.ruby_y_offset > 0.0,
"Below position should have positive y_offset, got {}",
result.ruby_y_offset
);
}
#[test]
fn extra_line_height_always_positive() {
let base = vec![make_base_glyph(1, 0.0, 20.0, 0)];
let ruby = vec![make_ruby_shaped(2, 10.0)];
for pos in [RubyPosition::Above, RubyPosition::Below] {
let ann = RubyAnnotation {
base_range: 0..1,
ruby_text: "x".into(),
position: pos,
};
let result = layout_ruby(&base, &ruby, &ann, 8.0, 20.0);
assert!(
result.extra_line_height > 0.0,
"extra_line_height should be positive"
);
}
}
#[test]
fn ruby_glyphs_preserve_cluster() {
let base = vec![make_base_glyph(1, 5.0, 30.0, 3)];
let ruby = vec![make_ruby_shaped(2, 15.0), make_ruby_shaped(3, 15.0)];
let ann = RubyAnnotation {
base_range: 3..6,
ruby_text: "ふに".into(),
position: RubyPosition::Above,
};
let result = layout_ruby(&base, &ruby, &ann, 8.0, 20.0);
for rg in &result.ruby_glyphs {
assert_eq!(rg.cluster, 3, "ruby cluster should match base_range.start");
}
}
#[test]
fn ruby_glyphs_advance_incrementally() {
let base = vec![make_base_glyph(1, 0.0, 40.0, 0)];
let ruby = vec![make_ruby_shaped(2, 10.0), make_ruby_shaped(3, 10.0)];
let ann = RubyAnnotation {
base_range: 0..1,
ruby_text: "ab".into(),
position: RubyPosition::Above,
};
let result = layout_ruby(&base, &ruby, &ann, 8.0, 20.0);
assert_eq!(result.ruby_glyphs.len(), 2);
let x0 = result.ruby_glyphs[0].pos.0;
let x1 = result.ruby_glyphs[1].pos.0;
assert!(
(x1 - x0 - 10.0).abs() < 1e-4,
"second ruby glyph should be 10px right of first; got x0={x0}, x1={x1}"
);
}
#[test]
fn base_glyphs_unchanged() {
let base = vec![
make_base_glyph(1, 0.0, 20.0, 0),
make_base_glyph(2, 20.0, 20.0, 2),
];
let ruby = vec![make_ruby_shaped(3, 10.0)];
let ann = RubyAnnotation {
base_range: 0..2,
ruby_text: "x".into(),
position: RubyPosition::Above,
};
let result = layout_ruby(&base, &ruby, &ann, 8.0, 20.0);
assert_eq!(result.base_glyphs.len(), 2);
assert_eq!(result.base_glyphs[0].pos.0, 0.0);
assert_eq!(result.base_glyphs[1].pos.0, 20.0);
}
#[test]
fn empty_ruby_shaped() {
let base = vec![make_base_glyph(1, 0.0, 20.0, 0)];
let ruby: Vec<ShapedGlyph> = vec![];
let ann = RubyAnnotation {
base_range: 0..1,
ruby_text: String::new(),
position: RubyPosition::Above,
};
let result = layout_ruby(&base, &ruby, &ann, 8.0, 20.0);
assert!(result.ruby_glyphs.is_empty());
}
}