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