use std::str::FromStr;
use std::sync::Arc;
use harfbuzz_rs::{Direction as HbDirection, Face, Feature, Font as HbFont, Tag, UnicodeBuffer};
use typf_core::{
error::Result,
traits::{FontRef, Shaper, Stage},
types::{Direction, PositionedGlyph, ShapingResult},
ShapingParams,
};
pub use typf_core::shaping_cache::{CacheStats, ShapingCache, ShapingCacheKey, SharedShapingCache};
pub struct HarfBuzzShaper {
cache: Option<SharedShapingCache>,
}
impl HarfBuzzShaper {
pub fn new() -> Self {
Self { cache: None }
}
pub fn with_cache() -> Self {
Self {
cache: Some(Arc::new(std::sync::RwLock::new(ShapingCache::new()))),
}
}
pub fn with_shared_cache(cache: SharedShapingCache) -> Self {
Self { cache: Some(cache) }
}
pub fn cache_stats(&self) -> Option<CacheStats> {
self.cache
.as_ref()
.and_then(|c| c.read().ok())
.map(|c| c.stats())
}
pub fn cache_hit_rate(&self) -> Option<f64> {
self.cache
.as_ref()
.and_then(|c| c.read().ok())
.map(|c| c.hit_rate())
}
fn to_hb_direction(dir: Direction) -> HbDirection {
match dir {
Direction::LeftToRight => HbDirection::Ltr,
Direction::RightToLeft => HbDirection::Rtl,
Direction::TopToBottom => HbDirection::Ttb,
Direction::BottomToTop => HbDirection::Btt,
}
}
}
impl Default for HarfBuzzShaper {
fn default() -> Self {
Self::new()
}
}
impl Stage for HarfBuzzShaper {
fn name(&self) -> &'static str {
"HarfBuzz"
}
fn process(
&self,
ctx: typf_core::context::PipelineContext,
) -> Result<typf_core::context::PipelineContext> {
Ok(ctx)
}
}
impl Shaper for HarfBuzzShaper {
fn name(&self) -> &'static str {
"HarfBuzz"
}
fn shape(
&self,
text: &str,
font: Arc<dyn FontRef>,
params: &ShapingParams,
) -> Result<ShapingResult> {
if text.is_empty() {
return Ok(ShapingResult {
glyphs: Vec::new(),
advance_width: 0.0,
advance_height: params.size,
direction: params.direction,
});
}
let font_data = font.data();
let cache_key = if self.cache.is_some() {
let key = ShapingCacheKey::new(
text,
Shaper::name(self),
font_data,
params.size,
params.language.clone(),
params.script.clone(),
params.features.clone(),
params.variations.clone(),
);
if let Some(ref cache) = self.cache {
if let Ok(cache_guard) = cache.read() {
if let Some(result) = cache_guard.get(&key) {
return Ok(result);
}
}
}
Some(key)
} else {
None
};
if font_data.is_empty() {
let mut glyphs = Vec::new();
let mut x_offset = 0.0;
for ch in text.chars() {
if let Some(glyph_id) = font.glyph_id(ch) {
let advance = font.advance_width(glyph_id);
glyphs.push(PositionedGlyph {
id: glyph_id,
x: x_offset,
y: 0.0,
advance,
cluster: 0,
});
x_offset += advance * params.size / font.units_per_em() as f32;
}
}
let result = ShapingResult {
glyphs,
advance_width: x_offset,
advance_height: params.size,
direction: params.direction,
};
if let Some(key) = cache_key {
if let Some(ref cache) = self.cache {
if let Ok(cache_guard) = cache.write() {
cache_guard.insert(key, result.clone());
}
}
}
return Ok(result);
}
let face = Face::from_bytes(font_data, 0);
let mut hb_font = HbFont::new(face);
let scale = (params.size * 64.0) as i32;
hb_font.set_scale(scale, scale);
if !params.variations.is_empty() {
let variations: Vec<harfbuzz_rs::Variation> = params
.variations
.iter()
.filter_map(|(tag_str, value)| {
if tag_str.len() == 4 {
let bytes = tag_str.as_bytes();
let tag = Tag::new(
bytes[0] as char,
bytes[1] as char,
bytes[2] as char,
bytes[3] as char,
);
Some(harfbuzz_rs::Variation::new(tag, *value))
} else {
None
}
})
.collect();
hb_font.set_variations(&variations);
}
let mut buffer = UnicodeBuffer::new()
.add_str(text)
.set_direction(Self::to_hb_direction(params.direction));
if let Some(ref lang) = params.language {
if let Ok(language) = harfbuzz_rs::Language::from_str(lang) {
buffer = buffer.set_language(language);
}
}
if let Some(ref script_str) = params.script {
if script_str.len() == 4 {
let bytes = script_str.as_bytes();
let tag = Tag::new(
bytes[0] as char,
bytes[1] as char,
bytes[2] as char,
bytes[3] as char,
);
buffer = buffer.set_script(tag);
}
}
let hb_features: Vec<Feature> = params
.features
.iter()
.filter_map(|(name, value)| {
if name.len() == 4 {
let bytes = name.as_bytes();
let tag = Tag::new(
bytes[0] as char,
bytes[1] as char,
bytes[2] as char,
bytes[3] as char,
);
Some(Feature::new(tag, *value, 0..text.len()))
} else {
None
}
})
.collect();
let output = harfbuzz_rs::shape(&hb_font, buffer, &hb_features);
let mut glyphs = Vec::new();
let mut x_offset = 0.0;
let positions = output.get_glyph_positions();
let infos = output.get_glyph_infos();
for (info, pos) in infos.iter().zip(positions.iter()) {
glyphs.push(PositionedGlyph {
id: info.codepoint,
x: x_offset + (pos.x_offset as f32 / 64.0),
y: pos.y_offset as f32 / 64.0,
advance: pos.x_advance as f32 / 64.0,
cluster: info.cluster,
});
x_offset += pos.x_advance as f32 / 64.0;
}
let advance_width = x_offset;
let advance_height = params.size;
let result = ShapingResult {
glyphs,
advance_width,
advance_height,
direction: params.direction,
};
if let Some(key) = cache_key {
if let Some(ref cache) = self.cache {
if let Ok(cache_guard) = cache.write() {
cache_guard.insert(key, result.clone());
}
}
}
Ok(result)
}
fn supports_script(&self, _script: &str) -> bool {
true
}
fn clear_cache(&self) {
if let Some(ref cache) = self.cache {
if let Ok(mut cache_guard) = cache.write() {
*cache_guard = ShapingCache::new();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestFont {
data: Vec<u8>,
}
impl FontRef for TestFont {
fn data(&self) -> &[u8] {
&self.data
}
fn units_per_em(&self) -> u16 {
1000
}
fn glyph_id(&self, ch: char) -> Option<u32> {
Some(ch as u32)
}
fn advance_width(&self, _: u32) -> f32 {
500.0
}
}
#[test]
fn test_empty_text() {
let shaper = HarfBuzzShaper::new();
let font = Arc::new(TestFont { data: vec![] });
let params = ShapingParams::default();
let result = shaper.shape("", font, ¶ms).unwrap();
assert_eq!(result.glyphs.len(), 0);
assert_eq!(result.advance_width, 0.0);
}
#[test]
fn test_simple_text_no_font_data() {
let shaper = HarfBuzzShaper::new();
let font = Arc::new(TestFont { data: vec![] });
let params = ShapingParams::default();
let result = shaper.shape("Hi", font, ¶ms).unwrap();
assert_eq!(result.glyphs.len(), 2);
assert!(result.advance_width > 0.0);
}
#[test]
#[cfg(target_os = "macos")]
fn test_with_system_font() {
use std::fs;
let font_path = "/System/Library/Fonts/Helvetica.ttc";
if let Ok(font_data) = fs::read(font_path) {
let font = Arc::new(TestFont { data: font_data });
let shaper = HarfBuzzShaper::new();
let params = ShapingParams::default();
let result = shaper.shape("Hello, World!", font, ¶ms);
assert!(result.is_ok());
let shaped = result.unwrap();
assert!(shaped.glyphs.len() > 10);
assert!(shaped.advance_width > 0.0);
for glyph in &shaped.glyphs {
assert!(glyph.id > 0);
assert!(glyph.advance > 0.0);
}
}
}
#[test]
#[cfg(target_os = "linux")]
fn test_with_system_font_linux() {
use std::fs;
let font_paths = vec![
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
];
for font_path in font_paths {
if let Ok(font_data) = fs::read(font_path) {
let font = Arc::new(TestFont { data: font_data });
let shaper = HarfBuzzShaper::new();
let params = ShapingParams::default();
let result = shaper.shape("Test", font, ¶ms);
assert!(result.is_ok());
let shaped = result.unwrap();
assert_eq!(shaped.glyphs.len(), 4); assert!(shaped.advance_width > 0.0);
return; }
}
}
#[test]
fn test_complex_text_shaping() {
let shaper = HarfBuzzShaper::new();
let font = Arc::new(TestFont { data: vec![] });
let ltr_params = ShapingParams {
direction: Direction::LeftToRight,
..Default::default()
};
let rtl_params = ShapingParams {
direction: Direction::RightToLeft,
..Default::default()
};
let ltr_result = shaper.shape("abc", font.clone(), <r_params).unwrap();
assert_eq!(ltr_result.direction, Direction::LeftToRight);
assert_eq!(ltr_result.glyphs.len(), 3);
let rtl_result = shaper.shape("abc", font, &rtl_params).unwrap();
assert_eq!(rtl_result.direction, Direction::RightToLeft);
assert_eq!(rtl_result.glyphs.len(), 3);
}
#[test]
fn test_font_size_variations() {
let shaper = HarfBuzzShaper::new();
let font = Arc::new(TestFont { data: vec![] });
let text = "M";
for size in [12.0, 24.0, 48.0] {
let params = ShapingParams {
size,
..Default::default()
};
let result = shaper.shape(text, font.clone(), ¶ms).unwrap();
assert_eq!(result.glyphs.len(), 1);
assert_eq!(result.advance_height, size);
}
}
#[test]
fn test_opentype_features() {
let shaper = HarfBuzzShaper::new();
let font = Arc::new(TestFont { data: vec![] });
let params_liga = ShapingParams {
features: vec![("liga".to_string(), 1)],
..Default::default()
};
let result = shaper.shape("fi", font.clone(), ¶ms_liga).unwrap();
assert_eq!(result.glyphs.len(), 2);
let params_kern = ShapingParams {
features: vec![("kern".to_string(), 1)],
..Default::default()
};
let result = shaper.shape("AV", font.clone(), ¶ms_kern).unwrap();
assert_eq!(result.glyphs.len(), 2);
let params_multi = ShapingParams {
features: vec![
("liga".to_string(), 1),
("kern".to_string(), 1),
("smcp".to_string(), 1), ],
..Default::default()
};
let result = shaper.shape("Test", font, ¶ms_multi).unwrap();
assert_eq!(result.glyphs.len(), 4);
}
#[test]
fn test_language_and_script() {
let shaper = HarfBuzzShaper::new();
let font = Arc::new(TestFont { data: vec![] });
let params_lang = ShapingParams {
language: Some("en".to_string()),
..Default::default()
};
let result = shaper.shape("Hello", font.clone(), ¶ms_lang).unwrap();
assert_eq!(result.glyphs.len(), 5);
let params_script = ShapingParams {
script: Some("latn".to_string()),
..Default::default()
};
let result = shaper.shape("Test", font.clone(), ¶ms_script).unwrap();
assert_eq!(result.glyphs.len(), 4);
let params_both = ShapingParams {
language: Some("ar".to_string()),
script: Some("arab".to_string()),
..Default::default()
};
let result = shaper.shape("text", font, ¶ms_both).unwrap();
assert!(!result.glyphs.is_empty());
}
#[test]
#[cfg(target_os = "macos")]
fn test_features_with_real_font() {
use std::fs;
let font_path = "/System/Library/Fonts/Helvetica.ttc";
if let Ok(font_data) = fs::read(font_path) {
let font = Arc::new(TestFont { data: font_data });
let shaper = HarfBuzzShaper::new();
let params_no_liga = ShapingParams {
features: vec![("liga".to_string(), 0)], ..Default::default()
};
let result_no_liga = shaper
.shape("fi fl", font.clone(), ¶ms_no_liga)
.unwrap();
let params_liga = ShapingParams {
features: vec![("liga".to_string(), 1)], ..Default::default()
};
let result_liga = shaper.shape("fi fl", font, ¶ms_liga).unwrap();
assert!(!result_no_liga.glyphs.is_empty());
assert!(!result_liga.glyphs.is_empty());
}
}
#[test]
fn test_arabic_shaping() {
let shaper = HarfBuzzShaper::new();
let font = Arc::new(TestFont { data: vec![] });
let params = ShapingParams {
language: Some("ar".to_string()),
script: Some("arab".to_string()),
direction: Direction::RightToLeft,
..Default::default()
};
let result = shaper.shape("مرحبا", font, ¶ms).unwrap();
assert_eq!(result.direction, Direction::RightToLeft);
assert!(!result.glyphs.is_empty());
assert!(result.advance_width > 0.0);
}
#[test]
fn test_devanagari_shaping() {
let shaper = HarfBuzzShaper::new();
let font = Arc::new(TestFont { data: vec![] });
let params = ShapingParams {
language: Some("hi".to_string()),
script: Some("deva".to_string()),
direction: Direction::LeftToRight,
..Default::default()
};
let result = shaper.shape("नमस्ते", font, ¶ms).unwrap();
assert_eq!(result.direction, Direction::LeftToRight);
assert!(!result.glyphs.is_empty());
assert!(result.advance_width > 0.0);
}
#[test]
fn test_hebrew_shaping() {
let shaper = HarfBuzzShaper::new();
let font = Arc::new(TestFont { data: vec![] });
let params = ShapingParams {
language: Some("he".to_string()),
script: Some("hebr".to_string()),
direction: Direction::RightToLeft,
..Default::default()
};
let result = shaper.shape("שלום", font, ¶ms).unwrap();
assert_eq!(result.direction, Direction::RightToLeft);
assert_eq!(result.glyphs.len(), 4); assert!(result.advance_width > 0.0);
}
#[test]
fn test_thai_shaping() {
let shaper = HarfBuzzShaper::new();
let font = Arc::new(TestFont { data: vec![] });
let params = ShapingParams {
language: Some("th".to_string()),
script: Some("thai".to_string()),
..Default::default()
};
let result = shaper.shape("สวัสดี", font, ¶ms).unwrap();
assert_eq!(result.direction, Direction::LeftToRight);
assert!(!result.glyphs.is_empty());
assert!(result.advance_width > 0.0);
}
#[test]
fn test_cjk_shaping() {
let shaper = HarfBuzzShaper::new();
let font = Arc::new(TestFont { data: vec![] });
let params = ShapingParams {
language: Some("zh".to_string()),
script: Some("hani".to_string()),
..Default::default()
};
let result = shaper.shape("你好", font.clone(), ¶ms).unwrap();
assert_eq!(result.direction, Direction::LeftToRight);
assert_eq!(result.glyphs.len(), 2); assert!(result.advance_width > 0.0);
let params_ja = ShapingParams {
language: Some("ja".to_string()),
script: Some("hani".to_string()),
..Default::default()
};
let result = shaper.shape("こんにちは", font, ¶ms_ja).unwrap();
assert_eq!(result.glyphs.len(), 5);
assert!(result.advance_width > 0.0);
}
#[test]
fn test_mixed_script_text() {
let shaper = HarfBuzzShaper::new();
let font = Arc::new(TestFont { data: vec![] });
let params = ShapingParams {
direction: Direction::LeftToRight, ..Default::default()
};
let result = shaper.shape("Hello مرحبا World", font, ¶ms).unwrap();
assert!(!result.glyphs.is_empty());
assert!(result.advance_width > 0.0);
}
#[test]
fn test_shaper_with_cache() {
let _guard = typf_core::cache_config::scoped_caching_enabled(true);
let shaper = HarfBuzzShaper::with_cache();
let font = Arc::new(TestFont { data: vec![] });
let params = ShapingParams::default();
let result1 = shaper.shape("Hello", font.clone(), ¶ms).unwrap();
assert_eq!(result1.glyphs.len(), 5);
let result2 = shaper.shape("Hello", font.clone(), ¶ms).unwrap();
assert_eq!(result2.glyphs.len(), 5);
assert_eq!(result1.advance_width, result2.advance_width);
let hit_rate = shaper.cache_hit_rate().unwrap();
assert!(
hit_rate > 0.0,
"Cache hit rate should be > 0 after repeat query"
);
}
#[test]
fn test_shaper_without_cache() {
let shaper = HarfBuzzShaper::new();
assert!(shaper.cache_stats().is_none());
assert!(shaper.cache_hit_rate().is_none());
}
#[test]
fn test_cache_stats() {
let _guard = typf_core::cache_config::scoped_caching_enabled(true);
let shaper = HarfBuzzShaper::with_cache();
let font = Arc::new(TestFont { data: vec![] });
let params = ShapingParams::default();
let stats = shaper.cache_stats().unwrap();
assert_eq!(stats.hits, 0);
assert_eq!(stats.misses, 0);
shaper.shape("Test", font.clone(), ¶ms).unwrap();
shaper.shape("Test", font.clone(), ¶ms).unwrap();
let stats = shaper.cache_stats().unwrap();
assert!(stats.hits >= 1, "Should have at least one hit");
}
#[test]
fn test_shared_cache_across_shapers() {
let _guard = typf_core::cache_config::scoped_caching_enabled(true);
use std::sync::RwLock;
let shared_cache: SharedShapingCache = Arc::new(RwLock::new(ShapingCache::new()));
let shaper1 = HarfBuzzShaper::with_shared_cache(shared_cache.clone());
let shaper2 = HarfBuzzShaper::with_shared_cache(shared_cache.clone());
let font = Arc::new(TestFont { data: vec![] });
let params = ShapingParams::default();
let result1 = shaper1.shape("Shared", font.clone(), ¶ms).unwrap();
let result2 = shaper2.shape("Shared", font.clone(), ¶ms).unwrap();
assert_eq!(result1.glyphs.len(), result2.glyphs.len());
assert_eq!(result1.advance_width, result2.advance_width);
let shared_stats = shared_cache.read().unwrap().stats();
assert!(
shared_stats.hits >= 1,
"Shared cache should have at least one hit"
);
}
#[test]
fn test_clear_cache() {
let _guard = typf_core::cache_config::scoped_caching_enabled(true);
let shaper = HarfBuzzShaper::with_cache();
let font = Arc::new(TestFont { data: vec![] });
let params = ShapingParams::default();
shaper.shape("ClearTest", font.clone(), ¶ms).unwrap();
shaper.shape("ClearTest", font.clone(), ¶ms).unwrap();
shaper.clear_cache();
let stats_after = shaper.cache_stats().unwrap();
assert_eq!(stats_after.hits, 0, "Stats should be reset after clear");
assert_eq!(stats_after.misses, 0, "Stats should be reset after clear");
}
#[test]
fn test_cache_different_params() {
let _guard = typf_core::cache_config::scoped_caching_enabled(true);
let shaper = HarfBuzzShaper::with_cache();
let font = Arc::new(TestFont { data: vec![] });
let params1 = ShapingParams {
size: 12.0,
..Default::default()
};
let params2 = ShapingParams {
size: 24.0,
..Default::default()
};
let result1 = shaper.shape("Size", font.clone(), ¶ms1).unwrap();
let result2 = shaper.shape("Size", font.clone(), ¶ms2).unwrap();
assert_eq!(result1.advance_height, 12.0);
assert_eq!(result2.advance_height, 24.0);
assert_ne!(
result1.advance_height, result2.advance_height,
"Different sizes should produce different results"
);
}
}