Skip to main content

ranim_items/vitem/
text.rs

1#[cfg(feature = "render")]
2use ranim_render::extract::{Extract, ExtractContext};
3
4use std::{collections::HashMap, sync::OnceLock};
5
6use ranim_core::{
7    color::{AlphaColor, Srgb},
8    glam::{DMat3, DVec3},
9    traits::{
10        Aabb, Discard, FillColor, Locate, PointsFunc, ScaleTransform, ShiftTransform, StrokeColor,
11        StrokeWidth, With,
12    },
13};
14use typst::foundations::Repr;
15
16use crate::vitem::{
17    VItem,
18    geometry::{Parallelogram, anchor::Origin},
19    svg::SvgItem,
20    typst::typst_svg,
21};
22pub use typst::text::{FontStretch, FontStyle, FontVariant, FontWeight};
23
24/// Font information for text items
25#[derive(Clone, Debug)]
26pub struct TextFont {
27    families: Vec<String>,
28    variant: FontVariant,
29    features: HashMap<String, u32>,
30}
31
32impl TextFont {
33    /// Create a new font
34    pub fn new(families: impl IntoIterator<Item = impl Into<String>>) -> Self {
35        Self {
36            families: families.into_iter().map(|v| v.into()).collect(),
37            variant: Default::default(),
38            features: Default::default(),
39        }
40    }
41    /// Set font weight
42    pub fn with_weight(mut self, weight: FontWeight) -> Self {
43        self.variant.weight = weight;
44        self
45    }
46    /// Set font style
47    pub fn with_style(mut self, style: FontStyle) -> Self {
48        self.variant.style = style;
49        self
50    }
51    /// Set font stretch
52    pub fn with_stretch(mut self, stretch: FontStretch) -> Self {
53        self.variant.stretch = stretch;
54        self
55    }
56    /// Add OTF features
57    pub fn with_features(
58        mut self,
59        features: impl IntoIterator<Item = (impl Into<String>, u32)>,
60    ) -> Self {
61        self.features
62            .extend(features.into_iter().map(|(k, v)| (k.into(), v)));
63        self
64    }
65}
66
67impl Default for TextFont {
68    fn default() -> Self {
69        Self::new(["New Computer Modern", "Libertinus Serif"])
70    }
71}
72
73/// Simple single-line text item
74#[derive(Clone, Debug)]
75pub struct TextItem {
76    /// Text content
77    text: String,
78    /// Intrinsic em size in local coordinates.
79    em_size: f64,
80    /// Font info
81    font: TextFont,
82    /// Fill color
83    fill_rgbas: AlphaColor<Srgb>,
84    /// Stroke color
85    stroke_rgbas: AlphaColor<Srgb>,
86    /// Stroke width
87    stroke_width: f32,
88    /// Cached items
89    items: OnceLock<Vec<VItem>>,
90    /// cached text inline size
91    inline_length_em: OnceLock<f64>,
92}
93
94impl Locate<TextItem> for Origin {
95    fn locate(&self, _target: &TextItem) -> DVec3 {
96        DVec3::ZERO
97    }
98}
99
100impl TextItem {
101    /// Create a new text item
102    pub fn new(text: impl Into<String>, em_size: f64) -> Self {
103        Self {
104            text: text.into(),
105            em_size,
106            font: TextFont::default(),
107            fill_rgbas: AlphaColor::WHITE,
108            stroke_rgbas: AlphaColor::WHITE,
109            stroke_width: 0.0,
110            items: OnceLock::new(),
111            inline_length_em: OnceLock::new(),
112        }
113    }
114
115    /// Set font
116    pub fn with_font(mut self, font: TextFont) -> Self {
117        self.font = font;
118        self.items.take();
119        self.inline_length_em.take();
120        self
121    }
122
123    /// Get font
124    pub fn font(&self) -> &TextFont {
125        &self.font
126    }
127
128    /// Get intrinsic em size.
129    pub fn em_size(&self) -> f64 {
130        self.em_size
131    }
132
133    /// Get the canonical local basis.
134    pub fn basis(&self) -> (DVec3, DVec3) {
135        (DVec3::X * self.em_size, DVec3::Y * self.em_size)
136    }
137
138    /// Get text
139    pub fn text(&self) -> &str {
140        &self.text
141    }
142
143    /// Get inline length in em units
144    pub fn inline_length_em(&self) -> f64 {
145        let _ = self.vitems(); // ensure outlines are generated
146        *self.inline_length_em.get().unwrap()
147    }
148
149    /// Returns the canonical local text outline box from the baseline origin.
150    ///
151    /// Positioning and orientation are supplied by `Transformed<TextItem, G>`.
152    pub fn text_box(&self) -> Parallelogram {
153        let (u, v) = self.basis();
154        Parallelogram::from_origin_and_axes(DVec3::ZERO, (u * self.inline_length_em(), v))
155    }
156
157    fn generate_items(&self) -> Vec<VItem> {
158        let font = &self.font;
159        let text = self.text.as_str();
160
161        // font families
162        let mut families = String::new();
163        for family in font.families.iter() {
164            families.push('"');
165            families.push_str(family);
166            families.push_str("\", ");
167        }
168
169        // font weight as an integer between 100 and 900
170        let weight = font.variant.weight.to_number();
171
172        // font style
173        let style = {
174            use FontStyle::*;
175            match font.variant.style {
176                Normal => "normal",
177                Italic => "italic",
178                Oblique => "oblique",
179            }
180        };
181
182        // font stretch
183        let stretch = font.variant.stretch.to_ratio().repr();
184
185        // OTF features
186        let features = if font.features.is_empty() {
187            ":".to_string()
188        } else {
189            let mut features = String::new();
190            for (tag, value) in font.features.iter() {
191                features.push('"');
192                features.push_str(tag);
193                features.push_str("\": ");
194                features.push_str(value.to_string().as_str());
195                features.push_str(", ");
196            }
197            features
198        };
199
200        let svg_src = typst_svg(
201            format!(
202                r#"#set text(
203    top-edge: 1em,
204    font: ({families}),
205    weight: {weight},
206    style: "{style}",
207    stretch: {stretch},
208    features: ({features}),
209)
210#set page(
211    width: auto,
212    height: auto,
213    margin: 0pt,
214    background: rect(width: 100%, height: 100%),
215)
216
217{text}
218"#
219            )
220            .as_str(),
221        );
222
223        let mut items = Vec::<VItem>::from(SvgItem::new(svg_src));
224        let baseline_em_box = items[0].aabb();
225        let texts = items.split_off(1);
226
227        let (u, v) = self.basis();
228        let fill_rgbas = self.fill_rgbas;
229        let stroke_rgbas = self.stroke_rgbas;
230        let stroke_width = self.stroke_width;
231        let [min, max] = baseline_em_box;
232        let h = max.y - min.y;
233        let _ = self.inline_length_em.set((max.x - min.x) / h);
234        let mat = DMat3::from_cols(u, v, DVec3::ZERO);
235        texts.with(|x| {
236            x.shift(-min)
237                .scale(DVec3::splat(1. / h)) // Make height = 1.0
238                .apply_point_func(|p| *p = mat * *p)
239                .set_fill_color(fill_rgbas)
240                .set_stroke_color(stroke_rgbas)
241                .set_stroke_width(stroke_width)
242                .discard()
243        })
244    }
245
246    /// Borrow the canonical local outlines, generating and caching them on first use.
247    pub fn vitems(&self) -> &[VItem] {
248        self.items.get_or_init(|| self.generate_items())
249    }
250
251    fn transform_items(&mut self, transformation: impl FnOnce(&mut Vec<VItem>)) {
252        if let Some(v) = self.items.get_mut() {
253            transformation(v);
254        }
255    }
256}
257
258impl Aabb for TextItem {
259    fn aabb(&self) -> [DVec3; 2] {
260        self.vitems().aabb()
261    }
262}
263
264impl FillColor for TextItem {
265    fn fill_color(&self) -> AlphaColor<Srgb> {
266        self.fill_rgbas
267    }
268
269    fn set_fill_color(&mut self, color: AlphaColor<Srgb>) -> &mut Self {
270        self.fill_rgbas = color;
271        self.transform_items(|item| item.set_fill_color(color).discard());
272        self
273    }
274
275    fn set_fill_opacity(&mut self, opacity: f32) -> &mut Self {
276        self.fill_rgbas = self.fill_rgbas.with_alpha(opacity);
277        self.transform_items(|item| item.set_fill_opacity(opacity).discard());
278        self
279    }
280}
281
282impl StrokeColor for TextItem {
283    fn stroke_color(&self) -> AlphaColor<Srgb> {
284        self.stroke_rgbas
285    }
286
287    fn set_stroke_color(&mut self, color: AlphaColor<Srgb>) -> &mut Self {
288        self.stroke_rgbas = color;
289        self.transform_items(|item| item.set_stroke_color(color).discard());
290        self
291    }
292
293    fn set_stroke_opacity(&mut self, opacity: f32) -> &mut Self {
294        self.stroke_rgbas = self.stroke_rgbas.with_alpha(opacity);
295        self.transform_items(|item| item.set_stroke_opacity(opacity).discard());
296        self
297    }
298}
299
300impl From<TextItem> for Vec<VItem> {
301    fn from(mut item: TextItem) -> Self {
302        item.items.take().unwrap_or_else(|| item.generate_items())
303    }
304}
305
306#[cfg(feature = "render")]
307impl Extract for TextItem {
308    fn extract(&self, ctx: &mut ExtractContext<'_>) {
309        for item in self.vitems() {
310            item.extract(ctx);
311        }
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use assert_float_eq::assert_float_absolute_eq;
318
319    use super::*;
320
321    #[test]
322    fn test_text_item() {
323        let item = TextItem::new("Hello, world!", 0.25);
324        assert_float_absolute_eq!(item.basis().0.length(), 0.25, 1e-10);
325        assert_float_absolute_eq!(Origin.locate(&item).distance(DVec3::ZERO), 0.0, 1e-10);
326    }
327}