Skip to main content

pretext_render/
lib.rs

1use std::hash::{Hash, Hasher};
2use std::num::NonZeroUsize;
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::Arc;
5
6use lru::LruCache;
7use parking_lot::Mutex;
8use pretext::advanced::ShapedTextSpan;
9use pretext::font_catalog::FontId;
10use pretext::{BidiDirection, PretextEngine, PretextStyle as TextStyleSpec};
11
12const RASTER_CACHE_CAPACITY: usize = 1024;
13const GLYPH_PATH_CACHE_CAPACITY: usize = 4096;
14
15#[derive(Clone, Copy, Debug, PartialEq)]
16pub enum BaselineMode {
17    AutoFontMetrics,
18    FixedBaselinePx(f32),
19}
20
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub struct BaselineMetrics {
23    pub baseline_px: f32,
24    pub ascent_px: f32,
25    pub descent_px: f32,
26}
27
28#[derive(Clone, Copy, Debug)]
29pub struct TextRasterRequest<'a> {
30    pub text: &'a str,
31    pub style: &'a TextStyleSpec,
32    pub direction: BidiDirection,
33    pub slot_height: f32,
34    pub padding_x: f32,
35    pub padding_y: f32,
36    pub slack_x: f32,
37    pub slack_y: f32,
38    pub baseline_mode: BaselineMode,
39}
40
41#[derive(Clone)]
42pub struct RasterizedText {
43    cache_id: u64,
44    logical_size: LogicalSize,
45    pixel_size: [usize; 2],
46    alpha_pixels: Arc<[u8]>,
47}
48
49#[derive(Clone, Copy, Debug, Default, PartialEq)]
50pub struct LogicalSize {
51    pub width: f32,
52    pub height: f32,
53}
54
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
56pub struct RenderStatsSnapshot {
57    pub raster_cache_entries: usize,
58    pub glyph_path_entries: usize,
59    pub raster_cache_hits: u64,
60    pub raster_cache_misses: u64,
61    pub rasterizations: u64,
62    pub glyph_path_hits: u64,
63    pub glyph_path_misses: u64,
64}
65
66#[derive(Default)]
67struct RenderStats {
68    raster_cache_hits: AtomicU64,
69    raster_cache_misses: AtomicU64,
70    rasterizations: AtomicU64,
71    glyph_path_hits: AtomicU64,
72    glyph_path_misses: AtomicU64,
73}
74
75#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
76enum BaselineModeKey {
77    AutoFontMetrics,
78    FixedBaselinePx { baseline_px_q: u32 },
79}
80
81#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
82struct RasterCacheKey {
83    text_hash: u64,
84    style_hash: u64,
85    direction: BidiDirection,
86    slot_height_q: u32,
87    padding_x_q: u32,
88    padding_y_q: u32,
89    slack_x_q: u32,
90    slack_y_q: u32,
91    baseline_mode: BaselineModeKey,
92    raster_scale_q: u32,
93}
94
95#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
96struct GlyphPathKey {
97    face_id: FontId,
98    glyph_id: u16,
99}
100
101pub struct TextRenderCache {
102    rasterized_text: Mutex<LruCache<RasterCacheKey, Arc<RasterizedText>>>,
103    glyph_paths: Mutex<LruCache<GlyphPathKey, Arc<tiny_skia::Path>>>,
104    stats: RenderStats,
105}
106
107impl Default for TextRenderCache {
108    fn default() -> Self {
109        Self {
110            rasterized_text: Mutex::new(LruCache::new(
111                NonZeroUsize::new(RASTER_CACHE_CAPACITY).expect("raster cache capacity"),
112            )),
113            glyph_paths: Mutex::new(LruCache::new(
114                NonZeroUsize::new(GLYPH_PATH_CACHE_CAPACITY).expect("glyph path cache capacity"),
115            )),
116            stats: RenderStats::default(),
117        }
118    }
119}
120
121impl TextRasterRequest<'_> {
122    pub fn logical_size(&self, content_width: f32) -> LogicalSize {
123        LogicalSize {
124            width: content_width.ceil().max(1.0) + self.padding_x * 2.0 + self.slack_x,
125            height: self.slot_height.ceil().max(1.0) + self.padding_y * 2.0 + self.slack_y,
126        }
127    }
128}
129
130impl RasterizedText {
131    pub fn cache_id(&self) -> u64 {
132        self.cache_id
133    }
134
135    pub fn logical_size(&self) -> LogicalSize {
136        self.logical_size
137    }
138
139    pub fn pixel_size(&self) -> [usize; 2] {
140        self.pixel_size
141    }
142
143    pub fn alpha_pixels(&self) -> Arc<[u8]> {
144        self.alpha_pixels.clone()
145    }
146}
147
148impl BaselineMetrics {
149    pub fn content_top_px(&self) -> f32 {
150        self.baseline_px - self.ascent_px
151    }
152
153    pub fn content_height_px(&self) -> f32 {
154        (self.ascent_px + self.descent_px).max(1.0)
155    }
156
157    pub fn square_top(&self, square_size: f32) -> f32 {
158        self.content_top_px() + (self.content_height_px() - square_size) * 0.5
159    }
160}
161
162pub fn text_baseline_metrics(
163    engine: &PretextEngine,
164    request: TextRasterRequest<'_>,
165) -> BaselineMetrics {
166    let spans = engine.shape_text_spans_shared(request.text, request.style, request.direction);
167    shaped_text_baseline_metrics(&spans, &request, 1.0)
168}
169
170impl TextRenderCache {
171    pub fn rasterized_text(
172        &self,
173        engine: &PretextEngine,
174        request: TextRasterRequest<'_>,
175        raster_scale: f32,
176    ) -> Option<Arc<RasterizedText>> {
177        let key = RasterCacheKey::new(&request, raster_scale);
178        if let Some(cached) = self.rasterized_text.lock().get(&key).cloned() {
179            self.stats.raster_cache_hits.fetch_add(1, Ordering::Relaxed);
180            return Some(cached);
181        }
182
183        self.stats
184            .raster_cache_misses
185            .fetch_add(1, Ordering::Relaxed);
186
187        let value = Arc::new(self.build_rasterized_text(engine, request, key)?);
188        self.rasterized_text.lock().put(key, value.clone());
189        Some(value)
190    }
191
192    pub fn stats_snapshot(&self) -> RenderStatsSnapshot {
193        RenderStatsSnapshot {
194            raster_cache_entries: self.rasterized_text.lock().len(),
195            glyph_path_entries: self.glyph_paths.lock().len(),
196            raster_cache_hits: self.stats.raster_cache_hits.load(Ordering::Relaxed),
197            raster_cache_misses: self.stats.raster_cache_misses.load(Ordering::Relaxed),
198            rasterizations: self.stats.rasterizations.load(Ordering::Relaxed),
199            glyph_path_hits: self.stats.glyph_path_hits.load(Ordering::Relaxed),
200            glyph_path_misses: self.stats.glyph_path_misses.load(Ordering::Relaxed),
201        }
202    }
203
204    pub fn clear(&self) {
205        self.rasterized_text.lock().clear();
206        self.glyph_paths.lock().clear();
207    }
208
209    fn build_rasterized_text(
210        &self,
211        engine: &PretextEngine,
212        request: TextRasterRequest<'_>,
213        key: RasterCacheKey,
214    ) -> Option<RasterizedText> {
215        let spans = engine.shape_text_spans_shared(request.text, request.style, request.direction);
216        let content_width = spans.iter().map(|span| span.width).sum::<f32>();
217        let logical_size = request.logical_size(content_width);
218        let pixel_size = [
219            (logical_size.width * key.raster_scale()).ceil().max(1.0) as usize,
220            (logical_size.height * key.raster_scale()).ceil().max(1.0) as usize,
221        ];
222        let mut pixmap = tiny_skia::Pixmap::new(pixel_size[0] as u32, pixel_size[1] as u32)?;
223        let mut paint = tiny_skia::Paint::default();
224        paint.set_color_rgba8(255, 255, 255, 255);
225        paint.anti_alias = true;
226
227        let baseline_metrics = shaped_text_baseline_metrics(&spans, &request, key.raster_scale());
228        let baseline = request.padding_y * key.raster_scale() + baseline_metrics.baseline_px;
229        let mut span_left = request.padding_x * key.raster_scale();
230        for span in spans.iter() {
231            self.rasterize_shaped_text_span(
232                &mut pixmap,
233                &paint,
234                span,
235                request.style.size_px,
236                span_left,
237                baseline,
238                key.raster_scale(),
239            );
240            span_left += span.width * key.raster_scale();
241        }
242
243        self.stats.rasterizations.fetch_add(1, Ordering::Relaxed);
244
245        let alpha_pixels: Arc<[u8]> = pixmap
246            .pixels()
247            .iter()
248            .map(|pixel| pixel.alpha())
249            .collect::<Vec<_>>()
250            .into();
251
252        Some(RasterizedText {
253            cache_id: key.cache_id(),
254            logical_size,
255            pixel_size,
256            alpha_pixels,
257        })
258    }
259
260    fn rasterize_shaped_text_span(
261        &self,
262        pixmap: &mut tiny_skia::Pixmap,
263        paint: &tiny_skia::Paint<'_>,
264        span: &ShapedTextSpan,
265        font_size: f32,
266        span_left: f32,
267        baseline: f32,
268        raster_scale: f32,
269    ) {
270        let Ok(face) = ttf_parser::Face::parse(span.face.data(), span.face.face_index()) else {
271            return;
272        };
273        let units_per_em = span.face.units_per_em().max(1) as f32;
274        let glyph_scale = font_size * raster_scale / units_per_em;
275        let mut pen_x = span_left;
276
277        for glyph in span.glyphs.iter() {
278            let advance = glyph.advance * raster_scale;
279            let glyph_x = pen_x + glyph.x_offset * raster_scale;
280            pen_x += advance;
281            let Some(path) = self.glyph_path(&face, span.face.id(), glyph.glyph_id) else {
282                continue;
283            };
284            let transform = tiny_skia::Transform::from_row(
285                glyph_scale,
286                0.0,
287                0.0,
288                -glyph_scale,
289                glyph_x,
290                baseline - glyph.y_offset * raster_scale,
291            );
292            pixmap.fill_path(
293                path.as_ref(),
294                paint,
295                tiny_skia::FillRule::Winding,
296                transform,
297                None,
298            );
299        }
300    }
301
302    fn glyph_path(
303        &self,
304        face: &ttf_parser::Face<'_>,
305        face_id: FontId,
306        glyph_id: u16,
307    ) -> Option<Arc<tiny_skia::Path>> {
308        let key = GlyphPathKey { face_id, glyph_id };
309        if let Some(path) = self.glyph_paths.lock().get(&key).cloned() {
310            self.stats.glyph_path_hits.fetch_add(1, Ordering::Relaxed);
311            return Some(path);
312        }
313
314        let mut builder = GlyphPathBuilder::default();
315        face.outline_glyph(ttf_parser::GlyphId(glyph_id), &mut builder)?;
316        let path = Arc::new(builder.finish()?);
317        self.glyph_paths.lock().put(key, path.clone());
318        self.stats.glyph_path_misses.fetch_add(1, Ordering::Relaxed);
319        Some(path)
320    }
321}
322
323impl RasterCacheKey {
324    fn new(request: &TextRasterRequest<'_>, raster_scale: f32) -> Self {
325        Self {
326            text_hash: hash_text(request.text),
327            style_hash: hash_style(request.style),
328            direction: request.direction,
329            slot_height_q: quantize_f32(request.slot_height),
330            padding_x_q: quantize_f32(request.padding_x),
331            padding_y_q: quantize_f32(request.padding_y),
332            slack_x_q: quantize_f32(request.slack_x),
333            slack_y_q: quantize_f32(request.slack_y),
334            baseline_mode: normalize_baseline_mode(request.baseline_mode),
335            raster_scale_q: quantize_f32(raster_scale),
336        }
337    }
338
339    fn raster_scale(self) -> f32 {
340        self.raster_scale_q as f32 / 64.0
341    }
342
343    fn cache_id(self) -> u64 {
344        let mut state = std::collections::hash_map::DefaultHasher::new();
345        self.hash(&mut state);
346        state.finish()
347    }
348}
349
350fn normalize_baseline_mode(mode: BaselineMode) -> BaselineModeKey {
351    match mode {
352        BaselineMode::AutoFontMetrics => BaselineModeKey::AutoFontMetrics,
353        BaselineMode::FixedBaselinePx(value) => BaselineModeKey::FixedBaselinePx {
354            baseline_px_q: quantize_f32(value),
355        },
356    }
357}
358
359fn shaped_text_baseline_metrics(
360    spans: &[ShapedTextSpan],
361    request: &TextRasterRequest<'_>,
362    raster_scale: f32,
363) -> BaselineMetrics {
364    let (ascent, descent) = shaped_text_vertical_extents(spans, request, raster_scale);
365    match request.baseline_mode {
366        BaselineMode::AutoFontMetrics => {
367            let content_height = (ascent + descent).max(1.0);
368            let top_inset = ((request.slot_height * raster_scale - content_height).max(0.0)) * 0.5;
369            BaselineMetrics {
370                baseline_px: top_inset + ascent,
371                ascent_px: ascent,
372                descent_px: descent,
373            }
374        }
375        BaselineMode::FixedBaselinePx(value) => BaselineMetrics {
376            baseline_px: value * raster_scale,
377            ascent_px: ascent,
378            descent_px: descent,
379        },
380    }
381}
382
383fn shaped_text_vertical_extents(
384    spans: &[ShapedTextSpan],
385    request: &TextRasterRequest<'_>,
386    raster_scale: f32,
387) -> (f32, f32) {
388    let mut ascent = request.style.size_px * raster_scale * 0.8;
389    let mut descent = request.style.size_px * raster_scale * 0.2;
390
391    for span in spans {
392        if span_uses_emoji_baseline_defaults(span) {
393            continue;
394        }
395        let Ok(face) = ttf_parser::Face::parse(span.face.data(), span.face.face_index()) else {
396            continue;
397        };
398        let units_per_em = span.face.units_per_em().max(1) as f32;
399        let scale = request.style.size_px * raster_scale / units_per_em;
400        ascent = ascent.max(face.ascender() as f32 * scale);
401        descent = descent.max((-(face.descender() as f32) * scale).max(0.0));
402    }
403
404    (ascent.max(1.0), descent.max(0.0))
405}
406
407fn span_uses_emoji_baseline_defaults(span: &ShapedTextSpan) -> bool {
408    if span.face.family_name().contains("Emoji") {
409        return true;
410    }
411    let Ok(face) = ttf_parser::Face::parse(span.face.data(), span.face.face_index()) else {
412        return false;
413    };
414    span.glyphs
415        .iter()
416        .any(|glyph| face.is_color_glyph(ttf_parser::GlyphId(glyph.glyph_id)))
417}
418
419fn quantize_f32(value: f32) -> u32 {
420    (value.max(0.0) * 64.0).round() as u32
421}
422
423fn hash_text(text: &str) -> u64 {
424    let mut state = ahash::AHasher::default();
425    text.hash(&mut state);
426    state.finish()
427}
428
429fn hash_style(style: &TextStyleSpec) -> u64 {
430    let mut state = ahash::AHasher::default();
431    style.hash(&mut state);
432    state.finish()
433}
434
435#[derive(Default)]
436struct GlyphPathBuilder {
437    inner: tiny_skia::PathBuilder,
438}
439
440impl GlyphPathBuilder {
441    fn finish(self) -> Option<tiny_skia::Path> {
442        self.inner.finish()
443    }
444}
445
446impl ttf_parser::OutlineBuilder for GlyphPathBuilder {
447    fn move_to(&mut self, x: f32, y: f32) {
448        self.inner.move_to(x, y);
449    }
450
451    fn line_to(&mut self, x: f32, y: f32) {
452        self.inner.line_to(x, y);
453    }
454
455    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
456        self.inner.quad_to(x1, y1, x, y);
457    }
458
459    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
460        self.inner.cubic_to(x1, y1, x2, y2, x, y);
461    }
462
463    fn close(&mut self) {
464        self.inner.close();
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    fn engine() -> PretextEngine {
473        PretextEngine::builder()
474            .with_font_data(vec![
475                include_bytes!("../../../demos/app/assets/fonts/NotoSans-Regular.ttf").to_vec(),
476                include_bytes!("../../../demos/app/assets/fonts/NotoSansArabic-Regular.ttf")
477                    .to_vec(),
478                include_bytes!("../../../demos/app/assets/fonts/NotoSansCJK-Regular.ttc").to_vec(),
479                include_bytes!("../../../demos/app/assets/fonts/NotoSansMyanmar-Regular.ttf")
480                    .to_vec(),
481                include_bytes!("../../../demos/app/assets/fonts/Noto-COLRv1.ttf").to_vec(),
482                include_bytes!("../../../demos/app/assets/fonts/NotoSansMono-Regular.ttf").to_vec(),
483            ])
484            .include_system_fonts(false)
485            .build()
486    }
487
488    fn default_style() -> TextStyleSpec {
489        TextStyleSpec {
490            families: vec![
491                "Noto Sans".to_owned(),
492                "Noto Sans Arabic".to_owned(),
493                "Noto Color Emoji".to_owned(),
494            ],
495            size_px: 16.0,
496            weight: 400,
497            italic: false,
498        }
499    }
500
501    #[test]
502    fn rasterized_text_reuses_cached_entry() {
503        let engine = engine();
504        let cache = TextRenderCache::default();
505        let request = TextRasterRequest {
506            text: "English العربية",
507            style: &default_style(),
508            direction: BidiDirection::Ltr,
509            slot_height: 22.0,
510            padding_x: 2.0,
511            padding_y: 2.0,
512            slack_x: 2.0,
513            slack_y: 2.0,
514            baseline_mode: BaselineMode::AutoFontMetrics,
515        };
516
517        let first = cache
518            .rasterized_text(&engine, request, 2.0)
519            .expect("first rasterized text");
520        let second = cache
521            .rasterized_text(&engine, request, 2.0)
522            .expect("cached rasterized text");
523
524        assert!(Arc::ptr_eq(&first, &second));
525        let stats = cache.stats_snapshot();
526        assert_eq!(stats.raster_cache_hits, 1);
527        assert_eq!(stats.rasterizations, 1);
528    }
529
530    #[test]
531    fn rasterized_text_size_tracks_content_width_instead_of_external_slot_width() {
532        let engine = engine();
533        let cache = TextRenderCache::default();
534        let request = TextRasterRequest {
535            text: "Cache me",
536            style: &default_style(),
537            direction: BidiDirection::Ltr,
538            slot_height: 22.0,
539            padding_x: 2.0,
540            padding_y: 2.0,
541            slack_x: 2.0,
542            slack_y: 2.0,
543            baseline_mode: BaselineMode::AutoFontMetrics,
544        };
545
546        let raster = cache
547            .rasterized_text(&engine, request, 1.0)
548            .expect("rasterized text");
549
550        assert!(raster.logical_size().width > 8.0);
551        assert!(raster.logical_size().height >= 22.0);
552        assert_eq!(
553            raster.pixel_size()[0],
554            raster.logical_size().width.ceil() as usize
555        );
556    }
557
558    #[test]
559    fn fixed_baseline_metrics_preserve_requested_baseline_offset() {
560        let engine = engine();
561        let request = TextRasterRequest {
562            text: "emoji 🎉",
563            style: &default_style(),
564            direction: BidiDirection::Ltr,
565            slot_height: 20.0,
566            padding_x: 2.0,
567            padding_y: 2.0,
568            slack_x: 2.0,
569            slack_y: 2.0,
570            baseline_mode: BaselineMode::FixedBaselinePx(18.0),
571        };
572
573        let metrics = text_baseline_metrics(&engine, request);
574
575        assert_eq!(metrics.baseline_px, 18.0);
576        assert!(metrics.ascent_px > 0.0);
577        assert!(metrics.descent_px >= 0.0);
578    }
579
580    #[test]
581    fn baseline_metrics_square_top_centers_within_content_box() {
582        let metrics = BaselineMetrics {
583            baseline_px: 14.0,
584            ascent_px: 10.0,
585            descent_px: 4.0,
586        };
587
588        assert_eq!(metrics.content_top_px(), 4.0);
589        assert_eq!(metrics.content_height_px(), 14.0);
590        assert_eq!(metrics.square_top(10.0), 6.0);
591    }
592}