use std::collections::HashMap; use std::hash::Hash; use crate::FontData; use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, Weight}; /// Rich text span — ölçüm için gerekli bilgiler #[derive(Clone)] pub struct RichSpanMeasure { pub text: String, pub font_family: Option, pub font_size_pt: f32, pub font_weight: Option, } /// Opak text ölçüm cache'i. `TextMeasurer` call'ları arasında taşınarak /// aynı parametrelerle yapılan ölçümlerin yeniden hesaplanmasını önler. #[derive(Default)] pub struct TextMeasureCache { entries: HashMap, } impl TextMeasureCache { /// Cache içeriğini al ve yerine boş cache bırak. pub fn take(&mut self) -> Self { Self { entries: std::mem::take(&mut self.entries), } } } /// Cache key — text ölçüm parametrelerinin hash'lenebilir temsili. /// f32 değerler bit-exact karşılaştırma için u32'ye çevrilir. #[derive(Clone, Eq, PartialEq, Hash)] struct MeasureCacheKey { text: String, font_family: Option, font_size_bits: u32, font_weight: Option, available_width_bits: Option, } impl MeasureCacheKey { fn new( text: &str, font_family: Option<&str>, font_size_pt: f32, font_weight: Option<&str>, available_width_pt: Option, ) -> Self { Self { text: text.to_string(), font_family: font_family.map(|s| s.to_string()), font_size_bits: font_size_pt.to_bits(), font_weight: font_weight.map(|s| s.to_string()), available_width_bits: available_width_pt.map(|w| w.to_bits()), } } } /// Text ölçüm motoru. cosmic-text kullanarak verilen font, boyut ve /// mevcut genişlik kısıtı ile text'in kaplayacağı alanı hesaplar. /// Ölçüm sonuçları cache'lenir — aynı parametrelerle tekrar çağrılırsa /// cosmic-text'e gitmeden cache'ten döner. pub struct TextMeasurer { font_system: FontSystem, cache: HashMap, } /// pt → px dönüşümü (cosmic-text px cinsinden çalışır, 1pt = 1.333px @96dpi) const PT_TO_PX: f32 = 96.0 / 72.0; impl TextMeasurer { pub fn new(fonts: &[FontData]) -> Self { let mut font_system = FontSystem::new_with_locale_and_db( "tr-TR".to_string(), cosmic_text::fontdb::Database::new(), ); for font in fonts { font_system.db_mut().load_font_data(font.data.clone()); } Self { font_system, cache: HashMap::new(), } } /// Mevcut cache'i koruyarak yeni bir TextMeasurer oluştur. /// Font seti değişmediyse eski cache geçerliliğini korur. pub fn new_with_cache(fonts: &[FontData], cache: TextMeasureCache) -> Self { let mut m = Self::new(fonts); m.cache = cache.entries; m } /// Cache'i dışarı taşı (persist etmek için). pub fn take_cache(self) -> TextMeasureCache { TextMeasureCache { entries: self.cache } } /// Text'i ölç. Dönen değerler pt cinsinden (width, height). /// `available_width_pt`: Mevcut genişlik kısıtı (pt). None ise sınırsız. /// Sonuç cache'lenir — aynı parametrelerle tekrar çağrılırsa cache'ten döner. pub fn measure( &mut self, text: &str, font_family: Option<&str>, font_size_pt: f32, font_weight: Option<&str>, available_width_pt: Option, ) -> (f32, f32) { if text.is_empty() { return (0.0, font_size_pt * 1.2); } let key = MeasureCacheKey::new(text, font_family, font_size_pt, font_weight, available_width_pt); if let Some(&cached) = self.cache.get(&key) { return cached; } let result = self.measure_uncached(text, font_family, font_size_pt, font_weight, available_width_pt); self.cache.insert(key, result); result } /// Cache'siz ölçüm — cosmic-text ile gerçek hesaplama. fn measure_uncached( &mut self, text: &str, font_family: Option<&str>, font_size_pt: f32, font_weight: Option<&str>, available_width_pt: Option, ) -> (f32, f32) { let font_size_px = font_size_pt * PT_TO_PX; let line_height_px = font_size_px * 1.2; let metrics = Metrics::new(font_size_px, line_height_px); let mut buffer = Buffer::new(&mut self.font_system, metrics); let width_px = available_width_pt.map(|w| w * PT_TO_PX); buffer.set_size(&mut self.font_system, width_px, None); let weight = match font_weight { Some("bold") => Weight::BOLD, _ => Weight::NORMAL, }; let family_name = font_family.unwrap_or("Noto Sans"); let attrs = Attrs::new() .family(Family::Name(family_name)) .weight(weight); buffer.set_text(&mut self.font_system, text, &attrs, Shaping::Advanced, None); buffer.shape_until_scroll(&mut self.font_system, false); let mut max_width: f32 = 0.0; let mut total_height: f32 = 0.0; for run in buffer.layout_runs() { let run_width = run.line_w; if run_width > max_width { max_width = run_width; } total_height = run.line_top + line_height_px; } if total_height == 0.0 { total_height = line_height_px; } let width_pt = max_width / PT_TO_PX; let height_pt = total_height / PT_TO_PX; // Text genişliğine küçük bir tolerans ekle (0.5pt ≈ 0.18mm). // cosmic-text ile browser font engine'i farklı subpixel sonuçlar üretir; // bu fark zoom değişimlerinde text wrap sınırında flickering'e yol açar. // 0.5pt baskıda görünmez ama wrapping dengesizliğini önler. let width_pt = width_pt + 0.5; (width_pt, height_pt) } /// Rich text ölç — birden fazla span, her biri farklı font/boyut/kalınlık. /// cosmic-text set_rich_text() ile attributed text ölçümü yapar. pub fn measure_rich_text( &mut self, spans: &[RichSpanMeasure], available_width_pt: Option, ) -> (f32, f32) { if spans.is_empty() { return (0.0, 0.0); } // En büyük font boyutunu bul — line height buna göre belirlenir let max_font_size_pt = spans .iter() .map(|s| s.font_size_pt) .fold(0.0f32, f32::max); if max_font_size_pt <= 0.0 { return (0.0, 0.0); } let max_font_size_px = max_font_size_pt * PT_TO_PX; let line_height_px = max_font_size_px * 1.2; let metrics = Metrics::new(max_font_size_px, line_height_px); let mut buffer = Buffer::new(&mut self.font_system, metrics); let width_px = available_width_pt.map(|w| w * PT_TO_PX); buffer.set_size(&mut self.font_system, width_px, None); // Her span için (text, Attrs) pair oluştur let rich_spans: Vec<(&str, Attrs)> = spans .iter() .map(|span| { let weight = match span.font_weight.as_deref() { Some("bold") => Weight::BOLD, _ => Weight::NORMAL, }; let family_name = span.font_family.as_deref().unwrap_or("Noto Sans"); let font_size_px = span.font_size_pt * PT_TO_PX; let attrs = Attrs::new() .family(Family::Name(family_name)) .weight(weight) .metrics(Metrics::new(font_size_px, font_size_px * 1.2)); (span.text.as_str(), attrs) }) .collect(); buffer.set_rich_text( &mut self.font_system, rich_spans, &Attrs::new(), Shaping::Advanced, None, ); buffer.shape_until_scroll(&mut self.font_system, false); let mut max_width: f32 = 0.0; let mut total_height: f32 = 0.0; for run in buffer.layout_runs() { if run.line_w > max_width { max_width = run.line_w; } total_height = run.line_top + line_height_px; } if total_height == 0.0 { total_height = line_height_px; } let width_pt = max_width / PT_TO_PX + 0.5; let height_pt = total_height / PT_TO_PX; (width_pt, height_pt) } } #[cfg(test)] pub(crate) fn load_test_fonts() -> Vec { let font_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() .join("backend/fonts"); let mut fonts = Vec::new(); for entry in std::fs::read_dir(&font_dir).expect("backend/fonts dizini bulunamadı") { let entry = entry.unwrap(); let path = entry.path(); if path.extension().is_some_and(|e| e == "ttf") { let data = std::fs::read(&path).unwrap(); let family = if path .file_name() .unwrap() .to_str() .unwrap() .contains("Mono") { "Noto Sans Mono".to_string() } else { "Noto Sans".to_string() }; fonts.push(crate::FontData { family, data }); } } fonts } #[cfg(test)] mod tests { use super::*; fn make_measurer() -> TextMeasurer { TextMeasurer::new(&load_test_fonts()) } #[test] fn test_empty_text() { let mut m = make_measurer(); let (w, h) = m.measure("", None, 12.0, None, None); assert_eq!(w, 0.0); assert!(h > 0.0); } #[test] fn test_basic_measurement() { let mut m = make_measurer(); let (w, h) = m.measure("Hello", None, 12.0, None, None); assert!(w > 0.0, "Width should be positive, got {w}"); assert!(h > 0.0, "Height should be positive, got {h}"); } #[test] fn test_cache_returns_same_result() { let mut m = make_measurer(); let (w1, h1) = m.measure("Cache test", None, 14.0, Some("bold"), Some(100.0)); let (w2, h2) = m.measure("Cache test", None, 14.0, Some("bold"), Some(100.0)); assert_eq!(w1, w2); assert_eq!(h1, h2); // Cache'te 1 entry olmalı (aynı key iki kere çağrıldı) assert_eq!(m.cache.len(), 1); } #[test] fn test_cache_persists_across_measurers() { let fonts = load_test_fonts(); let mut m1 = TextMeasurer::new(&fonts); let (w1, h1) = m1.measure("Persist test", None, 12.0, None, None); let cache = m1.take_cache(); let mut m2 = TextMeasurer::new_with_cache(&fonts, cache); assert_eq!(m2.cache.len(), 1); let (w2, h2) = m2.measure("Persist test", None, 12.0, None, None); assert_eq!(w1, w2); assert_eq!(h1, h2); } #[test] fn test_wrapping_reduces_width() { let mut m = make_measurer(); // Sınırsız genişlikte ölç let (w_unlimited, h_unlimited) = m.measure("This is a longer text that should wrap", None, 12.0, None, None); // Dar genişlikte ölç let (w_narrow, h_narrow) = m.measure("This is a longer text that should wrap", None, 12.0, None, Some(50.0)); // Dar genişlikte yükseklik artmalı (wrapping oldu) assert!( h_narrow >= h_unlimited, "Wrapped height ({h_narrow}) should be >= unlimited height ({h_unlimited})" ); // Dar genişlikte genişlik kısıtlanmış olmalı assert!( w_narrow <= w_unlimited + 1.0, "Wrapped width ({w_narrow}) should be <= unlimited width ({w_unlimited})" ); } }