🔒 Repository is read-only – file editing is disabled.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
//! Minimalny silnik tekstu oparty na `fontdue` (bez harfbuzz/pango).
//!
//! Obsługuje pojedyncze pismo bez kerningu i bez kształtowania złożonego — to
//! świadome uproszczenie M0. Docelowo miejsce to zajmie `cosmic-text` (shaping,
//! fallback fontów, pisma złożone), ale kontrakt (`measure`/`draw`) zostaje ten sam.
//!
//! Ładuje font systemowy (najpierw Adwaita Sans, potem DejaVu), a jeśli żaden nie
//! jest dostępny, działa w trybie „bez fontu” (przybliżone metryki, brak rysowania).
use libluna_core::style::weight;
use libluna_core::{Color, Size};
use fontdue::{Font, FontSettings};
/// Ścieżki fontu podstawowego, w kolejności preferencji (Adwaita Sans → DejaVu).
const REGULAR_CANDIDATES: &[&str] = &[
"/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf",
"/usr/share/fonts/adwaita/AdwaitaSans-Regular.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
];
/// Ścieżki fontu pogrubionego.
const BOLD_CANDIDATES: &[&str] = &[
"/usr/share/fonts/Adwaita/AdwaitaSans-Bold.ttf",
"/usr/share/fonts/adwaita/AdwaitaSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
];
/// Silnik tekstu: trzyma fonty i potrafi mierzyć oraz rysować napisy.
pub struct TextEngine {
regular: Option<Font>,
bold: Option<Font>,
}
impl TextEngine {
/// Tworzy silnik z fontów systemowych (może działać bez nich).
pub fn system() -> Self {
let regular = load_first(REGULAR_CANDIDATES).or_else(|| load_from_env("LUNA_FONT"));
let bold = load_first(BOLD_CANDIDATES).or_else(|| load_from_env("LUNA_FONT_BOLD"));
Self { regular, bold }
}
/// Silnik bez fontów (przybliżone metryki, brak rysowania) — do pomiaru drzew układu,
/// które nie zawierają etykiet. Tani w tworzeniu.
pub fn none() -> Self {
Self {
regular: None,
bold: None,
}
}
/// Czy jakikolwiek font został wczytany.
pub fn has_font(&self) -> bool {
self.regular.is_some()
}
fn font_for(&self, weight: u16) -> Option<&Font> {
if weight >= weight::BOLD {
self.bold.as_ref().or(self.regular.as_ref())
} else {
self.regular.as_ref()
}
}
/// Szerokość i wysokość tekstu w pikselach logicznych (bez kerningu).
pub fn measure(&self, text: &str, size: f64, weight: u16) -> Size {
let Some(font) = self.font_for(weight) else {
// Tryb bez fontu: przybliżenie, żeby układ miał sens.
return Size::new(text.chars().count() as f64 * size * 0.5, size * 1.2);
};
let px = size as f32;
let mut advance = 0.0f32;
let mut max_ascent = 0.0f32;
let mut max_descent = 0.0f32;
for ch in text.chars() {
let m = font.metrics(ch, px);
advance += m.advance_width;
max_ascent = max_ascent.max(m.height as f32 + m.ymin as f32);
max_descent = max_descent.max(-(m.ymin as f32));
}
Size::new(advance as f64, (max_ascent + max_descent) as f64)
}
/// Rysuje tekst do bufora RGBA8888 **premnożonego** (format tiny-skia).
///
/// `origin` w pikselach fizycznych, `size` skalowana przez `scale`.
/// `clip` ogranicza rysowanie do prostokąta (piksele fizyczne) — używane
/// przy aktywnym `Scene::push_clip`.
#[allow(clippy::too_many_arguments)]
pub fn draw(
&self,
buf: &mut [u8],
buf_w: usize,
buf_h: usize,
origin_x: f64,
origin_y: f64,
text: &str,
size: f64,
weight: u16,
color: Color,
scale: f64,
clip: Option<ClipRect>,
) {
let Some(font) = self.font_for(weight) else {
return;
};
let px = (size * scale) as f32;
let (r, g, b) = (color.r * color.a, color.g * color.a, color.b * color.a);
let alpha = color.a;
let mut pen_x = origin_x;
let baseline_y = origin_y;
for ch in text.chars() {
let (metrics, coverage) = font.rasterize(ch, px);
if metrics.width > 0 && metrics.height > 0 {
let gx = (pen_x + metrics.xmin as f64).round() as i64;
let gy =
(baseline_y - (metrics.height as f64 + metrics.ymin as f64)).round() as i64;
blend_glyph(
buf,
buf_w,
buf_h,
gx,
gy,
metrics.width,
metrics.height,
&coverage,
r,
g,
b,
alpha,
clip,
);
}
pen_x += metrics.advance_width as f64;
}
}
}
fn load_first(paths: &[&str]) -> Option<Font> {
paths.iter().find_map(|p| load_font(p))
}
fn load_from_env(var: &str) -> Option<Font> {
std::env::var(var).ok().and_then(|p| load_font(&p))
}
fn load_font(path: &str) -> Option<Font> {
let bytes = std::fs::read(path).ok()?;
Font::from_bytes(bytes, FontSettings::default()).ok()
}
/// Prostokąt obcinania w pikselach fizycznych (półotwarty: `x0..x1`, `y0..y1`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClipRect {
pub x0: i64,
pub y0: i64,
pub x1: i64,
pub y1: i64,
}
/// Nakłada bitmapę krycia glifu na bufor premnożony (operacja „over”).
#[allow(clippy::too_many_arguments)]
fn blend_glyph(
buf: &mut [u8],
buf_w: usize,
buf_h: usize,
gx: i64,
gy: i64,
gw: usize,
gh: usize,
coverage: &[u8],
sr: f32,
sg: f32,
sb: f32,
sa: f32,
clip: Option<ClipRect>,
) {
for row in 0..gh {
let y = gy + row as i64;
if y < 0 || y >= buf_h as i64 {
continue;
}
if let Some(c) = clip {
if y < c.y0 || y >= c.y1 {
continue;
}
}
for col in 0..gw {
let x = gx + col as i64;
if x < 0 || x >= buf_w as i64 {
continue;
}
if let Some(c) = clip {
if x < c.x0 || x >= c.x1 {
continue;
}
}
let cov = coverage[row * gw + col] as f32 / 255.0;
if cov <= 0.0 {
continue;
}
// Źródło premnożone, przemnożone jeszcze przez krycie glifu.
let a_s = sa * cov;
let r_s = sr * cov;
let g_s = sg * cov;
let b_s = sb * cov;
let idx = (y as usize * buf_w + x as usize) * 4;
let inv = 1.0 - a_s;
let a_d = buf[idx + 3] as f32 / 255.0;
let r_d = buf[idx] as f32 / 255.0;
let g_d = buf[idx + 1] as f32 / 255.0;
let b_d = buf[idx + 2] as f32 / 255.0;
let out_a = a_s + a_d * inv;
let out_r = r_s + r_d * inv;
let out_g = g_s + g_d * inv;
let out_b = b_s + b_d * inv;
buf[idx] = (out_r * 255.0).round().clamp(0.0, 255.0) as u8;
buf[idx + 1] = (out_g * 255.0).round().clamp(0.0, 255.0) as u8;
buf[idx + 2] = (out_b * 255.0).round().clamp(0.0, 255.0) as u8;
buf[idx + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn measure_without_font_is_positive() {
let engine = TextEngine {
regular: None,
bold: None,
};
let s = engine.measure("Hello", 14.0, weight::NORMAL);
assert!(s.width > 0.0);
assert!(s.height > 0.0);
}
#[test]
fn system_engine_loads_something_on_this_host() {
// Nie jest to twardy wymóg — jeśli brak fontów, po prostu nie rysujemy.
let engine = TextEngine::system();
let s = engine.measure("AG", 14.0, weight::NORMAL);
assert!(s.width > 0.0);
}
#[test]
fn blend_glyph_respects_clip() {
// Białe 4×2, pełne krycie glifu; czerwony glif obcięty do kolumn 0..2.
let (w, h) = (4usize, 2usize);
let mut buf = vec![255u8; w * h * 4];
let cov = vec![255u8; w * h];
let clip = Some(ClipRect {
x0: 0,
y0: 0,
x1: 2,
y1: 2,
});
blend_glyph(&mut buf, w, h, 0, 0, w, h, &cov, 1.0, 0.0, 0.0, 1.0, clip);
let px = |x: usize, y: usize| {
let i = (y * w + x) * 4;
(buf[i], buf[i + 1], buf[i + 2], buf[i + 3])
};
// W klipie: czerwony, pełne krycie.
assert_eq!(px(0, 0), (255, 0, 0, 255));
assert_eq!(px(1, 1), (255, 0, 0, 255));
// Poza klipem: nietknięte białe tło.
assert_eq!(px(2, 0), (255, 255, 255, 255));
assert_eq!(px(3, 1), (255, 255, 255, 255));
}
}