Skip to main content

rustmotion_components/
caption.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, FontStyle, Rect};
4
5use rustmotion_core::css::style::{FontStyle as CssFontStyle, WhiteSpace as CssWhiteSpace};
6use rustmotion_core::css::units::LengthContext;
7use rustmotion_core::css::CssStyle;
8use rustmotion_core::engine::animator::AnimatedProperties;
9use rustmotion_core::engine::layout_pass::BoxLayout;
10use rustmotion_core::engine::renderer::{
11    css_font_weight, draw_text_with_fallback, emoji_typeface, measure_text_with_fallback,
12    paint_from_hex, subpixel_font, typeface_with_fallback,
13};
14use rustmotion_core::schema::{CaptionStyle, CaptionWord, TimelineStep};
15use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
16
17#[derive(Debug, Serialize, Deserialize, JsonSchema)]
18#[deprecated(
19    since = "0.7.1",
20    note = "`caption` is a frozen composition. Compose a `for-each` over the words with \
21            `start_at`/`end_at` per word and an expression picking the active one instead \
22            — see crates/rustmotion/skills/rules/composition-recipes.md. Kept for \
23            compatibility; scheduled for removal in a future major version via \
24            `rustmotion migrate` (#335)."
25)]
26pub struct Caption {
27    pub words: Vec<CaptionWord>,
28    #[serde(default = "default_active_color")]
29    pub active_color: String,
30    #[serde(default)]
31    pub mode: CaptionStyle,
32    #[serde(default)]
33    pub max_width: Option<f32>,
34    /// Pill background color behind the active word (`word_pop` /
35    /// `karaoke_pop` modes). Defaults to black at 70% opacity.
36    #[serde(default)]
37    pub pill_color: Option<String>,
38    #[serde(default)]
39    pub style: CssStyle,
40    #[serde(flatten)]
41    pub timing: TimingConfig,
42    #[serde(default)]
43    pub timeline: Vec<TimelineStep>,
44    #[serde(default)]
45    pub stagger: Option<f32>,
46}
47
48rustmotion_core::impl_traits!(Caption {
49    Animatable => animation,
50    Timed => timing,
51    Styled => style,
52});
53
54impl Caption {
55    fn paint(&self, canvas: &Canvas, layout_width: f32, layout_height: f32, ctx: &PaintCtx) {
56        let time = ctx.time;
57        let base_ctx = crate::intrinsic::font_size_ctx(
58            ctx.video_width as f32,
59            ctx.video_height as f32,
60            layout_width.max(0.0),
61        );
62        let font_size = self.style.font_size_px_ctx(&base_ctx, 48.0);
63        let color = self.style.color_str_or("#FFFFFF");
64        let font_family = self.style.font_family_or("Inter");
65
66        let type_ctx = LengthContext {
67            font_size,
68            ..base_ctx
69        };
70
71        let font_style = Self::resolve_font_style(&self.style);
72
73        let Ok(typeface) = typeface_with_fallback(font_family, font_style) else {
74            return;
75        };
76
77        let font = subpixel_font(typeface, font_size);
78        let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size));
79
80        let top_offset = font_size * 1.2;
81        canvas.save();
82        canvas.translate((0.0, top_offset));
83        if layout_height > 0.0 {
84            const HALF_PLANE: f32 = 1_000_000.0;
85            canvas.clip_rect(
86                Rect::from_xywh(-HALF_PLANE, -top_offset, HALF_PLANE * 2.0, layout_height),
87                skia_safe::ClipOp::Intersect,
88                true,
89            );
90        }
91
92        match self.mode {
93            CaptionStyle::WordByWord => {
94                for word in &self.words {
95                    if time >= word.start && time < word.end {
96                        let paint = paint_from_hex(&self.active_color);
97                        let text_width =
98                            measure_text_with_fallback(&word.text, &font, &emoji_font, 0.0);
99
100                        let cx = layout_width / 2.0;
101
102                        if let Some(bg_color) = self.style.background_color_str() {
103                            let padding = font_size * 0.3;
104                            let bg_rect = Rect::from_xywh(
105                                cx - text_width / 2.0 - padding,
106                                -font_size - padding / 2.0,
107                                text_width + padding * 2.0,
108                                font_size * 1.4 + padding,
109                            );
110                            let bg_paint = paint_from_hex(bg_color);
111                            let rrect = skia_safe::RRect::new_rect_xy(bg_rect, padding, padding);
112                            canvas.draw_rrect(rrect, &bg_paint);
113                        }
114
115                        let x = cx - text_width / 2.0;
116                        draw_text_with_fallback(
117                            canvas,
118                            &word.text,
119                            &font,
120                            &emoji_font,
121                            0.0,
122                            x,
123                            0.0,
124                            &paint,
125                        );
126                        break;
127                    }
128                }
129            }
130            CaptionStyle::WordPop => {
131                for word in &self.words {
132                    if time >= word.start && time < word.end {
133                        let text_width =
134                            measure_text_with_fallback(&word.text, &font, &emoji_font, 0.0);
135                        let cx = layout_width / 2.0;
136
137                        let t = (((time - word.start) / POP_DURATION).clamp(0.0, 1.0)) as f32;
138                        let scale = ease_out_back(t).max(0.01);
139
140                        let cy = -font_size * 0.35;
141                        canvas.save();
142                        canvas.translate((cx, cy));
143                        canvas.scale((scale, scale));
144                        canvas.translate((-cx, -cy));
145
146                        let padding = font_size * 0.35;
147                        self.draw_pill(
148                            canvas,
149                            Rect::from_xywh(
150                                cx - text_width / 2.0 - padding,
151                                -font_size - padding / 2.0,
152                                text_width + padding * 2.0,
153                                font_size * 1.4 + padding,
154                            ),
155                        );
156
157                        let paint = paint_from_hex(&self.active_color);
158                        draw_text_with_fallback(
159                            canvas,
160                            &word.text,
161                            &font,
162                            &emoji_font,
163                            0.0,
164                            cx - text_width / 2.0,
165                            0.0,
166                            &paint,
167                        );
168                        canvas.restore();
169                        break;
170                    }
171                }
172            }
173            CaptionStyle::Highlight | CaptionStyle::Karaoke | CaptionStyle::KaraokePop => {
174                let nowrap = matches!(
175                    self.style.white_space,
176                    Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre)
177                );
178                let max_width = if nowrap {
179                    f32::MAX
180                } else if layout_width.is_finite() && layout_width > 0.0 {
181                    self.max_width
182                        .map_or(layout_width, |mw| mw.min(layout_width))
183                } else {
184                    self.max_width.unwrap_or(f32::MAX)
185                };
186                let space_width = measure_text_with_fallback(" ", &font, &emoji_font, 0.0);
187
188                let mut lines: Vec<Vec<(usize, f32)>> = vec![vec![]];
189                let mut current_x = 0.0f32;
190
191                for (i, word) in self.words.iter().enumerate() {
192                    let word_width =
193                        measure_text_with_fallback(&word.text, &font, &emoji_font, 0.0);
194                    if current_x + word_width > max_width && !lines.last().unwrap().is_empty() {
195                        lines.push(vec![]);
196                        current_x = 0.0;
197                    }
198                    lines.last_mut().unwrap().push((i, word_width));
199                    current_x += word_width + space_width;
200                }
201
202                let line_height = self.style.line_height_for_ctx(font_size, &type_ctx);
203                let cx = layout_width / 2.0;
204
205                if let Some(bg_color) = self.style.background_color_str() {
206                    let padding = font_size * 0.3;
207                    let total_height = lines.len() as f32 * line_height;
208                    let max_line_width = lines
209                        .iter()
210                        .map(|line| {
211                            line.iter().map(|(_, w)| w).sum::<f32>()
212                                + (line.len().saturating_sub(1)) as f32 * space_width
213                        })
214                        .fold(0.0f32, f32::max);
215                    let bg_rect = Rect::from_xywh(
216                        cx - max_line_width / 2.0 - padding,
217                        -font_size - padding / 2.0,
218                        max_line_width + padding * 2.0,
219                        total_height + padding,
220                    );
221                    let bg_paint = paint_from_hex(bg_color);
222                    let rrect = skia_safe::RRect::new_rect_xy(bg_rect, padding, padding);
223                    canvas.draw_rrect(rrect, &bg_paint);
224                }
225
226                for (line_idx, line) in lines.iter().enumerate() {
227                    let line_width: f32 = line.iter().map(|(_, w)| w).sum::<f32>()
228                        + (line.len().saturating_sub(1)) as f32 * space_width;
229                    let mut x = cx - line_width / 2.0;
230                    let y = line_idx as f32 * line_height;
231
232                    for (word_idx, word_width) in line {
233                        let word = &self.words[*word_idx];
234                        let is_active = time >= word.start && time < word.end;
235                        let pop = is_active && matches!(self.mode, CaptionStyle::KaraokePop);
236                        let word_color = if is_active { &self.active_color } else { color };
237                        let paint = paint_from_hex(word_color);
238
239                        if pop {
240                            let wcx = x + word_width / 2.0;
241                            let wcy = y - font_size * 0.35;
242                            canvas.save();
243                            canvas.translate((wcx, wcy));
244                            canvas.scale((KARAOKE_POP_SCALE, KARAOKE_POP_SCALE));
245                            canvas.translate((-wcx, -wcy));
246
247                            let padding = font_size * 0.18;
248                            self.draw_pill(
249                                canvas,
250                                Rect::from_xywh(
251                                    x - padding,
252                                    y - font_size - padding / 2.0,
253                                    word_width + padding * 2.0,
254                                    font_size * 1.4 + padding,
255                                ),
256                            );
257                        }
258
259                        draw_text_with_fallback(
260                            canvas,
261                            &word.text,
262                            &font,
263                            &emoji_font,
264                            0.0,
265                            x,
266                            y,
267                            &paint,
268                        );
269                        if pop {
270                            canvas.restore();
271                        }
272                        x += word_width + space_width;
273                    }
274                }
275            }
276        }
277        canvas.restore();
278    }
279}
280
281impl Caption {
282    fn draw_pill(&self, canvas: &Canvas, rect: Rect) {
283        let radius = rect.height() / 2.0;
284        let paint = paint_from_hex(self.pill_color.as_deref().unwrap_or(DEFAULT_PILL_COLOR));
285        canvas.draw_rrect(skia_safe::RRect::new_rect_xy(rect, radius, radius), &paint);
286    }
287
288    fn resolve_font_style(style: &CssStyle) -> FontStyle {
289        let weight = css_font_weight(style.font_weight.as_ref());
290        let slant = match style.font_style {
291            Some(CssFontStyle::Italic) => skia_safe::font_style::Slant::Italic,
292            Some(CssFontStyle::Oblique) => skia_safe::font_style::Slant::Oblique,
293            _ => skia_safe::font_style::Slant::Upright,
294        };
295        FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant)
296    }
297}
298
299impl Painter for Caption {
300    fn paint_content(
301        &self,
302        canvas: &Canvas,
303        layout: &BoxLayout,
304        _props: &AnimatedProperties,
305        ctx: &PaintCtx,
306    ) {
307        self.paint(canvas, layout.width, layout.height, ctx);
308    }
309}
310
311fn default_active_color() -> String {
312    "#FFFF00".to_string()
313}
314
315const DEFAULT_PILL_COLOR: &str = "#000000B3";
316
317const POP_DURATION: f64 = 0.18;
318
319const KARAOKE_POP_SCALE: f32 = 1.15;
320
321fn ease_out_back(t: f32) -> f32 {
322    const C1: f32 = 1.70158;
323    const C3: f32 = C1 + 1.0;
324    let p = t - 1.0;
325    1.0 + C3 * p * p * p + C1 * p * p
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use rustmotion_core::css::style::{CssStyle, FontWeight as CssFontWeight, FontWeightKw};
332    use rustmotion_core::css::Length;
333    use rustmotion_core::schema::CaptionWord;
334
335    fn test_ctx(time: f64) -> PaintCtx {
336        PaintCtx {
337            time,
338            scenario_time: time,
339            scene_duration: 2.0,
340            frame_index: (time * 30.0) as u32,
341            fps: 30,
342            video_width: 1920,
343            video_height: 1080,
344            stagger_offset: 0.0,
345        }
346    }
347
348    fn make_caption(text: &str, white_space: Option<CssWhiteSpace>) -> Caption {
349        make_caption_with_max_width(text, white_space, Some(80.0))
350    }
351
352    fn make_caption_with_max_width(
353        text: &str,
354        white_space: Option<CssWhiteSpace>,
355        max_width: Option<f32>,
356    ) -> Caption {
357        let words = text
358            .split_whitespace()
359            .map(|w| CaptionWord {
360                text: w.to_string(),
361                start: 0.0,
362                end: 1000.0,
363            })
364            .collect();
365        Caption {
366            words,
367            active_color: default_active_color(),
368            mode: CaptionStyle::Highlight,
369            max_width,
370            pill_color: None,
371            style: CssStyle {
372                font_size: Some(Length::Px(28.0)),
373                white_space,
374                ..Default::default()
375            },
376            timing: Default::default(),
377            timeline: Vec::new(),
378            stagger: None,
379        }
380    }
381
382    fn ink_bounds(
383        surface: &mut skia_safe::Surface,
384        w: i32,
385        h: i32,
386    ) -> Option<(i32, i32, i32, i32)> {
387        let snapshot = surface.image_snapshot();
388        let info = skia_safe::ImageInfo::new(
389            (w, h),
390            skia_safe::ColorType::RGBA8888,
391            skia_safe::AlphaType::Premul,
392            None,
393        );
394        let mut buf = vec![0u8; (w * h * 4) as usize];
395        let ok = snapshot.read_pixels(
396            &info,
397            &mut buf,
398            (w * 4) as usize,
399            skia_safe::IPoint::new(0, 0),
400            skia_safe::image::CachingHint::Disallow,
401        );
402        assert!(ok, "pixel read should succeed");
403        let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
404        for y in 0..h {
405            for x in 0..w {
406                if buf[((y * w + x) * 4 + 3) as usize] > 0 {
407                    minx = minx.min(x);
408                    maxx = maxx.max(x);
409                    miny = miny.min(y);
410                    maxy = maxy.max(y);
411                }
412            }
413        }
414        (minx <= maxx).then_some((minx, maxx, miny, maxy))
415    }
416
417    #[test]
418    fn ink_never_starts_above_the_box_top() {
419        let caption = make_caption("Hello world", None);
420        const W: i32 = 400;
421        const H: i32 = 200;
422        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
423        {
424            let canvas = surface.canvas();
425            caption.paint(canvas, W as f32, H as f32, &test_ctx(0.5));
426        }
427        let (_minx, _maxx, miny, _maxy) =
428            ink_bounds(&mut surface, W, H).expect("caption must paint something");
429        assert!(miny >= 0, "ink starts above the box top at y={miny}");
430    }
431
432    #[test]
433    fn word_pop_pill_never_starts_above_the_box_top() {
434        let mut caption = make_caption("Hello", None);
435        caption.mode = CaptionStyle::WordPop;
436        caption.words[0].start = 0.0;
437        caption.words[0].end = 10.0;
438        const W: i32 = 400;
439        const H: i32 = 200;
440        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
441        {
442            let canvas = surface.canvas();
443            caption.paint(canvas, W as f32, H as f32, &test_ctx(0.1));
444        }
445        let (_minx, _maxx, miny, _maxy) =
446            ink_bounds(&mut surface, W, H).expect("word_pop caption must paint something");
447        assert!(miny >= 0, "pill starts above the box top at y={miny}");
448    }
449
450    #[test]
451    fn nowrap_paints_one_wide_line_instead_of_wrapping_at_max_width() {
452        let caption = make_caption(
453            "the quick brown fox jumps over the lazy dog",
454            Some(CssWhiteSpace::Nowrap),
455        );
456        const W: i32 = 1600;
457        const H: i32 = 400;
458        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
459        {
460            let canvas = surface.canvas();
461            canvas.translate((800.0, 250.0));
462            caption.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
463        }
464        let (minx, maxx, miny, maxy) =
465            ink_bounds(&mut surface, W, H).expect("nowrap caption must paint something");
466
467        assert!(
468            maxx - minx > 240,
469            "nowrap caption must bleed far past its 80px max_width, got ink width {}",
470            maxx - minx
471        );
472        assert!(
473            maxy - miny < 50,
474            "nowrap caption must stay on one line, got ink height {}",
475            maxy - miny
476        );
477    }
478
479    #[test]
480    fn normal_white_space_wraps_at_max_width() {
481        let caption = make_caption("the quick brown fox jumps over the lazy dog", None);
482        const W: i32 = 1600;
483        const H: i32 = 400;
484        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
485        {
486            let canvas = surface.canvas();
487            canvas.translate((800.0, 250.0));
488            caption.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
489        }
490        let (minx, maxx, miny, maxy) =
491            ink_bounds(&mut surface, W, H).expect("wrapped caption must paint something");
492
493        assert!(
494            maxx - minx < 200,
495            "wrapped caption must pack close to its 80px max_width, got ink width {}",
496            maxx - minx
497        );
498        assert!(
499            maxy - miny > 50,
500            "wrapped caption must spread across multiple lines, got ink height {}",
501            maxy - miny
502        );
503    }
504
505    #[test]
506    fn wraps_at_layout_width_when_max_width_is_unset() {
507        let caption = make_caption_with_max_width(
508            "the quick brown fox jumps over the lazy dog again",
509            None,
510            None,
511        );
512        const W: i32 = 1600;
513        const H: i32 = 400;
514        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
515        {
516            let canvas = surface.canvas();
517            canvas.translate((800.0, 200.0));
518            caption.paint(canvas, 300.0, H as f32, &test_ctx(0.5));
519        }
520        let (minx, maxx, miny, maxy) =
521            ink_bounds(&mut surface, W, H).expect("caption must paint something");
522
523        assert!(
524            maxx - minx < 320,
525            "must wrap within ~layout_width (300px), got ink width {}",
526            maxx - minx
527        );
528        assert!(
529            maxy - miny > 50,
530            "must spread across multiple lines when max_width is unset, got ink height {}",
531            maxy - miny
532        );
533    }
534
535    #[test]
536    fn nowrap_still_ignores_layout_width_when_max_width_is_unset() {
537        let caption = make_caption_with_max_width(
538            "the quick brown fox jumps over the lazy dog",
539            Some(CssWhiteSpace::Nowrap),
540            None,
541        );
542        const W: i32 = 1600;
543        const H: i32 = 400;
544        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
545        {
546            let canvas = surface.canvas();
547            canvas.translate((800.0, 200.0));
548            caption.paint(canvas, 300.0, H as f32, &test_ctx(0.5));
549        }
550        let (minx, maxx, miny, maxy) =
551            ink_bounds(&mut surface, W, H).expect("caption must paint something");
552
553        assert!(
554            maxx - minx > 400,
555            "nowrap must still bleed past layout_width, got ink width {}",
556            maxx - minx
557        );
558        assert!(
559            maxy - miny < 50,
560            "nowrap must stay on one line, got ink height {}",
561            maxy - miny
562        );
563    }
564
565    #[test]
566    fn honours_style_line_height_instead_of_hardcoded_1_4() {
567        let mut tight = make_caption_with_max_width(
568            "one two three four five six seven eight",
569            None,
570            Some(80.0),
571        );
572        tight.style.line_height = Some(rustmotion_core::css::style::LineHeight::Number(0.9));
573        let mut loose = make_caption_with_max_width(
574            "one two three four five six seven eight",
575            None,
576            Some(80.0),
577        );
578        loose.style.line_height = Some(rustmotion_core::css::style::LineHeight::Number(2.0));
579
580        const W: i32 = 1600;
581        const H: i32 = 800;
582
583        let mut surf_tight =
584            skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
585        {
586            let canvas = surf_tight.canvas();
587            canvas.translate((800.0, 50.0));
588            tight.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
589        }
590        let (_, _, _, tight_maxy) =
591            ink_bounds(&mut surf_tight, W, H).expect("tight caption must paint something");
592
593        let mut surf_loose =
594            skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
595        {
596            let canvas = surf_loose.canvas();
597            canvas.translate((800.0, 50.0));
598            loose.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
599        }
600        let (_, _, _, loose_maxy) =
601            ink_bounds(&mut surf_loose, W, H).expect("loose caption must paint something");
602
603        assert!(
604            loose_maxy > tight_maxy + 50,
605            "line-height: 2.0 must spread lines much further than 0.9 \
606             (tight bottom={tight_maxy}, loose bottom={loose_maxy})"
607        );
608    }
609
610    #[test]
611    fn rem_font_size_paints_visible_ink() {
612        let mut caption = make_caption_with_max_width("hello world", None, Some(300.0));
613        caption.style.font_size = Some(Length::String("2rem".into()));
614        const W: i32 = 400;
615        const H: i32 = 200;
616        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
617        {
618            let canvas = surface.canvas();
619            caption.paint(canvas, 300.0, H as f32, &test_ctx(0.5));
620        }
621        let bounds = ink_bounds(&mut surface, W, H);
622        assert!(
623            bounds.is_some(),
624            "caption at font-size: 2rem must paint visible ink"
625        );
626    }
627
628    #[test]
629    fn resolve_font_style_defaults_to_normal_matching_the_intrinsic_measurement() {
630        let style = CssStyle::default();
631        let resolved = Caption::resolve_font_style(&style);
632        assert_eq!(
633            *resolved.weight(),
634            400,
635            "unset font-weight must resolve to normal (400), not a hardcoded bold"
636        );
637    }
638
639    #[test]
640    fn resolve_font_style_honours_explicit_bold_and_numeric_weight() {
641        let bold = CssStyle {
642            font_weight: Some(CssFontWeight::Keyword(FontWeightKw::Bold)),
643            ..Default::default()
644        };
645        assert_eq!(*Caption::resolve_font_style(&bold).weight(), 700);
646
647        let numeric = CssStyle {
648            font_weight: Some(CssFontWeight::Number(350)),
649            ..Default::default()
650        };
651        assert_eq!(*Caption::resolve_font_style(&numeric).weight(), 350);
652    }
653
654    #[test]
655    fn resolve_font_style_does_not_collapse_the_heavy_weights_to_bold() {
656        for declared in [600u16, 700, 800, 900] {
657            let style = CssStyle {
658                font_weight: Some(CssFontWeight::Number(declared)),
659                ..Default::default()
660            };
661            assert_eq!(
662                *Caption::resolve_font_style(&style).weight(),
663                i32::from(declared),
664                "a numeric font-weight must be passed through, not rounded to 700"
665            );
666        }
667    }
668
669    #[test]
670    fn resolve_font_style_reads_bolder_and_lighter_as_the_measurer_does() {
671        let bolder = CssStyle {
672            font_weight: Some(CssFontWeight::Keyword(FontWeightKw::Bolder)),
673            ..Default::default()
674        };
675        assert_eq!(*Caption::resolve_font_style(&bolder).weight(), 800);
676
677        let lighter = CssStyle {
678            font_weight: Some(CssFontWeight::Keyword(FontWeightKw::Lighter)),
679            ..Default::default()
680        };
681        assert_eq!(*Caption::resolve_font_style(&lighter).weight(), 300);
682    }
683
684    #[test]
685    fn resolve_font_style_honours_italic() {
686        let italic = CssStyle {
687            font_style: Some(CssFontStyle::Italic),
688            ..Default::default()
689        };
690        assert_eq!(
691            Caption::resolve_font_style(&italic).slant(),
692            skia_safe::font_style::Slant::Italic
693        );
694    }
695}