use rustybuzz::{Face, UnicodeBuffer};
use thiserror::Error;
use crate::render::dimension::Pt;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ShapedGlyph {
pub id: u16,
pub advance: Pt,
pub x_offset: Pt,
pub y_offset: Pt,
pub cluster_byte: u32,
}
#[derive(Clone, Debug)]
pub struct ShapedRun {
pub glyphs: Vec<ShapedGlyph>,
pub total_advance: Pt,
}
#[derive(Debug, Error)]
pub enum ShapeError {
#[error("rustybuzz refused to parse the font bytes")]
InvalidFontData,
#[error("font reports zero units_per_em β cannot scale glyph positions")]
ZeroUnitsPerEm,
#[error("glyph id {0} exceeds Skia's u16 range")]
GlyphIdOutOfRange(u32),
}
pub fn shape_text(font_bytes: &[u8], text: &str, size_px: f32) -> Result<ShapedRun, ShapeError> {
let face = Face::from_slice(font_bytes, 0).ok_or(ShapeError::InvalidFontData)?;
let upem = face.units_per_em();
if upem <= 0 {
return Err(ShapeError::ZeroUnitsPerEm);
}
let scale = size_px / upem as f32;
let mut buf = UnicodeBuffer::new();
buf.push_str(text);
let glyph_buf = rustybuzz::shape(&face, &[], buf);
let infos = glyph_buf.glyph_infos();
let positions = glyph_buf.glyph_positions();
let mut glyphs = Vec::with_capacity(infos.len());
let mut total = 0.0f32;
for (info, pos) in infos.iter().zip(positions.iter()) {
let id_u16 = u16::try_from(info.glyph_id)
.map_err(|_| ShapeError::GlyphIdOutOfRange(info.glyph_id))?;
let advance = pos.x_advance as f32 * scale;
let x_offset = pos.x_offset as f32 * scale;
let y_offset = pos.y_offset as f32 * scale;
glyphs.push(ShapedGlyph {
id: id_u16,
advance: Pt::new(advance),
x_offset: Pt::new(x_offset),
y_offset: Pt::new(y_offset),
cluster_byte: info.cluster,
});
total += advance;
}
Ok(ShapedRun {
glyphs,
total_advance: Pt::new(total),
})
}
#[cfg(test)]
mod tests {
use super::*;
use skia_safe::{FontMgr, FontStyle, Typeface};
fn any_system_font_bytes() -> Option<Vec<u8>> {
let mgr = FontMgr::new();
let tf = mgr.legacy_make_typeface(None::<&str>, FontStyle::normal())?;
tf.to_font_data().map(|(b, _)| b)
}
fn host_emoji_font_bytes() -> Option<Vec<u8>> {
let mgr = FontMgr::new();
for name in [
"Apple Color Emoji",
"Segoe UI Emoji",
"Noto Color Emoji",
"Twitter Color Emoji",
] {
if let Some(tf) = mgr.match_family_style(name, FontStyle::normal()) {
if tf.family_name().eq_ignore_ascii_case(name) {
if let Some((bytes, _)) = tf.to_font_data() {
return Some(bytes);
}
}
}
}
None
}
fn skia_typeface_for(bytes: &[u8]) -> Typeface {
let mgr = FontMgr::new();
mgr.new_from_data(&skia_safe::Data::new_copy(bytes), 0)
.expect("skia must accept the same bytes rustybuzz parses")
}
#[test]
fn y1_ascii_does_not_ligate() {
let bytes = match any_system_font_bytes() {
Some(b) => b,
None => {
eprintln!("skipping Y1: no system typeface");
return;
}
};
let run = shape_text(&bytes, "abc", 24.0).expect("shape");
assert_eq!(run.glyphs.len(), 3, "ASCII text must not ligate");
for g in &run.glyphs {
assert!(g.advance.raw() > 0.0);
}
}
#[test]
fn y2_keycap_ligates_to_one_glyph() {
let bytes = match host_emoji_font_bytes() {
Some(b) => b,
None => {
eprintln!("skipping Y2: no host color emoji typeface");
return;
}
};
let run = shape_text(&bytes, "1\u{FE0F}\u{20E3}", 24.0).expect("shape");
assert_eq!(
run.glyphs.len(),
1,
"keycap must ligate to one glyph (got {} glyphs)",
run.glyphs.len()
);
assert!(run.glyphs[0].advance.raw() > 0.0);
}
#[test]
fn y3_modifier_sequence_ligates() {
let bytes = match host_emoji_font_bytes() {
Some(b) => b,
None => {
eprintln!("skipping Y3: no host color emoji typeface");
return;
}
};
let run = shape_text(&bytes, "\u{1F44D}\u{1F3FF}", 24.0).expect("shape");
assert_eq!(run.glyphs.len(), 1, "modifier sequence must ligate");
}
#[test]
fn y4_zwj_family_ligates() {
let bytes = match host_emoji_font_bytes() {
Some(b) => b,
None => {
eprintln!("skipping Y4: no host color emoji typeface");
return;
}
};
let run =
shape_text(&bytes, "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}", 24.0).expect("shape");
assert_eq!(run.glyphs.len(), 1, "ZWJ family must ligate");
}
#[test]
fn y5_invalid_font_bytes_errors() {
let garbage = vec![0xFFu8; 64];
let result = shape_text(&garbage, "hi", 12.0);
assert!(matches!(result, Err(ShapeError::InvalidFontData)));
}
#[test]
fn y6_empty_text_yields_empty_run() {
let bytes = match any_system_font_bytes() {
Some(b) => b,
None => {
eprintln!("skipping Y6: no system typeface");
return;
}
};
let run = shape_text(&bytes, "", 24.0).expect("shape");
assert!(run.glyphs.is_empty());
assert_eq!(run.total_advance.raw(), 0.0);
}
#[test]
fn y7_glyph_ids_are_skia_compatible() {
let bytes = match any_system_font_bytes() {
Some(b) => b,
None => {
eprintln!("skipping Y7: no system typeface");
return;
}
};
let tf = skia_typeface_for(&bytes);
let font = skia_safe::Font::from_typeface(tf, 24.0);
let run = shape_text(&bytes, "abc", 24.0).expect("shape");
let ids: Vec<u16> = run.glyphs.iter().map(|g| g.id).collect();
let mut widths = vec![0f32; ids.len()];
font.get_widths(&ids, &mut widths);
for w in widths {
assert!(
w > 0.0,
"every shaped glyph id must have a positive Skia advance"
);
}
}
}