simple_editor\engine/
text.rs

1//! Text rasterizer for Text clips: system fonts (fontdb) + ab_glyph. Produces a tight, straight-alpha
2//! RGBA image with fill, outline (dilated coverage), shadow (offset + box blur) and optional background box.
3//! Output is cached by (style.cache_key(), scale bits). `scale` = canvas px per project px, so a 72 px
4//! style at a 960-wide preview of a 1920 project renders at 36 px.
5
6use crate::media::Frame;
7use crate::model::TextStyle;
8use ab_glyph::{point, Font, FontArc, FontVec, Glyph, GlyphId, OutlinedGlyph, PxScale, ScaleFont};
9use fontdb::{Database, Family, Query, Style, Weight, ID};
10use std::collections::HashMap;
11use std::sync::Arc;
12
13/// Largest rendered text image side — keeps silly sizes from allocating gigabytes.
14const MAX_SIDE: u32 = 8192;
15
16pub struct TextRasterizer {
17    cache: HashMap<(u64, u32), Arc<Frame>>,
18    families: Vec<String>,
19    loaded: bool,
20    db: Database,
21    fonts: HashMap<ID, Option<FontArc>>,
22    /// User font file paths already loaded (or attempted), so repeat calls are cheap.
23    user_fonts: Vec<String>,
24}
25
26impl Default for TextRasterizer {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32impl TextRasterizer {
33    /// Cheap; fonts load lazily (or via `load_system_fonts` from a background thread).
34    pub fn new() -> Self {
35        Self {
36            cache: HashMap::new(),
37            families: Vec::new(),
38            loaded: false,
39            db: Database::new(),
40            fonts: HashMap::new(),
41            user_fonts: Vec::new(),
42        }
43    }
44    /// Scan system fonts (≈100 ms). Idempotent.
45    pub fn load_system_fonts(&mut self) {
46        if self.loaded {
47            return;
48        }
49        self.loaded = true;
50        self.db.load_system_fonts();
51        self.refresh_families();
52    }
53    /// Load user font files (`Settings.user_fonts`) into the database. Idempotent: paths already
54    /// loaded (or that failed) are skipped on repeat calls.
55    pub fn load_user_fonts(&mut self, paths: &[String]) {
56        let mut added = false;
57        for p in paths {
58            if self.user_fonts.iter().any(|q| q == p) {
59                continue;
60            }
61            // ponytail: failed paths are remembered and never retried — restart to retry
62            self.user_fonts.push(p.clone());
63            if self.db.load_font_file(p).is_ok() {
64                added = true;
65            }
66        }
67        if added {
68            self.refresh_families();
69            // styles that fell back to another font before this one existed must re-render
70            self.cache.clear();
71        }
72    }
73    fn refresh_families(&mut self) {
74        let mut fams: Vec<String> =
75            self.db.faces().filter_map(|f| f.families.first().map(|(n, _)| n.clone())).collect();
76        fams.sort();
77        fams.dedup();
78        self.families = fams;
79    }
80    pub fn is_loaded(&self) -> bool {
81        self.loaded
82    }
83    /// Sorted unique family names (empty until loaded).
84    pub fn families(&self) -> &[String] {
85        &self.families
86    }
87    /// Render `style` at `scale`. Never fails: unknown fonts fall back to any sans font; empty text
88    /// yields a 1×1 transparent frame.
89    pub fn render(&mut self, style: &TextStyle, scale: f32) -> Arc<Frame> {
90        let key = (style.cache_key(), scale.to_bits());
91        if let Some(f) = self.cache.get(&key) {
92            return f.clone();
93        }
94        self.load_system_fonts();
95        let frame = Arc::new(self.rasterize(style, scale).unwrap_or_else(|| Frame::new(1, 1)));
96        if self.cache.len() >= 64 {
97            // ponytail: drop-all cache — LRU if text-heavy projects thrash
98            self.cache.clear();
99        }
100        self.cache.insert(key, frame.clone());
101        frame
102    }
103
104    fn font_for(&mut self, style: &TextStyle) -> Option<FontArc> {
105        let q = Query {
106            families: &[Family::Name(&style.font), Family::SansSerif],
107            weight: if style.bold { Weight::BOLD } else { Weight::NORMAL },
108            style: if style.italic { Style::Italic } else { Style::Normal },
109            stretch: Default::default(),
110        };
111        let id = self.db.query(&q).or_else(|| self.db.faces().next().map(|f| f.id))?;
112        let db = &self.db;
113        self.fonts
114            .entry(id)
115            .or_insert_with(|| {
116                db.with_face_data(id, |data, idx| FontVec::try_from_vec_and_index(data.to_vec(), idx).ok())
117                    .flatten()
118                    .map(FontArc::new)
119            })
120            .clone()
121    }
122
123    fn rasterize(&mut self, style: &TextStyle, scale: f32) -> Option<Frame> {
124        let px = style.size * scale;
125        if style.text.trim().is_empty() || !(px >= 1.0) || !px.is_finite() {
126            return None;
127        }
128        let font = self.font_for(style)?;
129        // `size` is the em size in px; ab_glyph's PxScale is the ascent-descent height.
130        let upm = font.units_per_em().unwrap_or(font.height_unscaled());
131        let sf = font.as_scaled(PxScale::from(px * font.height_unscaled() / upm));
132        let ps = sf.scale();
133        let ls = style.letter_spacing * scale;
134        let lh = (sf.ascent() - sf.descent() + sf.line_gap()) * style.line_spacing.max(0.1);
135
136        // ---- layout: glyph positions per line, then align ----
137        let mut glyphs: Vec<Glyph> = Vec::new();
138        let mut lines: Vec<(usize, usize, f32)> = Vec::new();
139        for line in style.text.split('\n') {
140            let start = glyphs.len();
141            let mut x = 0.0f32;
142            let mut prev: Option<GlyphId> = None;
143            for c in line.chars().filter(|c| *c != '\r') {
144                let id = sf.glyph_id(c);
145                if let Some(p) = prev {
146                    x += sf.kern(p, id);
147                }
148                glyphs.push(id.with_scale_and_position(ps, point(x, 0.0)));
149                x += sf.h_advance(id) + ls;
150                prev = Some(id);
151            }
152            if prev.is_some() {
153                x -= ls;
154            }
155            lines.push((start, glyphs.len(), x.max(0.0)));
156        }
157        let block_w = lines.iter().map(|l| l.2).fold(0.0, f32::max);
158        let block_h = (lines.len() - 1) as f32 * lh + sf.ascent() - sf.descent();
159        let align = match style.align {
160            0 => 0.0,
161            2 => 1.0,
162            _ => 0.5,
163        };
164        for (i, (s, e, lw)) in lines.iter().enumerate() {
165            let ox = (block_w - lw) * align;
166            let oy = sf.ascent() + i as f32 * lh;
167            for g in &mut glyphs[*s..*e] {
168                g.position.x += ox;
169                g.position.y += oy;
170            }
171        }
172        let outlined: Vec<OutlinedGlyph> = glyphs.iter().filter_map(|g| font.outline_glyph(g.clone())).collect();
173
174        // ---- image size: layout block ∪ glyph pixel bounds, plus a uniform margin for effects ----
175        let (mut bx0, mut by0, mut bx1, mut by1) = (0.0f32, 0.0f32, block_w.ceil(), block_h.ceil());
176        for og in &outlined {
177            let b = og.px_bounds();
178            bx0 = bx0.min(b.min.x);
179            by0 = by0.min(b.min.y);
180            bx1 = bx1.max(b.max.x);
181            by1 = by1.max(b.max.y);
182        }
183        let r = style.outline_width.max(0.0) * scale;
184        let r = if r < 0.5 { 0.0 } else { r };
185        let (shx, shy, blur) = if style.shadow {
186            let s = |v: f32| (v * scale).round();
187            (s(style.shadow_x), s(style.shadow_y), s(style.shadow_blur.max(0.0)))
188        } else {
189            (0.0, 0.0, 0.0)
190        };
191        let has_box = style.box_color[3] > 0;
192        let pad = if has_box { style.box_padding.max(0.0) * scale } else { 0.0 };
193        let m = (r + shx.abs().max(shy.abs()) + blur * 2.0).max(pad).ceil() as i32 + 1;
194        // ponytail: hard cap on the image size — bigger text just gets clipped
195        let w = ((bx1 - bx0).ceil() as i32 + 2 * m).clamp(1, MAX_SIDE as i32) as u32;
196        let h = ((by1 - by0).ceil() as i32 + 2 * m).clamp(1, MAX_SIDE as i32) as u32;
197        let (ox, oy) = (m as f32 - bx0, m as f32 - by0);
198        let (wu, n) = (w as usize, (w * h) as usize);
199
200        // ---- masks ----
201        let mut fill = vec![0u8; n];
202        for og in &outlined {
203            let b = og.px_bounds();
204            let (gx, gy) = ((b.min.x + ox) as i32, (b.min.y + oy) as i32);
205            og.draw(|x, y, c| {
206                let (px, py) = (gx + x as i32, gy + y as i32);
207                if px >= 0 && py >= 0 && (px as u32) < w && (py as u32) < h {
208                    let i = py as usize * wu + px as usize;
209                    fill[i] = fill[i].max((c.min(1.0) * 255.0 + 0.5) as u8);
210                }
211            });
212        }
213        let outline = if r > 0.0 { dilate(&fill, w, h, r) } else { Vec::new() };
214        let shadow = if style.shadow && style.shadow_color[3] > 0 {
215            let mut s = vec![0u8; n];
216            let (dx, dy) = (shx as i32, shy as i32);
217            for y in 0..h as i32 {
218                let sy = y - dy;
219                if sy < 0 || sy >= h as i32 {
220                    continue;
221                }
222                for x in 0..w as i32 {
223                    let sx = x - dx;
224                    if sx < 0 || sx >= w as i32 {
225                        continue;
226                    }
227                    let j = sy as usize * wu + sx as usize;
228                    let v = if outline.is_empty() { fill[j] } else { fill[j].max(outline[j]) };
229                    s[y as usize * wu + x as usize] = v;
230                }
231            }
232            box_blur(&mut s, w, h, blur as usize);
233            s
234        } else {
235            Vec::new()
236        };
237
238        // ---- compose: box, shadow, outline, fill ----
239        let mut out = Frame::new(w, h);
240        if has_box {
241            let x0 = ((ox - pad).floor().max(0.0) as u32).min(w);
242            let y0 = ((oy - pad).floor().max(0.0) as u32).min(h);
243            let x1 = ((block_w + ox + pad).ceil().max(0.0) as u32).min(w);
244            let y1 = ((block_h + oy + pad).ceil().max(0.0) as u32).min(h);
245            for y in y0..y1 {
246                for x in x0..x1 {
247                    over(&mut out.rgba[(y as usize * wu + x as usize) * 4..][..4], style.box_color, 255);
248                }
249            }
250        }
251        for (mask, color) in [(&shadow, style.shadow_color), (&outline, style.outline_color), (&fill, style.color)] {
252            if mask.is_empty() || color[3] == 0 {
253                continue;
254            }
255            for (px, &m) in out.rgba.chunks_exact_mut(4).zip(mask.iter()) {
256                if m > 0 {
257                    over(px, color, m);
258                }
259            }
260        }
261        Some(out)
262    }
263}
264
265/// Straight-alpha "over": `c` with coverage `cov` (0..255) onto the straight-alpha pixel `d`.
266#[inline]
267fn over(d: &mut [u8], c: [u8; 4], cov: u8) {
268    let sa = c[3] as f32 / 255.0 * cov as f32 / 255.0;
269    if sa <= 0.0 {
270        return;
271    }
272    let da = d[3] as f32 / 255.0 * (1.0 - sa);
273    let oa = sa + da;
274    for i in 0..3 {
275        d[i] = ((c[i] as f32 * sa + d[i] as f32 * da) / oa + 0.5) as u8;
276    }
277    d[3] = (oa * 255.0 + 0.5) as u8;
278}
279
280/// Disc dilation of a coverage mask by radius `r` (antialiased rim): exact Euclidean distance transform
281/// (Felzenszwalb–Huttenlocher, columns then rows), O(px) whatever the radius.
282fn dilate(src: &[u8], w: u32, h: u32, r: f32) -> Vec<u8> {
283    const INF: f32 = 1e20;
284    let (w, h) = (w as usize, h as usize);
285    // squared distance to the nearest covered (≥ 50 %) pixel
286    let mut d: Vec<f32> = src.iter().map(|&v| if v >= 128 { 0.0 } else { INF }).collect();
287    let n = w.max(h);
288    // line scratch in f64: q² exceeds f32's exact integer range on lines longer than 4096 px
289    let (mut f, mut g, mut v, mut z) = (vec![0f64; n], vec![0f64; n], vec![0usize; n], vec![0f64; n + 1]);
290    // (lines, len, line stride, element stride): columns, then rows
291    for (lines, len, ls, es) in [(w, h, 1, w), (h, w, w, 1)] {
292        for l in 0..lines {
293            for j in 0..len {
294                f[j] = d[l * ls + j * es] as f64;
295            }
296            dt1d(&f[..len], &mut g, &mut v, &mut z);
297            for j in 0..len {
298                d[l * ls + j * es] = g[j] as f32;
299            }
300        }
301    }
302    d.iter().map(|&d2| ((r + 0.5 - d2.sqrt()).clamp(0.0, 1.0) * 255.0 + 0.5) as u8).collect()
303}
304
305/// 1-D squared-distance transform of sampled function `f` into `d` (lower envelope of parabolas).
306/// `v`/`z` are scratch of len ≥ f.len() / f.len()+1.
307fn dt1d(f: &[f64], d: &mut [f64], v: &mut [usize], z: &mut [f64]) {
308    let n = f.len();
309    let mut k = 0usize;
310    v[0] = 0;
311    z[0] = f64::NEG_INFINITY;
312    z[1] = f64::INFINITY;
313    let s_at = |q: usize, p: usize| ((f[q] + (q * q) as f64) - (f[p] + (p * p) as f64)) / (2.0 * (q - p) as f64);
314    for q in 1..n {
315        let mut s = s_at(q, v[k]);
316        while s <= z[k] {
317            k -= 1;
318            s = s_at(q, v[k]);
319        }
320        k += 1;
321        v[k] = q;
322        z[k] = s;
323        z[k + 1] = f64::INFINITY;
324    }
325    k = 0;
326    for (q, dq) in d[..n].iter_mut().enumerate() {
327        while z[k + 1] < q as f64 {
328            k += 1;
329        }
330        let p = v[k];
331        *dq = (q as f64 - p as f64).powi(2) + f[p];
332    }
333}
334
335/// Two iterations of a separable box blur with radius `r` (≈ Gaussian of radius 2r).
336fn box_blur(buf: &mut [u8], w: u32, h: u32, r: usize) {
337    if r == 0 || buf.is_empty() {
338        return;
339    }
340    let (w, h) = (w as usize, h as usize);
341    let mut tmp = vec![0u8; buf.len()];
342    for _ in 0..2 {
343        blur_lines(buf, &mut tmp, h, w, w, 1, r);
344        blur_lines(&tmp, buf, w, h, 1, w, r);
345    }
346}
347
348/// 1-D box blur of `lines` lines of `len` elements; element j of line i is at i*lstride + j*estride.
349fn blur_lines(src: &[u8], dst: &mut [u8], lines: usize, len: usize, lstride: usize, estride: usize, r: usize) {
350    let norm = (2 * r + 1) as u32;
351    for l in 0..lines {
352        let base = l * lstride;
353        let at = |j: usize| base + j * estride;
354        let mut sum: u32 = (0..=r.min(len - 1)).map(|j| src[at(j)] as u32).sum();
355        for j in 0..len {
356            dst[at(j)] = ((sum + norm / 2) / norm) as u8;
357            if j + r + 1 < len {
358                sum += src[at(j + r + 1)] as u32;
359            }
360            if j >= r {
361                sum -= src[at(j - r)] as u32;
362            }
363        }
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    fn alpha_sum(f: &Frame) -> u64 {
372        f.rgba.chunks_exact(4).map(|p| p[3] as u64).sum()
373    }
374
375    #[test]
376    fn renders_default_style_and_caches() {
377        let mut tr = TextRasterizer::new();
378        let style = TextStyle::default();
379        let a = tr.render(&style, 0.5);
380        if tr.families().is_empty() {
381            eprintln!("no system fonts — skipping");
382            assert_eq!((a.width, a.height), (1, 1));
383            return;
384        }
385        assert!(a.width > 4 && a.height > 4, "{}x{}", a.width, a.height);
386        assert!(alpha_sum(&a) > 0);
387        // white fill: every covered pixel is white
388        assert!(a.rgba.chunks_exact(4).filter(|p| p[3] > 0).all(|p| p[0] == 255 && p[1] == 255 && p[2] == 255));
389        let b = tr.render(&style, 0.5);
390        assert!(Arc::ptr_eq(&a, &b), "cache hit expected");
391        let c = tr.render(&style, 1.0);
392        assert!(!Arc::ptr_eq(&a, &c));
393        assert!(c.width > a.width);
394
395        // unknown font falls back, never panics
396        let mut s2 = style.clone();
397        s2.font = "No Such Font 123".into();
398        s2.bold = true;
399        s2.italic = true;
400        assert!(alpha_sum(&tr.render(&s2, 0.5)) > 0);
401
402        // outline + shadow + box make the image bigger and add non-white pixels
403        let mut s3 = style.clone();
404        s3.outline_width = 4.0;
405        s3.shadow = true;
406        s3.box_color = [0, 0, 255, 255];
407        let d = tr.render(&s3, 0.5);
408        assert!(d.width > a.width && d.height > a.height);
409        assert!(d.rgba.chunks_exact(4).any(|p| p[3] > 0 && p[2] == 255 && p[0] == 0));
410        assert!(d.rgba.chunks_exact(4).any(|p| p[3] > 0 && p[0] == 0 && p[2] == 0));
411
412        // multi-line, left aligned, is taller
413        let mut s4 = style.clone();
414        s4.text = "One\nTwo\nThree".into();
415        s4.align = 0;
416        let e = tr.render(&s4, 0.5);
417        assert!(e.height > a.height * 2);
418    }
419
420    #[test]
421    fn user_font_load_is_idempotent_and_listed() {
422        let src = std::path::Path::new("C:\\Windows\\Fonts\\arial.ttf");
423        if !src.exists() {
424            eprintln!("no arial.ttf — skipping");
425            return;
426        }
427        let dst = std::env::temp_dir().join(format!("se-userfont-{}.ttf", std::process::id()));
428        std::fs::copy(src, &dst).expect("copy font");
429        let mut tr = TextRasterizer::new();
430        let paths = vec![dst.to_string_lossy().into_owned()];
431        tr.load_user_fonts(&paths);
432        assert!(tr.families().iter().any(|f| f == "Arial"), "{:?}", tr.families());
433        let n = tr.families().len();
434        tr.load_user_fonts(&paths); // idempotent
435        assert_eq!(tr.families().len(), n);
436        // renders with the user family (before system fonts are scanned the db has only this file,
437        // but render() also pulls in system fonts — either way it must not panic and must cover pixels)
438        let mut style = TextStyle::default();
439        style.font = "Arial".into();
440        assert!(alpha_sum(&tr.render(&style, 0.5)) > 0);
441        // a missing path is remembered without error
442        tr.load_user_fonts(&["Z:\\nope\\missing-font.ttf".into()]);
443        let _ = std::fs::remove_file(&dst);
444    }
445
446    #[test]
447    fn empty_text_is_1x1() {
448        let mut tr = TextRasterizer::new();
449        let mut style = TextStyle::default();
450        style.text = "  \n ".into();
451        let f = tr.render(&style, 1.0);
452        assert_eq!((f.width, f.height, f.rgba[3]), (1, 1, 0));
453        style.text = "x".into();
454        assert_eq!(tr.render(&style, 0.0).width, 1);
455    }
456
457    #[test]
458    fn blur_and_dilate_basics() {
459        let mut m = vec![0u8; 25];
460        m[12] = 255;
461        let d = dilate(&m, 5, 5, 1.5);
462        assert_eq!(d[12], 255);
463        assert_eq!(d[11], 255);
464        assert_eq!(d[7], 255);
465        assert_eq!(d[0], 0);
466        assert_eq!(d[10], 0);
467        // two passes of radius 1 spread 2 px: centre dims, neighbours light up, corners (3 px away) stay 0
468        let mut b = vec![0u8; 49];
469        b[24] = 255;
470        box_blur(&mut b, 7, 7, 1);
471        assert!(b[24] < 255 && b[24] > 0);
472        assert!(b[23] > 0 && b[16] > 0 && b[10] > 0);
473        assert_eq!(b[0], 0);
474        assert_eq!(b[3], 0);
475    }
476
477    #[test]
478    fn dilate_matches_brute_force_and_is_fast() {
479        // sparse seeds on a 40×30 mask, r = 3.7: every pixel equals the brute-force nearest-seed disc
480        let (w, h) = (40usize, 30usize);
481        let mut m = vec![0u8; w * h];
482        for &(x, y) in &[(3usize, 4usize), (20, 15), (21, 15), (38, 28), (0, 0)] {
483            m[y * w + x] = 255;
484        }
485        m[10 * w + 10] = 100; // below 50 % coverage: not a seed
486        let r = 3.7f32;
487        let d = dilate(&m, w as u32, h as u32, r);
488        for y in 0..h {
489            for x in 0..w {
490                let mut best = f32::INFINITY;
491                for (i, &v) in m.iter().enumerate() {
492                    if v >= 128 {
493                        let (xx, yy) = ((i % w) as f32, (i / w) as f32);
494                        best = best.min(((x as f32 - xx).powi(2) + (y as f32 - yy).powi(2)).sqrt());
495                    }
496                }
497                let want = ((r + 0.5 - best).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
498                assert_eq!(d[y * w + x], want, "at {x},{y}");
499            }
500        }
501        // size-1000 / outline-50 class (2000×600, r = 100): O(px), so well under a second
502        let (w, h) = (2000u32, 600u32);
503        let mut big = vec![0u8; (w * h) as usize];
504        for y in 200..400 {
505            big[(y * w + 500) as usize..(y * w + 1500) as usize].fill(255);
506        }
507        let t = std::time::Instant::now();
508        let d = dilate(&big, w, h, 100.0);
509        assert!(t.elapsed() < std::time::Duration::from_secs(2), "{:?}", t.elapsed());
510        assert_eq!(d[(300 * w + 1000) as usize], 255);
511        assert_eq!(d[(300 * w + 1550) as usize], 255); // 51 px out
512        assert_eq!(d[(300 * w + 1600) as usize], 0); // 101 px out
513    }
514}