Skip to main content

ravenrs_text/
lib.rs

1//! Text shaping and layout for RavenRS.
2//!
3//! [`TextSystem`] owns Parley's font database and scratch space. It ships with
4//! Inter (SIL Open Font License 1.1, see `fonts/Inter-LICENSE.txt`) embedded,
5//! because browsers expose no system fonts to WebAssembly; the same bytes are used
6//! on the desktop so text looks identical everywhere. System fonts are not loaded.
7//!
8//! Layout happens in logical pixels. The renderer rasterizes glyphs at the device
9//! scale factor, so text stays sharp on HiDPI screens without re-shaping.
10
11use std::collections::HashMap;
12use std::fmt;
13use std::hash::{Hash, Hasher};
14use std::sync::{Arc, OnceLock};
15
16use parley::fontique::{Blob, Collection, CollectionOptions, FamilyId, GenericFamily, SourceCache};
17use parley::{
18    Alignment, AlignmentOptions, FontContext, FontFamily, FontWeight, Layout, LayoutContext, LineHeight,
19    PositionedLayoutItem, StyleProperty,
20};
21use ravenrs_core::{Color, FontRef, GlyphRun, Point, PositionedGlyph, Scene, Size};
22
23static INTER_REGULAR: &[u8] = include_bytes!("../fonts/Inter-Regular.ttf");
24static INTER_SEMIBOLD: &[u8] = include_bytes!("../fonts/Inter-SemiBold.ttf");
25/// A subset of Lucide icons (ISC License), see `scripts/gen-icons.py`.
26static RAVEN_ICONS: &[u8] = include_bytes!("../fonts/RavenIcons.ttf");
27/// Fira Mono (SIL Open Font License), for code.
28#[cfg(feature = "mono")]
29static FIRA_MONO: &[u8] = include_bytes!("../fonts/FiraMono-Medium.ttf");
30
31/// Every built-in font, in the order of [`builtin_font_number`]: new ones go at the end.
32#[cfg(not(feature = "mono"))]
33const BUILTIN: [&[u8]; 3] = [INTER_REGULAR, INTER_SEMIBOLD, RAVEN_ICONS];
34#[cfg(feature = "mono")]
35const BUILTIN: [&[u8]; 4] = [INTER_REGULAR, INTER_SEMIBOLD, RAVEN_ICONS, FIRA_MONO];
36
37/// The family name of the embedded icon font.
38pub const ICON_FAMILY: &str = "RavenIcons";
39
40/// The family name of the embedded default font.
41pub const DEFAULT_FAMILY: &str = "Inter";
42
43/// The family name of the embedded monospaced font, with the `mono` feature. It is also
44/// what the generic `monospace` family resolves to.
45#[cfg(feature = "mono")]
46pub const MONO_FAMILY: &str = "Fira Mono";
47
48/// Which built-in font these bytes are, as a number that does not change between
49/// runs: what a recording stores instead of the font itself.
50pub fn builtin_font_number(bytes: &[u8]) -> Option<u8> {
51    BUILTIN.iter().position(|font| *font == bytes).map(|i| i as u8)
52}
53
54/// A built-in font by its [`builtin_font_number`], for drawing shaped glyphs back.
55pub fn builtin_font(number: u8) -> Option<FontRef> {
56    static FONTS: OnceLock<[FontRef; BUILTIN.len()]> = OnceLock::new();
57    let fonts = FONTS.get_or_init(|| {
58        BUILTIN.map(|bytes| {
59            let blob = Blob::new(Arc::new(bytes));
60            FontRef { id: blob.id(), data: Arc::new(blob), index: 0 }
61        })
62    });
63    fonts.get(usize::from(number)).cloned()
64}
65
66/// The embedded Inter font file, regular or semibold, for documents that embed
67/// the font they were measured with.
68pub fn inter_font(semibold: bool) -> &'static [u8] {
69    if semibold { INTER_SEMIBOLD } else { INTER_REGULAR }
70}
71
72/// Folds a character to what searches compare: lowercase, without accents, so
73/// "regiao" finds "Região". Folding can change how many characters there are.
74pub fn fold(c: char) -> impl Iterator<Item = char> {
75    c.to_lowercase().map(|c| match c {
76        'á' | 'à' | 'â' | 'ã' | 'ä' | 'å' => 'a',
77        'é' | 'è' | 'ê' | 'ë' => 'e',
78        'í' | 'ì' | 'î' | 'ï' => 'i',
79        'ó' | 'ò' | 'ô' | 'õ' | 'ö' => 'o',
80        'ú' | 'ù' | 'û' | 'ü' => 'u',
81        'ç' => 'c',
82        'ñ' => 'n',
83        // A number formatted with a non-breaking space is found with a plain one.
84        '\u{a0}' => ' ',
85        other => other,
86    })
87}
88
89/// Visual attributes of a piece of text.
90#[derive(Clone, Debug, PartialEq)]
91pub struct TextAttrs {
92    /// Font size in logical pixels.
93    pub size: f32,
94    /// Line height in logical pixels.
95    pub line_height: f32,
96    /// CSS weight (400 regular, 600 semibold).
97    pub weight: f32,
98    /// Extra space between characters in logical pixels; may be negative.
99    pub letter_spacing: f32,
100    /// Font family list in CSS syntax; defaults to the embedded Inter.
101    pub family: Option<String>,
102}
103
104impl Default for TextAttrs {
105    fn default() -> Self {
106        Self { size: 15.0, line_height: 22.0, weight: 400.0, letter_spacing: 0.0, family: None }
107    }
108}
109
110/// Work a [`TextSystem`] has done since it was created.
111#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
112pub struct TextStats {
113    /// Times text was shaped, the costly part of showing it.
114    pub shapes: u64,
115}
116
117/// Layouts kept per generation; two generations are alive, so up to twice this.
118const CACHE_GENERATION: usize = 8192;
119/// Longer texts are rare and take the most memory, so they are shaped each time.
120const CACHE_LONGEST_TEXT: usize = 4096;
121
122/// What identifies a shaped text: the text, its attributes and, once cut to fit, the
123/// width. Hashed with fixed keys, because the same run must do the same work every time.
124fn cache_key(text: &str, attrs: &TextAttrs, fit: Option<f32>) -> u64 {
125    let mut hash = std::hash::DefaultHasher::new();
126    text.hash(&mut hash);
127    attrs.size.to_bits().hash(&mut hash);
128    attrs.line_height.to_bits().hash(&mut hash);
129    attrs.weight.to_bits().hash(&mut hash);
130    attrs.letter_spacing.to_bits().hash(&mut hash);
131    attrs.family.hash(&mut hash);
132    fit.map(f32::to_bits).hash(&mut hash);
133    hash.finish()
134}
135
136#[derive(Clone)]
137struct CachedLayout {
138    /// Checked on every hit: a hash can collide, and wrong text would be a visible bug.
139    text: Box<str>,
140    attrs: TextAttrs,
141    layout: TextLayout,
142}
143
144/// Recently shaped text. Rebuilding a screen shows almost the same text as before, so
145/// most lookups hit. What was not used for a whole generation goes away.
146#[derive(Default)]
147struct LayoutCache {
148    current: HashMap<u64, CachedLayout>,
149    previous: HashMap<u64, CachedLayout>,
150}
151
152impl LayoutCache {
153    fn get(&mut self, key: u64, text: &str, attrs: &TextAttrs) -> Option<TextLayout> {
154        let matches = |entry: &CachedLayout| &*entry.text == text && entry.attrs == *attrs;
155        if let Some(entry) = self.current.get(&key).filter(|entry| matches(entry)) {
156            return Some(entry.layout.clone());
157        }
158        let entry = self.previous.get(&key).filter(|entry| matches(entry))?.clone();
159        let layout = entry.layout.clone();
160        self.insert(key, entry);
161        Some(layout)
162    }
163
164    fn put(&mut self, key: u64, text: &str, attrs: &TextAttrs, layout: &TextLayout) {
165        if text.len() <= CACHE_LONGEST_TEXT {
166            let entry = CachedLayout { text: text.into(), attrs: attrs.clone(), layout: layout.clone() };
167            self.insert(key, entry);
168        }
169    }
170
171    fn insert(&mut self, key: u64, entry: CachedLayout) {
172        if self.current.len() >= CACHE_GENERATION {
173            self.previous = std::mem::take(&mut self.current);
174        }
175        self.current.insert(key, entry);
176    }
177
178    fn clear(&mut self) {
179        self.current.clear();
180        self.previous.clear();
181    }
182}
183
184/// Font database plus reusable layout scratch space. Create one per app.
185pub struct TextSystem {
186    fonts: FontContext,
187    layouts: LayoutContext<()>,
188    stats: TextStats,
189    cache: LayoutCache,
190}
191
192impl fmt::Debug for TextSystem {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        f.debug_struct("TextSystem").finish_non_exhaustive()
195    }
196}
197
198impl Default for TextSystem {
199    fn default() -> Self {
200        Self::new()
201    }
202}
203
204impl TextSystem {
205    /// Creates a text system with Inter registered and used for every generic family.
206    pub fn new() -> Self {
207        let collection = Collection::new(CollectionOptions { shared: false, system_fonts: false });
208        let mut system = Self {
209            fonts: FontContext { collection, source_cache: SourceCache::default() },
210            layouts: LayoutContext::new(),
211            stats: TextStats::default(),
212            cache: LayoutCache::default(),
213        };
214        let mut families = Vec::new();
215        for bytes in [INTER_REGULAR, INTER_SEMIBOLD] {
216            families.extend(system.register_static_font(bytes));
217        }
218        families.dedup();
219        system.register_static_font(RAVEN_ICONS);
220        for generic in [GenericFamily::SansSerif, GenericFamily::SystemUi, GenericFamily::UiSansSerif] {
221            system.fonts.collection.set_generic_families(generic, families.iter().copied());
222        }
223        #[cfg(feature = "mono")]
224        {
225            let mono = system.register_static_font(FIRA_MONO);
226            for generic in [GenericFamily::Monospace, GenericFamily::UiMonospace] {
227                system.fonts.collection.set_generic_families(generic, mono.iter().copied());
228            }
229        }
230        system
231    }
232
233    fn register_static_font(&mut self, bytes: &'static [u8]) -> Vec<FamilyId> {
234        let blob = Blob::new(Arc::new(bytes));
235        self.fonts.collection.register_fonts(blob, None).into_iter().map(|(id, _)| id).collect()
236    }
237
238    /// Registers a font file (TTF/OTF/TTC) and returns the family names it provides.
239    pub fn register_font(&mut self, bytes: Vec<u8>) -> Vec<String> {
240        // A new font can change what any text resolves to.
241        self.cache.clear();
242        let ids: Vec<FamilyId> = self
243            .fonts
244            .collection
245            .register_fonts(Blob::from(bytes), None)
246            .into_iter()
247            .map(|(id, _)| id)
248            .collect();
249        ids.into_iter().filter_map(|id| self.fonts.collection.family_name(id).map(str::to_owned)).collect()
250    }
251
252    /// What this system has done so far.
253    pub fn stats(&self) -> TextStats {
254        self.stats
255    }
256
257    /// Shapes `text`. Line breaking happens later in [`TextLayout::measure`].
258    pub fn layout(&mut self, text: &str, attrs: &TextAttrs) -> TextLayout {
259        let key = cache_key(text, attrs, None);
260        if let Some(layout) = self.cache.get(key, text, attrs) {
261            return layout;
262        }
263        let layout = self.shape(text, attrs);
264        self.cache.put(key, text, attrs, &layout);
265        layout
266    }
267
268    fn shape(&mut self, text: &str, attrs: &TextAttrs) -> TextLayout {
269        self.stats.shapes += 1;
270        let mut builder = self.layouts.ranged_builder(&mut self.fonts, text, 1.0, false);
271        let family = attrs.family.as_deref().unwrap_or(DEFAULT_FAMILY);
272        builder.push_default(StyleProperty::FontFamily(FontFamily::Source(family.into())));
273        builder.push_default(StyleProperty::FontSize(attrs.size));
274        builder.push_default(StyleProperty::FontWeight(FontWeight::new(attrs.weight)));
275        builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(attrs.line_height)));
276        if attrs.letter_spacing != 0.0 {
277            builder.push_default(StyleProperty::LetterSpacing(attrs.letter_spacing));
278        }
279        let layout = builder.build(text);
280        TextLayout { layout, broken_at: None }
281    }
282
283    /// Shapes `text` on one line, cut at a character boundary and ended with
284    /// `…` so it fits `max_width`. Text that already fits comes back whole.
285    pub fn layout_ellipsized(&mut self, text: &str, attrs: &TextAttrs, max_width: f32) -> TextLayout {
286        let key = cache_key(text, attrs, Some(max_width));
287        if let Some(layout) = self.cache.get(key, text, attrs) {
288            return layout;
289        }
290        let layout = self.fit(text, attrs, max_width);
291        self.cache.put(key, text, attrs, &layout);
292        layout
293    }
294
295    fn fit(&mut self, text: &str, attrs: &TextAttrs, max_width: f32) -> TextLayout {
296        let mut whole = self.layout(text, attrs);
297        if whole.measure(None).width <= max_width {
298            return whole;
299        }
300        let ends: Vec<usize> = text.char_indices().map(|(i, _)| i).collect();
301        let cut = |count: usize| {
302            let end = ends.get(count).copied().unwrap_or(text.len());
303            format!("{}…", text[..end].trim_end())
304        };
305        // The most characters that still fit, by bisection: shaping is the costly part.
306        let (mut low, mut high) = (0, ends.len());
307        while low < high {
308            let middle = (low + high).div_ceil(2);
309            if self.layout(&cut(middle), attrs).measure(None).width <= max_width {
310                low = middle;
311            } else {
312                high = middle - 1;
313            }
314        }
315        let mut fitted = self.layout(&cut(low), attrs);
316        fitted.measure(None);
317        fitted
318    }
319}
320
321/// One line of a broken [`TextLayout`], as reported by [`TextLayout::lines`].
322#[derive(Clone, Debug, PartialEq)]
323pub struct TextLine {
324    /// Byte range of the line in the source text, trailing whitespace included.
325    pub range: std::ops::Range<usize>,
326    /// Width of the line's content, trailing whitespace excluded.
327    pub width: f32,
328    /// Top of the line box, from the top of the layout.
329    pub top: f32,
330    /// Baseline, from the top of the layout.
331    pub baseline: f32,
332    /// Height of the line box.
333    pub height: f32,
334}
335
336/// One glyph of a [`TextLayout`], as reported by [`TextLayout::glyphs`].
337#[derive(Clone, Debug, PartialEq)]
338pub struct ShapedGlyph {
339    /// The glyph's index in the font.
340    pub id: u32,
341    /// Horizontal position, from the left of the layout.
342    pub x: f32,
343    /// How far the next glyph advances.
344    pub advance: f32,
345    /// Byte range of the source text this glyph's cluster stands for.
346    pub text: std::ops::Range<usize>,
347}
348
349/// Shaped text, ready to be broken into lines at any width and drawn.
350#[derive(Clone)]
351pub struct TextLayout {
352    layout: Layout<()>,
353    /// The width limit of the last line breaking, to skip redundant work.
354    broken_at: Option<Option<f32>>,
355}
356
357impl fmt::Debug for TextLayout {
358    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359        f.debug_struct("TextLayout")
360            .field("width", &self.layout.width())
361            .field("height", &self.layout.height())
362            .finish()
363    }
364}
365
366impl TextLayout {
367    /// Breaks lines at `max_width` (or never, for `None`) and returns the text's size.
368    pub fn measure(&mut self, max_width: Option<f32>) -> Size {
369        if self.broken_at != Some(max_width) {
370            self.layout.break_all_lines(max_width);
371            self.layout.align(Alignment::Start, AlignmentOptions::default());
372            self.broken_at = Some(max_width);
373        }
374        Size::new(self.layout.width(), self.layout.height())
375    }
376
377    /// The lines of the last [`Self::measure`], with their text ranges and
378    /// positions (documents and PDF export draw text line by line).
379    pub fn lines(&self) -> Vec<TextLine> {
380        self.layout
381            .lines()
382            .map(|line| {
383                let m = line.metrics();
384                TextLine {
385                    range: line.text_range(),
386                    width: (m.advance - m.hanging_advance).max(0.0),
387                    top: m.block_min_coord,
388                    baseline: m.baseline,
389                    height: m.block_max_coord - m.block_min_coord,
390                }
391            })
392            .collect()
393    }
394
395    /// The glyphs of the last [`Self::measure`] in visual order, with the text
396    /// each one stands for (document exporters embed glyphs, not characters).
397    pub fn glyphs(&self) -> Vec<ShapedGlyph> {
398        let mut glyphs = Vec::new();
399        for line in self.layout.lines() {
400            for item in line.items() {
401                let PositionedLayoutItem::GlyphRun(run) = item else { continue };
402                let mut x = run.offset();
403                for cluster in run.run().visual_clusters() {
404                    let text = cluster.text_range();
405                    for g in cluster.glyphs() {
406                        glyphs.push(ShapedGlyph {
407                            id: g.id,
408                            x: x + g.x,
409                            advance: g.advance,
410                            text: text.clone(),
411                        });
412                        x += g.advance;
413                    }
414                }
415            }
416        }
417        glyphs
418    }
419
420    /// Appends the glyphs, as laid out by the last [`Self::measure`], to `scene`
421    /// with their top-left corner at `origin`.
422    pub fn paint(&self, origin: Point, color: Color, scene: &mut Scene) {
423        for line in self.layout.lines() {
424            for item in line.items() {
425                let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
426                    continue;
427                };
428                let run = glyph_run.run();
429                let font = run.font();
430                let glyphs = glyph_run
431                    .positioned_glyphs()
432                    .map(|g| PositionedGlyph { id: g.id, x: origin.x + g.x, y: origin.y + g.y })
433                    .collect();
434                scene.push_glyphs(GlyphRun {
435                    font: FontRef {
436                        id: font.data.id(),
437                        data: Arc::new(font.data.clone()),
438                        index: font.index,
439                    },
440                    size: run.font_size(),
441                    color,
442                    glyphs,
443                });
444            }
445        }
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use ravenrs_core::Primitive;
452
453    use super::*;
454
455    #[test]
456    fn shapes_with_embedded_font() {
457        let mut ts = TextSystem::new();
458        let mut layout = ts.layout("Welcome to RavenRS", &TextAttrs::default());
459        let size = layout.measure(None);
460        assert!(size.width > 100.0 && size.width < 200.0, "unexpected width {}", size.width);
461        assert_eq!(size.height, 22.0);
462    }
463
464    #[test]
465    fn reports_lines_with_ranges_and_baselines() {
466        let mut ts = TextSystem::new();
467        let text = "one two three four five six";
468        let mut layout = ts.layout(text, &TextAttrs::default());
469        layout.measure(Some(60.0));
470        let lines = layout.lines();
471        assert!(lines.len() > 1);
472        assert_eq!(lines[0].range.start, 0);
473        assert_eq!(lines.last().map(|l| l.range.end), Some(text.len()));
474        for pair in lines.windows(2) {
475            assert_eq!(pair[0].range.end, pair[1].range.start, "lines cover the text without gaps");
476            assert!(pair[1].baseline > pair[0].baseline);
477        }
478        assert!(lines.iter().all(|l| l.width <= 60.0 + 0.5 && l.height == 22.0));
479        assert!(text[lines[0].range.clone()].ends_with(' '), "trailing space stays in the range");
480    }
481
482    #[test]
483    fn the_same_text_is_shaped_once() {
484        let mut ts = TextSystem::new();
485        let attrs = TextAttrs::default();
486        let mut first = ts.layout("Repeated text", &attrs);
487        let mut again = ts.layout("Repeated text", &attrs);
488        assert_eq!(ts.stats().shapes, 1);
489        assert_eq!(first.measure(None), again.measure(None));
490        ts.layout("Other text", &attrs);
491        ts.layout("Repeated text", &TextAttrs { weight: 600.0, ..TextAttrs::default() });
492        assert_eq!(ts.stats().shapes, 3, "other text and other attributes are other layouts");
493    }
494
495    #[test]
496    fn breaking_a_cached_layout_leaves_the_next_one_alone() {
497        let mut ts = TextSystem::new();
498        let text = "one two three four five six";
499        let mut narrow = ts.layout(text, &TextAttrs::default());
500        let narrow = narrow.measure(Some(60.0));
501        let wide = ts.layout(text, &TextAttrs::default()).measure(None);
502        assert!(narrow.height > wide.height, "the cache keeps no one's line breaks");
503    }
504
505    #[test]
506    fn cutting_text_to_fit_is_kept_per_width() {
507        let mut ts = TextSystem::new();
508        let attrs = TextAttrs::default();
509        let text = "A long sentence that will not fit in the space it was given";
510        ts.layout_ellipsized(text, &attrs, 100.0);
511        let shapes = ts.stats().shapes;
512        let mut fitted = ts.layout_ellipsized(text, &attrs, 100.0);
513        assert_eq!(ts.stats().shapes, shapes, "the bisection does not run again");
514        assert!(fitted.measure(None).width <= 100.0);
515        let mut wider = ts.layout_ellipsized(text, &attrs, 200.0);
516        assert!(wider.measure(None).width > fitted.measure(None).width);
517    }
518
519    #[test]
520    fn a_new_font_empties_the_cache() {
521        let mut ts = TextSystem::new();
522        ts.layout("Text", &TextAttrs::default());
523        ts.register_font(INTER_REGULAR.to_vec());
524        ts.layout("Text", &TextAttrs::default());
525        assert_eq!(ts.stats().shapes, 2);
526    }
527
528    #[test]
529    fn wraps_when_narrow() {
530        let mut ts = TextSystem::new();
531        let mut layout = ts.layout("one two three four five six", &TextAttrs::default());
532        let wide = layout.measure(None);
533        let narrow = layout.measure(Some(60.0));
534        assert!(narrow.height > wide.height);
535        assert!(narrow.width <= 60.0 + 0.5);
536    }
537
538    #[test]
539    fn semibold_is_wider_than_regular() {
540        let mut ts = TextSystem::new();
541        let regular = ts.layout("RavenRS", &TextAttrs::default()).measure(None).width;
542        let bold =
543            ts.layout("RavenRS", &TextAttrs { weight: 600.0, ..TextAttrs::default() }).measure(None).width;
544        assert!(bold > regular, "semibold face should be selected ({bold} vs {regular})");
545    }
546
547    #[test]
548    fn icon_font_is_registered() {
549        let mut ts = TextSystem::new();
550        let attrs = TextAttrs {
551            family: Some(ICON_FAMILY.into()),
552            size: 16.0,
553            line_height: 16.0,
554            ..Default::default()
555        };
556        // U+E151 is "search" in Lucide; any private-use glyph from the subset will do.
557        let mut layout = ts.layout("\u{e151}", &attrs);
558        let size = layout.measure(None);
559        assert!((size.width - 16.0).abs() < 1.0, "icon glyph should be one em wide, got {}", size.width);
560    }
561
562    #[test]
563    fn paints_one_glyph_per_character_offset_by_origin() {
564        let mut ts = TextSystem::new();
565        let mut layout = ts.layout("Raven", &TextAttrs::default());
566        layout.measure(None);
567        let mut scene = Scene::new();
568        layout.paint(Point::new(10.0, 20.0), Color::WHITE, &mut scene);
569        let Some(Primitive::Glyphs(run)) = scene.primitives().first() else {
570            panic!("expected a glyph run");
571        };
572        assert_eq!(run.glyphs.len(), 5);
573        assert!(run.glyphs[0].x >= 10.0);
574        assert!(run.glyphs[0].y > 20.0, "glyph y is the baseline below the origin");
575        assert!(!run.font.bytes().is_empty());
576    }
577}