Skip to main content

oxframe_text/
lib.rs

1//! Text for oxframe: layout with `parley`, glyph outlines with `skrifa`.
2//!
3//! Text becomes a [`BezPath`], so it renders identically on every backend
4//! and can be morphed and trimmed like any other path.
5//!
6//! Determinism: only embedded fonts are used — Inter and JetBrains Mono
7//! (SIL OFL 1.1, see `fonts/`) plus fonts registered with
8//! [`register_font`]. System fonts are never consulted.
9
10#![warn(missing_docs)]
11
12mod code;
13
14pub use code::{Lang, Token, highlight};
15
16use std::cell::RefCell;
17use std::hash::{Hash, Hasher};
18use std::rc::Rc;
19
20use kurbo::{Affine, BezPath, Rect};
21use parley::fontique::{Blob, FontInfoOverride};
22use parley::{
23    Alignment, AlignmentOptions, FontContext, FontFamily, FontWeight, Layout, LayoutContext,
24    LineHeight, PositionedLayoutItem, StyleProperty,
25};
26use skrifa::instance::{LocationRef, Size};
27use skrifa::outline::{DrawSettings, OutlinePen};
28use skrifa::{FontRef, GlyphId, MetadataProvider};
29
30const INTER: &[u8] = include_bytes!("../fonts/Inter.ttf");
31const JETBRAINS_MONO: &[u8] = include_bytes!("../fonts/JetBrainsMono.ttf");
32
33/// Built-in sans-serif family (Inter).
34pub const SANS: &str = "Inter";
35/// Built-in monospace family (JetBrains Mono).
36pub const MONO: &str = "JetBrains Mono";
37
38/// Horizontal alignment of lines.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
40pub enum Align {
41    #[default]
42    /// Left.
43    Start,
44    /// Centered.
45    Center,
46    /// Right.
47    End,
48}
49
50/// How text is laid out.
51#[derive(Debug, Clone, PartialEq)]
52pub struct TextStyle {
53    /// Font family name: [`SANS`], [`MONO`] or a registered family.
54    pub family: String,
55    /// Font size in pixels.
56    pub size: f64,
57    /// 100..=900.
58    pub weight: f64,
59    /// Italic style.
60    pub italic: bool,
61    /// Multiple of the font size.
62    pub line_height: f64,
63    /// Extra space between letters, in pixels.
64    pub letter_spacing: f64,
65    /// Alignment of lines.
66    pub align: Align,
67    /// Wrap width; `None` means no wrapping.
68    pub max_width: Option<f64>,
69}
70
71impl Default for TextStyle {
72    fn default() -> Self {
73        Self {
74            family: SANS.to_owned(),
75            size: 48.0,
76            weight: 400.0,
77            italic: false,
78            line_height: 1.2,
79            letter_spacing: 0.0,
80            align: Align::Start,
81            max_width: None,
82        }
83    }
84}
85
86impl Hash for TextStyle {
87    fn hash<H: Hasher>(&self, h: &mut H) {
88        self.family.hash(h);
89        for v in [
90            self.size,
91            self.weight,
92            self.line_height,
93            self.letter_spacing,
94        ] {
95            v.to_bits().hash(h);
96        }
97        self.italic.hash(h);
98        self.align.hash(h);
99        self.max_width.map(f64::to_bits).hash(h);
100    }
101}
102
103/// A run of text with its own style; unset fields inherit the base style.
104#[derive(Debug, Clone, PartialEq, Default)]
105pub struct TextSpan {
106    /// The text.
107    pub text: String,
108    /// Font family.
109    pub family: Option<String>,
110    /// Font size in pixels.
111    pub size: Option<f64>,
112    /// Weight, 100..=900.
113    pub weight: Option<f64>,
114    /// Italic style.
115    pub italic: Option<bool>,
116}
117
118impl Hash for TextSpan {
119    fn hash<H: Hasher>(&self, h: &mut H) {
120        self.text.hash(h);
121        self.family.hash(h);
122        self.size.map(f64::to_bits).hash(h);
123        self.weight.map(f64::to_bits).hash(h);
124        self.italic.hash(h);
125    }
126}
127
128/// Where a glyph came from.
129#[derive(Debug, Clone, Copy, PartialEq)]
130pub struct GlyphInfo {
131    /// Index of the span (0 for plain text).
132    pub span: u16,
133    /// First character of the glyph's cluster.
134    pub ch: char,
135    /// Pen position of the glyph (baseline origin).
136    pub origin: kurbo::Point,
137}
138
139/// Laid-out text. Coordinates: origin at the top-left of the layout box,
140/// y pointing down.
141#[derive(Debug, Clone)]
142pub struct TextLayout {
143    /// All glyph outlines.
144    pub path: BezPath,
145    /// One path per glyph, in visual order — for per-letter animation.
146    pub glyphs: Vec<BezPath>,
147    /// Parallel to `glyphs`.
148    pub info: Vec<GlyphInfo>,
149    /// Width of the layout box.
150    pub width: f64,
151    /// Height of the layout box.
152    pub height: f64,
153}
154
155impl TextLayout {
156    /// The layout box as a rectangle.
157    pub fn bounds(&self) -> Rect {
158        Rect::new(0.0, 0.0, self.width, self.height)
159    }
160}
161
162struct Fonts {
163    font_cx: FontContext,
164    layout_cx: LayoutContext<u16>,
165    cache: Vec<(u64, Rc<TextLayout>)>,
166}
167
168const CACHE_SIZE: usize = 256;
169
170thread_local! {
171    static FONTS: RefCell<Fonts> = RefCell::new(Fonts::new());
172}
173
174impl Fonts {
175    fn new() -> Self {
176        let mut font_cx = FontContext::new();
177        for data in [INTER, JETBRAINS_MONO] {
178            font_cx
179                .collection
180                .register_fonts(Blob::new(std::sync::Arc::new(data)), None);
181        }
182        Self {
183            font_cx,
184            layout_cx: LayoutContext::new(),
185            cache: Vec::new(),
186        }
187    }
188}
189
190/// Registers a font (TTF/OTF/TTC bytes, e.g. from `include_bytes!`).
191/// Returns the family names it provides.
192pub fn register_font(data: &'static [u8]) -> Vec<String> {
193    FONTS.with(|f| {
194        let mut f = f.borrow_mut();
195        f.cache.clear();
196        let collection = &mut f.font_cx.collection;
197        let families = collection.register_fonts(
198            Blob::new(std::sync::Arc::new(data)),
199            None::<FontInfoOverride<'_>>,
200        );
201        families
202            .into_iter()
203            .filter_map(|(id, _)| collection.family_name(id).map(str::to_owned))
204            .collect()
205    })
206}
207
208/// Lays out `text` (cached by text and style).
209pub fn layout(text: &str, style: &TextStyle) -> Rc<TextLayout> {
210    layout_rich(
211        &[TextSpan {
212            text: text.to_owned(),
213            ..Default::default()
214        }],
215        style,
216    )
217}
218
219/// Lays out styled spans as one paragraph (cached).
220pub fn layout_rich(spans: &[TextSpan], style: &TextStyle) -> Rc<TextLayout> {
221    let mut h = std::hash::DefaultHasher::new();
222    spans.hash(&mut h);
223    style.hash(&mut h);
224    let key = h.finish();
225    FONTS.with(|f| {
226        let mut f = f.borrow_mut();
227        if let Some(pos) = f.cache.iter().position(|(k, _)| *k == key) {
228            let entry = f.cache.remove(pos);
229            let value = entry.1.clone();
230            f.cache.push(entry);
231            return value;
232        }
233        let value = Rc::new(build(&mut f, spans, style));
234        if f.cache.len() >= CACHE_SIZE {
235            f.cache.remove(0);
236        }
237        f.cache.push((key, value.clone()));
238        value
239    })
240}
241
242fn build(f: &mut Fonts, spans: &[TextSpan], style: &TextStyle) -> TextLayout {
243    let Fonts {
244        font_cx, layout_cx, ..
245    } = f;
246    let text: String = spans.iter().map(|s| s.text.as_str()).collect();
247    let text = text.as_str();
248    let mut b = layout_cx.ranged_builder(font_cx, text, 1.0, true);
249    b.push_default(StyleProperty::FontFamily(FontFamily::named(&style.family)));
250    b.push_default(StyleProperty::FontSize(style.size as f32));
251    b.push_default(StyleProperty::FontWeight(FontWeight::new(
252        style.weight as f32,
253    )));
254    if style.italic {
255        b.push_default(StyleProperty::FontStyle(parley::FontStyle::Italic));
256    }
257    b.push_default(StyleProperty::LineHeight(LineHeight::FontSizeRelative(
258        style.line_height as f32,
259    )));
260    b.push_default(StyleProperty::LetterSpacing(style.letter_spacing as f32));
261    b.push_default(StyleProperty::Brush(0u16));
262    let mut at = 0;
263    for (i, span) in spans.iter().enumerate() {
264        let r = at..at + span.text.len();
265        at = r.end;
266        if r.is_empty() {
267            continue;
268        }
269        b.push(StyleProperty::Brush(i as u16), r.clone());
270        if let Some(f) = &span.family {
271            b.push(StyleProperty::FontFamily(FontFamily::named(f)), r.clone());
272        }
273        if let Some(sz) = span.size {
274            b.push(StyleProperty::FontSize(sz as f32), r.clone());
275        }
276        if let Some(w) = span.weight {
277            b.push(
278                StyleProperty::FontWeight(FontWeight::new(w as f32)),
279                r.clone(),
280            );
281        }
282        if let Some(it) = span.italic {
283            let st = if it {
284                parley::FontStyle::Italic
285            } else {
286                parley::FontStyle::Normal
287            };
288            b.push(StyleProperty::FontStyle(st), r.clone());
289        }
290    }
291    let mut layout: Layout<u16> = b.build(text);
292    layout.break_all_lines(style.max_width.map(|w| w as f32));
293    let align = match style.align {
294        Align::Start => Alignment::Start,
295        Align::Center => Alignment::Center,
296        Align::End => Alignment::End,
297    };
298    layout.align(align, AlignmentOptions::default());
299
300    let mut path = BezPath::new();
301    let mut glyphs = Vec::new();
302    let mut info = Vec::new();
303    for line in layout.lines() {
304        for item in line.items() {
305            let PositionedLayoutItem::GlyphRun(run) = item else {
306                continue;
307            };
308            let r = run.run();
309            let span = run.style().brush;
310            // The source character of every glyph, in visual order.
311            let chars: Vec<char> = r
312                .visual_clusters()
313                .flat_map(|c| {
314                    let ch = text[c.text_range()].chars().next().unwrap_or('\0');
315                    std::iter::repeat_n(ch, c.glyphs().count())
316                })
317                .collect();
318            let font = r.font();
319            let Ok(font_ref) = FontRef::from_index(font.data.as_ref(), font.index) else {
320                continue;
321            };
322            let outlines = font_ref.outline_glyphs();
323            let coords = r.normalized_coords();
324            let size = Size::new(r.font_size());
325            let coords: Vec<skrifa::instance::NormalizedCoord> = coords
326                .iter()
327                .map(|c| skrifa::raw::types::F2Dot14::from_bits(c.to_bits()))
328                .collect();
329            let location = LocationRef::new(&coords);
330            // Synthetic italic when the family has no italic face.
331            let skew = r
332                .synthesis()
333                .skew()
334                .map_or(0.0, |deg| f64::from(deg).to_radians().tan());
335            for (gi, g) in run.positioned_glyphs().enumerate() {
336                let Some(outline) = outlines.get(GlyphId::new(g.id)) else {
337                    continue;
338                };
339                let mut pen = Pen(BezPath::new());
340                if outline
341                    .draw(DrawSettings::unhinted(size, location), &mut pen)
342                    .is_err()
343                {
344                    continue;
345                }
346                // Font units are y-up; flip and move to the pen position.
347                let tf = Affine::translate((f64::from(g.x), f64::from(g.y)))
348                    * Affine::scale_non_uniform(1.0, -1.0)
349                    * Affine::skew(skew, 0.0);
350                let glyph = tf * pen.0;
351                if !glyph.elements().is_empty() {
352                    path.extend(glyph.elements().iter().copied());
353                    glyphs.push(glyph);
354                    info.push(GlyphInfo {
355                        span,
356                        ch: chars.get(gi).copied().unwrap_or('\0'),
357                        origin: kurbo::Point::new(f64::from(g.x), f64::from(g.y)),
358                    });
359                }
360            }
361        }
362    }
363    TextLayout {
364        path,
365        glyphs,
366        info,
367        width: f64::from(layout.width()),
368        height: f64::from(layout.height()),
369    }
370}
371
372struct Pen(BezPath);
373
374impl OutlinePen for Pen {
375    fn move_to(&mut self, x: f32, y: f32) {
376        self.0.move_to((f64::from(x), f64::from(y)));
377    }
378    fn line_to(&mut self, x: f32, y: f32) {
379        self.0.line_to((f64::from(x), f64::from(y)));
380    }
381    fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
382        self.0.quad_to(
383            (f64::from(cx0), f64::from(cy0)),
384            (f64::from(x), f64::from(y)),
385        );
386    }
387    fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
388        self.0.curve_to(
389            (f64::from(cx0), f64::from(cy0)),
390            (f64::from(cx1), f64::from(cy1)),
391            (f64::from(x), f64::from(y)),
392        );
393    }
394    fn close(&mut self) {
395        self.0.close_path();
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use kurbo::Shape;
402
403    use super::*;
404
405    #[test]
406    fn lays_out_text_with_embedded_font() {
407        let l = layout("Hello", &TextStyle::default());
408        assert_eq!(l.glyphs.len(), 5);
409        assert!(l.width > 100.0 && l.width < 160.0, "width {}", l.width);
410        let bb = l.path.bounding_box();
411        assert!(bb.y0 >= 0.0 && bb.y1 <= l.height, "{bb:?} h={}", l.height);
412    }
413
414    #[test]
415    fn mono_and_weight_change_shapes() {
416        let s = TextStyle::default();
417        let regular = layout("iii", &s);
418        let mono = layout(
419            "iii",
420            &TextStyle {
421                family: MONO.into(),
422                ..s.clone()
423            },
424        );
425        let bold = layout("iii", &TextStyle { weight: 800.0, ..s });
426        assert!(mono.width > regular.width);
427        assert_ne!(bold.path, regular.path);
428    }
429
430    #[test]
431    fn wraps_and_centers() {
432        let s = TextStyle {
433            max_width: Some(120.0),
434            align: Align::Center,
435            ..Default::default()
436        };
437        let l = layout("one two three four", &s);
438        assert!(l.height > 2.0 * 48.0);
439    }
440
441    #[test]
442    fn rich_spans_carry_index_and_chars() {
443        let spans = [
444            TextSpan {
445                text: "ab ".into(),
446                ..Default::default()
447            },
448            TextSpan {
449                text: "cd".into(),
450                weight: Some(800.0),
451                ..Default::default()
452            },
453        ];
454        let l = layout_rich(&spans, &TextStyle::default());
455        let got: Vec<(u16, char)> = l.info.iter().map(|i| (i.span, i.ch)).collect();
456        assert_eq!(got, vec![(0, 'a'), (0, 'b'), (1, 'c'), (1, 'd')]);
457        assert!(l.info[1].origin.x > l.info[0].origin.x);
458    }
459
460    #[test]
461    fn cyrillic_is_supported() {
462        let l = layout("Привет", &TextStyle::default());
463        assert_eq!(l.glyphs.len(), 6);
464    }
465}