mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
364 lines
12 KiB
Rust
364 lines
12 KiB
Rust
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<String>,
|
||
pub font_size_pt: f32,
|
||
pub font_weight: Option<String>,
|
||
}
|
||
|
||
/// 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<MeasureCacheKey, (f32, f32)>,
|
||
}
|
||
|
||
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<String>,
|
||
font_size_bits: u32,
|
||
font_weight: Option<String>,
|
||
available_width_bits: Option<u32>,
|
||
}
|
||
|
||
impl MeasureCacheKey {
|
||
fn new(
|
||
text: &str,
|
||
font_family: Option<&str>,
|
||
font_size_pt: f32,
|
||
font_weight: Option<&str>,
|
||
available_width_pt: Option<f32>,
|
||
) -> 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<MeasureCacheKey, (f32, f32)>,
|
||
}
|
||
|
||
/// 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, 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, 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, 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<crate::FontData> {
|
||
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})"
|
||
);
|
||
}
|
||
}
|