Skip to main content

motion_content/
text.rs

1//! Text paragraphs, rich styles and grapheme-aware stateless part animation.
2//!
3//! ```no_run
4//! use motion_content::prelude::*;
5//! use motion_core::prelude::*;
6//! use motion_core::{
7//!     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
8//!     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
9//! };
10//! use motion_render::{Composition, RenderPrepare, RenderOptions, Renderer, render_still};
11//! let mut fonts = FontLibrary::new();
12//! fonts.load_file("assets/project.ttf")?;
13//! let resources = Assets::new(".")?.with_fonts(fonts);
14//! let text = Text::rich([
15//!     TextSpan::new("Hello, ").fill(Color::WHITE),
16//!     TextSpan::new("motion-rs").weight(FontWeight::BOLD),
17//! ])
18//! .size(28.0)
19//! .max_width(300.0);
20//! let frame = Composition::new(320, 120)
21//!     .resources(resources)
22//!     .render_frame(0, Size::new(320.0, 120.0), |_| text.clone().into())?;
23//! assert_eq!(frame.width(), 320);
24//! # Ok::<(), motion_core::Error>(())
25//! ```
26
27use crate::FontLibrary;
28
29use motion_core::{
30    Canvas, Color, DrawContext, Drawable, FillRule, Node, NodeModifiers, Paint, Rect, Time,
31    Transform, ValidationError,
32};
33use std::{
34    collections::BTreeMap,
35    ops::Range,
36    sync::{Arc, Mutex},
37};
38use unicode_segmentation::UnicodeSegmentation;
39
40mod drawable;
41pub(crate) mod glyph;
42mod layout;
43mod parts;
44#[cfg(test)]
45mod tests;
46
47pub use cosmic_text::Weight as FontWeight;
48pub use parts::{PartStyle, TextPart};
49
50/// Paragraph alignment, with start/end resolved by the paragraph direction.
51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
52pub enum TextAlignment {
53    /// Follow paragraph start.
54    #[default]
55    Start,
56    /// Follow paragraph end.
57    End,
58    /// Center each line within the requested width.
59    Center,
60    /// Justify nonfinal lines.
61    Justified,
62}
63
64/// Units used for visibility and per-part animation.
65#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
66pub enum TextSplit {
67    /// Extended Unicode grapheme clusters, not scalar chars.
68    #[default]
69    Characters,
70    /// Unicode words, with separators attached to the preceding word.
71    Words,
72    /// Wrapped visual lines.
73    Lines,
74}
75
76/// span style; unset properties inherit paragraph defaults.
77#[derive(Clone, Debug)]
78pub struct TextSpan {
79    text: Arc<str>,
80    family: Option<Arc<str>>,
81    size: Option<f64>,
82    weight: Option<FontWeight>,
83    italic: bool,
84    fill: Option<Paint>,
85}
86
87impl TextSpan {
88    /// Creates an inheriting UTF-8 span.
89    pub fn new(text: impl Into<Arc<str>>) -> Self {
90        Self {
91            text: text.into(),
92            family: None,
93            size: None,
94            weight: None,
95            italic: false,
96            fill: None,
97        }
98    }
99
100    /// Overrides family name.
101    pub fn family(mut self, family: impl Into<Arc<str>>) -> Self {
102        self.family = Some(family.into());
103        self
104    }
105
106    /// Overrides logical font size.
107    pub fn size(mut self, size: f64) -> Self {
108        self.size = Some(size);
109        self
110    }
111
112    /// Overrides OpenType weight.
113    pub fn weight(mut self, weight: FontWeight) -> Self {
114        self.weight = Some(weight);
115        self
116    }
117
118    /// Requests an italic face.
119    pub fn italic(mut self, italic: bool) -> Self {
120        self.italic = italic;
121        self
122    }
123
124    /// Overrides fill paint.
125    pub fn fill(mut self, paint: impl Into<Paint>) -> Self {
126        self.fill = Some(paint.into());
127        self
128    }
129}
130
131/// Public glyph provenance and logical ink/hit bounds from one paragraph layout.
132#[derive(Clone, Debug)]
133pub struct TextGlyph {
134    /// Original UTF-8 cluster byte range.
135    pub source: Range<usize>,
136    /// Wrapped visual line number.
137    pub line: usize,
138    /// Explicit/fallback font family selected by the shaper.
139    pub family: Arc<str>,
140    /// Local glyph hit/ink bounds, including baseline placement.
141    pub bounds: Rect,
142    artwork: Arc<glyph::Artwork>,
143    transform: Transform,
144    span: usize,
145}
146
147/// Immutable paragraph geometry; color/part transforms do not reshape it.
148#[derive(Clone, Debug)]
149pub struct ParagraphLayout {
150    glyphs: Arc<[TextGlyph]>,
151    bounds: Rect,
152    baseline: f64,
153    lines: Arc<[Rect]>,
154    line_sources: Arc<[Range<usize>]>,
155    text: Arc<str>,
156}
157
158impl ParagraphLayout {
159    /// Logical bounds enclosing line boxes and glyph ink.
160    pub fn bounds(&self) -> Rect {
161        self.bounds
162    }
163
164    /// Real first-line baseline in local coordinates.
165    pub fn baseline(&self) -> f64 {
166        self.baseline
167    }
168
169    /// Shaped glyphs and their original cluster ranges.
170    pub fn glyphs(&self) -> &[TextGlyph] {
171        &self.glyphs
172    }
173
174    /// Wrapped visual line boxes.
175    pub fn lines(&self) -> &[Rect] {
176        &self.lines
177    }
178}
179
180type LayoutCache = Arc<Mutex<BTreeMap<u64, Result<Arc<ParagraphLayout>, ValidationError>>>>;
181
182pub(crate) type SharedLayouts = Arc<Mutex<BTreeMap<LayoutKey, Arc<ParagraphLayout>>>>;
183
184type SpanLayoutKey = (Arc<str>, Option<Arc<str>>, Option<u64>, Option<u16>, bool);
185
186#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
187pub(crate) struct LayoutKey {
188    spans: Vec<SpanLayoutKey>,
189    family: Arc<str>,
190    size: u64,
191    weight: u16,
192    line_height: u64,
193    spacing: u64,
194    width: Option<u64>,
195    alignment: TextAlignment,
196}
197
198/// multi-line Unicode text; cosmic-text is the sole paragraph shaper,
199/// while unhinted skrifa outlines enter the ordinary shared fill pipeline.
200#[derive(Clone, Debug)]
201pub struct Text {
202    spans: Arc<[TextSpan]>,
203    family: Arc<str>,
204    size: f64,
205    weight: FontWeight,
206    fill: Paint,
207    stroke: Option<(f64, Paint)>,
208    line_height: f64,
209    spacing: f64,
210    width: Option<f64>,
211    alignment: TextAlignment,
212
213    write_progress: Option<f64>,
214    part_animation: Option<PartAnimation>,
215    split: TextSplit,
216    visible: Option<usize>,
217
218    fonts: Option<FontLibrary>,
219
220    layout_cache: LayoutCache,
221}
222
223#[derive(Clone)]
224struct PartAnimation {
225    delay: motion_core::Duration,
226    style: Arc<dyn Fn(&TextPart) -> PartStyle + Send + Sync>,
227}
228
229impl std::fmt::Debug for PartAnimation {
230    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        formatter
232            .debug_struct("PartAnimation")
233            .field("delay", &self.delay)
234            .finish_non_exhaustive()
235    }
236}
237
238impl Text {
239    /// Builds and validates text using one explicit immutable font face.
240    pub fn from_font(
241        text: &str,
242        font: &crate::FontFace,
243        size: f64,
244        paint: impl Into<Paint>,
245    ) -> Result<Self, ValidationError> {
246        if !size.is_finite() || size <= 0.0 || size > 4096.0 {
247            return Err(invalid(
248                "font_size",
249                "Require a finite font size in (0, 4096]",
250            ));
251        }
252        let fonts = font.library()?;
253        let face = fonts
254            .database()
255            .faces()
256            .find(|face| face.index == font.index())
257            .ok_or_else(|| invalid("font", "Explicit font face is unavailable"))?;
258        let family = face
259            .families
260            .first()
261            .ok_or_else(|| invalid("font", "Explicit font face has no family"))?
262            .0
263            .clone();
264        let span = TextSpan::new(text)
265            .family(family)
266            .weight(FontWeight(face.weight.0))
267            .italic(face.style == fontdb::Style::Italic);
268        let value = Self::rich([span]).fonts(&fonts).size(size).fill(paint);
269        value.layout(&fonts)?;
270        Ok(value)
271    }
272
273    /// Creates a paragraph with size 24, line height 1.3 and white fill.
274    pub fn new(text: impl Into<Arc<str>>) -> Self {
275        Self::rich([TextSpan::new(text)])
276    }
277
278    /// Creates rich spans, whose unset styles inherit paragraph defaults.
279    pub fn rich(spans: impl IntoIterator<Item = TextSpan>) -> Self {
280        Self {
281            write_progress: None,
282            part_animation: None,
283            spans: spans.into_iter().take(257).collect::<Vec<_>>().into(),
284            family: "".into(),
285            size: 24.0,
286            weight: FontWeight::NORMAL,
287            fill: Color::WHITE.into(),
288            stroke: None,
289            line_height: 1.3,
290            spacing: 0.0,
291            width: None,
292            alignment: TextAlignment::Start,
293            fonts: None,
294            split: TextSplit::Characters,
295            visible: None,
296            layout_cache: Arc::new(Mutex::new(BTreeMap::new())),
297        }
298    }
299
300    fn invalidate_layout_cache(&mut self) {
301        self.layout_cache = Arc::new(Mutex::new(BTreeMap::new()));
302    }
303
304    /// Selects an explicit family; empty uses the library's generic default.
305    pub fn family(mut self, family: impl Into<Arc<str>>) -> Self {
306        let family = family.into();
307        if self.family != family {
308            self.family = family;
309            self.invalidate_layout_cache();
310        }
311        self
312    }
313
314    /// Sets finite logical size in (0,4096].
315    pub fn size(mut self, size: f64) -> Self {
316        if self.size.to_bits() != size.to_bits() {
317            self.size = size;
318            self.invalidate_layout_cache();
319        }
320        self
321    }
322
323    /// Selects OpenType weight; variable weight participates in glyph identity.
324    pub fn weight(mut self, weight: FontWeight) -> Self {
325        if self.weight != weight {
326            self.weight = weight;
327            self.invalidate_layout_cache();
328        }
329        self
330    }
331
332    /// Sets paint without invalidating shaped geometry.
333    pub fn fill(mut self, paint: impl Into<Paint>) -> Self {
334        self.fill = paint.into();
335        self
336    }
337
338    /// Adds stroke after filling; existing bounded stroke rules apply.
339    pub fn stroke(mut self, width: f64, paint: impl Into<Paint>) -> Self {
340        self.stroke = Some((width, paint.into()));
341        self
342    }
343
344    /// Sets a positive relative line-height multiplier.
345    pub fn line_height(mut self, height: f64) -> Self {
346        if self.line_height.to_bits() != height.to_bits() {
347            self.line_height = height;
348            self.invalidate_layout_cache();
349        }
350        self
351    }
352
353    /// Sets logical tracking, converted to em internally for shaping.
354    pub fn letter_spacing(mut self, spacing: f64) -> Self {
355        if self.spacing.to_bits() != spacing.to_bits() {
356            self.spacing = spacing;
357            self.invalidate_layout_cache();
358        }
359        self
360    }
361
362    /// Wraps to finite positive logical width.
363    pub fn max_width(mut self, width: f64) -> Self {
364        if self.width.map(f64::to_bits) != Some(width.to_bits()) {
365            self.width = Some(width);
366            self.invalidate_layout_cache();
367        }
368        self
369    }
370
371    /// Selects bidirectional-aware paragraph alignment.
372    pub fn alignment(mut self, alignment: TextAlignment) -> Self {
373        if self.alignment != alignment {
374            self.alignment = alignment;
375            self.invalidate_layout_cache();
376        }
377        self
378    }
379
380    /// Binds an explicit font snapshot, overriding a DrawContext environment.
381    pub fn fonts(mut self, fonts: &FontLibrary) -> Self {
382        self.fonts = Some(fonts.clone());
383        self
384    }
385
386    /// Selects grapheme, word-boundary or wrapped-line parts.
387    pub fn split(mut self, split: TextSplit) -> Self {
388        self.split = split;
389        self
390    }
391
392    /// Keeps original layout while drawing only the first logical parts.
393    pub fn visible_parts(mut self, count: usize) -> Self {
394        self.visible = Some(count);
395        self
396    }
397
398    /// Defers vector handwriting to compilation with the current font environment.
399    pub fn write(mut self, progress: f64) -> Self {
400        self.write_progress = Some(progress);
401        self
402    }
403
404    /// Defers per-part style evaluation to scene-local drawing time.
405    pub fn animate_parts(
406        mut self,
407        delay: motion_core::Duration,
408        style: impl Fn(&TextPart) -> PartStyle + Send + Sync + 'static,
409    ) -> Self {
410        self.part_animation = Some(PartAnimation {
411            delay,
412            style: Arc::new(style),
413        });
414        self
415    }
416
417    /// Applies the common vector handwriting effect with default contour/fill
418    /// handoff. Bind fonts on this Text or supply them in the DrawContext.
419    pub fn write_with_context(
420        &self,
421        progress: f64,
422        context: &DrawContext,
423    ) -> Result<Node, ValidationError> {
424        self.vector_tree(context)?
425            .write(progress, crate::vector::WriteOptions::default())
426    }
427
428    /// Exports original shared glyph curves, color layers, bitmap strikes and
429    /// source ranges into the common vector model. No second shaping pass occurs.
430    pub fn vector_tree(
431        &self,
432        context: &DrawContext,
433    ) -> Result<Arc<crate::vector::VectorTree>, ValidationError> {
434        use crate::vector::{VectorGroup, VectorImage, VectorItem, VectorPath, VectorTree};
435        let layout = self.resolve(context)?;
436        let parts = layout.parts(self.split);
437        let mut children = Vec::new();
438        for (index, glyph) in layout.glyphs.iter().enumerate() {
439            let owner = parts.iter().position(|part| part.owns(glyph)).unwrap_or(0);
440            if self.visible.is_some_and(|visible| owner >= visible) {
441                continue;
442            }
443            let fill = self.spans[glyph.span]
444                .fill
445                .as_ref()
446                .unwrap_or(&self.fill)
447                .clone();
448            let mut ink = Vec::new();
449            if !glyph.artwork.path.verbs().is_empty() {
450                ink.push(VectorItem::Path(VectorPath {
451                    id: None,
452                    path: glyph.artwork.path.clone(),
453                    fill: Some((fill.clone(), FillRule::NonZero)),
454                    stroke: None,
455                    source: None,
456                }));
457            }
458            for (path, color) in &glyph.artwork.layers {
459                ink.push(VectorItem::Path(VectorPath {
460                    id: None,
461                    path: path.clone(),
462                    fill: Some((
463                        color.map_or_else(|| fill.clone(), Paint::from),
464                        FillRule::NonZero,
465                    )),
466                    stroke: None,
467                    source: None,
468                }));
469            }
470            let mut glyph_children = vec![VectorItem::Group(VectorGroup {
471                transform: glyph.transform,
472                children: ink,
473                ..Default::default()
474            })];
475            if let Some((image, local)) = &glyph.artwork.bitmap {
476                let pattern =
477                    motion_core::ImagePattern::from_asset(image.clone()).transform(*local);
478                let rect = motion_core::display_list::mapped_bounds(
479                    Rect::new(
480                        0.0,
481                        0.0,
482                        f64::from(image.width()),
483                        f64::from(image.height()),
484                    ),
485                    *local,
486                    "text.bitmap",
487                )?;
488                let mut image = VectorImage::new(image.clone(), rect);
489                image.pixel_transform = *local;
490                image.sampling = pattern.sampling;
491                image.extend = pattern.extend;
492                image.decal = pattern.decal;
493                glyph_children.push(VectorItem::Group(VectorGroup {
494                    transform: glyph.transform,
495                    children: vec![VectorItem::Image(image)],
496                    ..Default::default()
497                }));
498            }
499            if let Some((width, paint)) = &self.stroke {
500                for path in std::iter::once(&glyph.artwork.path)
501                    .chain(glyph.artwork.layers.iter().map(|(path, _)| path))
502                    .filter(|path| !path.verbs().is_empty())
503                {
504                    let outline = motion_core::StrokeStyle {
505                        width: *width,
506                        ..Default::default()
507                    }
508                    .outline(&path.transformed(&glyph.transform)?, "text.vector.stroke")?;
509                    glyph_children
510                        .push(VectorItem::Path(VectorPath::filled(outline, paint.clone())));
511                }
512            }
513            children.push(VectorItem::Group(VectorGroup {
514                id: Some(format!("glyph-{index}").into()),
515                source: Some(glyph.source.clone()),
516                children: glyph_children,
517                ..Default::default()
518            }));
519        }
520        Ok(Arc::new(
521            VectorTree::new(
522                self.try_bounds(context)?,
523                VectorGroup {
524                    children,
525                    ..Default::default()
526                },
527            )?
528            .with_baseline(layout.baseline),
529        ))
530    }
531}
532
533pub(crate) fn invalid(field: &str, message: &str) -> ValidationError {
534    ValidationError::invalid("text", field, message)
535}
536
537pub(crate) fn limited(message: &str) -> ValidationError {
538    ValidationError::limit("text", "work", message)
539}