Files
dreport/layout-engine/src/text_measure.rs
2026-04-03 01:26:54 +03:00

364 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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})"
);
}
}