Skip to main content

oxframe_text/
lib.rs

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