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}