1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::gradient::{self, Colors, Gradient};
4use skia_safe::{Canvas, Color4f, FontStyle, Point};
5
6use rustmotion_core::css::style::{
7 FontStyle as CssFontStyle, TextAlign as CssTextAlign, WhiteSpace as CssWhiteSpace,
8};
9use rustmotion_core::css::CssStyle;
10use rustmotion_core::engine::animator::AnimatedProperties;
11use rustmotion_core::engine::layout_pass::BoxLayout;
12use rustmotion_core::engine::renderer::{
13 css_font_weight, draw_text_with_fallback, emoji_typeface, measure_text_with_fallback,
14 paint_from_hex, parse_hex_color, subpixel_font, typeface_with_fallback,
15 wrap_text_with_tracking,
16};
17use rustmotion_core::schema::{TextAlign, TimelineStep};
18use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
19
20fn default_colors() -> Vec<String> {
21 vec!["#3B82F6".to_string(), "#8B5CF6".to_string()]
22}
23
24fn default_angle() -> f32 {
25 90.0
26}
27
28fn default_speed() -> f32 {
29 0.5
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
34pub struct GradientTextStop {
35 pub color: String,
37 #[serde(alias = "offset")]
41 pub position: f32,
42}
43
44#[derive(Debug, Serialize, Deserialize, JsonSchema)]
45pub struct GradientText {
46 pub content: String,
47 #[serde(default = "default_colors")]
48 pub colors: Vec<String>,
49 #[serde(default)]
51 pub stops: Option<Vec<GradientTextStop>>,
52 #[serde(default = "default_angle")]
53 pub angle: f32,
54 #[serde(default)]
55 pub animate_angle: bool,
56 #[serde(default = "default_speed")]
57 pub speed: f32,
58 #[serde(flatten)]
59 pub timing: TimingConfig,
60 #[serde(default)]
61 pub style: CssStyle,
62 #[serde(default)]
63 pub timeline: Vec<TimelineStep>,
64 #[serde(default)]
65 pub stagger: Option<f32>,
66}
67
68rustmotion_core::impl_traits!(GradientText {
69 Animatable => animation,
70 Timed => timing,
71 Styled => style,
72});
73
74impl GradientText {
75 fn resolve_typeface(&self) -> Option<skia_safe::Typeface> {
76 let font_family = self.style.font_family_or("Inter");
77
78 let slant = match self.style.font_style {
79 Some(CssFontStyle::Italic) => skia_safe::font_style::Slant::Italic,
80 Some(CssFontStyle::Oblique) => skia_safe::font_style::Slant::Oblique,
81 _ => skia_safe::font_style::Slant::Upright,
82 };
83 let weight = css_font_weight(self.style.font_weight.as_ref());
84 let skia_style = FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant);
85
86 typeface_with_fallback(font_family, skia_style).ok()
87 }
88}
89
90impl GradientText {
91 fn paint(
92 &self,
93 canvas: &Canvas,
94 layout_width: f32,
95 content_height: Option<f32>,
96 time: f64,
97 props: &AnimatedProperties,
98 ctx: &PaintCtx,
99 ) {
100 let has_stops = self.stops.as_ref().is_some_and(|s| !s.is_empty());
101 if self.content.is_empty() || (self.colors.is_empty() && !has_stops) {
102 return;
103 }
104
105 let base_ctx = crate::intrinsic::font_size_ctx(
106 ctx.video_width as f32,
107 ctx.video_height as f32,
108 layout_width.max(0.0),
109 );
110 let mut font_size = self.style.font_size_px_ctx(&base_ctx, 48.0);
111 let Some(typeface) = self.resolve_typeface() else {
112 return;
113 };
114 let type_ctx = rustmotion_core::css::units::LengthContext {
115 font_size,
116 ..base_ctx
117 };
118 let mut line_height_val = self.style.line_height_for_ctx(font_size, &type_ctx);
119 let mut letter_spacing = self.style.letter_spacing_px_ctx(&type_ctx);
120
121 let nowrap = matches!(
122 self.style.white_space,
123 Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre)
124 );
125 let box_width = (layout_width.is_finite() && layout_width > 0.0).then_some(layout_width);
126 let wrap_at = if nowrap { None } else { box_width };
127
128 if matches!(self.style.text_autofit, Some(true)) {
129 let declared_height = content_height.filter(|h| *h > 0.0 && h.is_finite());
130 let (fs, ls, lh) = crate::intrinsic::resolve_text_autofit(
131 &self.content,
132 &typeface,
133 font_size,
134 letter_spacing,
135 line_height_val,
136 !nowrap,
137 box_width,
138 declared_height,
139 );
140 font_size = fs;
141 letter_spacing = ls;
142 line_height_val = lh;
143 }
144
145 let font = subpixel_font(typeface, font_size);
146 let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size));
147
148 let lines =
149 wrap_text_with_tracking(&self.content, &font, &emoji_font, wrap_at, letter_spacing);
150
151 let (_, metrics) = font.metrics();
152 let ascent = -metrics.ascent;
153 let descent = metrics.descent;
154 let text_w = lines
155 .iter()
156 .map(|l| measure_text_with_fallback(l, &font, &emoji_font, letter_spacing))
157 .fold(0.0f32, f32::max);
158 let text_h = (lines.len().max(1) - 1) as f32 * line_height_val + ascent + descent;
159
160 let align_width = if layout_width.is_finite() && layout_width > 0.0 {
161 layout_width
162 } else {
163 text_w
164 };
165 let align = self.style.text_align.unwrap_or(CssTextAlign::Left);
166 let line_x = |advance: f32| match align {
167 CssTextAlign::Center => (align_width - advance) / 2.0,
168 CssTextAlign::Right | CssTextAlign::End => align_width - advance,
169 _ => 0.0,
170 };
171 let block_x = line_x(text_w);
172
173 let angle = if self.animate_angle {
174 self.angle + time as f32 * self.speed * 360.0
175 } else {
176 self.angle
177 };
178
179 let angle_rad = angle * std::f32::consts::PI / 180.0;
180 let (dir_x, dir_y) = (angle_rad.sin(), -angle_rad.cos());
181 let cx = block_x + text_w / 2.0;
182 let cy = text_h / 2.0;
183 let half_len = ((text_w * angle_rad.sin()).abs() + (text_h * angle_rad.cos()).abs()) / 2.0;
184 let start = Point::new(cx - dir_x * half_len, cy - dir_y * half_len);
185 let end = Point::new(cx + dir_x * half_len, cy + dir_y * half_len);
186
187 let (skia_colors, positions_vec): (Vec<skia_safe::Color>, Option<Vec<f32>>) =
188 if let Some(explicit_stops) = self.stops.as_ref().filter(|s| !s.is_empty()) {
189 let colors = explicit_stops
190 .iter()
191 .map(|stop| {
192 let (r, g, b, a) = parse_hex_color(&stop.color);
193 skia_safe::Color::from_argb(a, r, g, b)
194 })
195 .collect();
196 let positions = explicit_stops.iter().map(|stop| stop.position).collect();
197 (colors, Some(positions))
198 } else {
199 let colors = self
200 .colors
201 .iter()
202 .map(|hex| {
203 let (r, g, b, a) = parse_hex_color(hex);
204 skia_safe::Color::from_argb(a, r, g, b)
205 })
206 .collect();
207 (colors, None)
208 };
209
210 let colors4f: Vec<Color4f> = skia_colors.iter().map(|c| Color4f::from(*c)).collect();
211 let gradient_stops = Colors::new(
212 &colors4f,
213 positions_vec.as_deref(),
214 skia_safe::TileMode::Clamp,
215 None,
216 );
217 let grad = Gradient::new(gradient_stops, gradient::Interpolation::default());
218 let shader = gradient::shaders::linear_gradient((start, end), &grad, None);
219
220 let fill_paint = match shader {
221 Some(shader) => {
222 let mut p = skia_safe::Paint::default();
223 p.set_anti_alias(true);
224 p.set_shader(shader);
225 p
226 }
227 None => {
228 let fallback_hex = self
229 .stops
230 .as_ref()
231 .and_then(|s| s.first())
232 .map(|stop| stop.color.as_str())
233 .or_else(|| self.colors.first().map(String::as_str))
234 .unwrap_or("#FFFFFF");
235 let mut p = paint_from_hex(fallback_hex);
236 p.set_anti_alias(true);
237 p
238 }
239 };
240
241 if let Some(ref resolved) = props.char_animation {
242 let schema_align = match align {
243 CssTextAlign::Center => TextAlign::Center,
244 CssTextAlign::Right | CssTextAlign::End => TextAlign::Right,
245 _ => TextAlign::Left,
246 };
247 crate::intrinsic::render_char_animation(
248 canvas,
249 &font,
250 &emoji_font,
251 &fill_paint,
252 letter_spacing,
253 schema_align,
254 align_width,
255 line_height_val,
256 ascent,
257 &lines,
258 resolved,
259 time,
260 );
261 return;
262 }
263
264 for (i, line) in lines.iter().enumerate() {
265 if line.is_empty() {
266 continue;
267 }
268 let y = i as f32 * line_height_val + ascent;
269 let x = line_x(measure_text_with_fallback(
270 line,
271 &font,
272 &emoji_font,
273 letter_spacing,
274 ));
275 draw_text_with_fallback(
276 canvas,
277 line,
278 &font,
279 &emoji_font,
280 letter_spacing,
281 x,
282 y,
283 &fill_paint,
284 );
285 }
286 }
287}
288
289impl Painter for GradientText {
290 fn paint_content(
291 &self,
292 canvas: &Canvas,
293 layout: &BoxLayout,
294 props: &AnimatedProperties,
295 ctx: &PaintCtx,
296 ) {
297 let (_, _, _, content_height) = layout.content_box();
298 let content_height =
299 (content_height > 0.0 && content_height.is_finite()).then_some(content_height);
300 self.paint(canvas, layout.width, content_height, ctx.time, props, ctx);
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use rustmotion_core::css::style::CssStyle;
308 use rustmotion_core::css::Length;
309 use rustmotion_core::schema::{
310 AnimationEffect, CharAnimationTiming, EasingType, TextAnimGranularity,
311 };
312
313 fn make_gradient_text(content: &str, white_space: Option<CssWhiteSpace>) -> GradientText {
314 GradientText {
315 content: content.into(),
316 colors: default_colors(),
317 stops: None,
318 angle: default_angle(),
319 animate_angle: false,
320 speed: default_speed(),
321 timing: Default::default(),
322 style: CssStyle {
323 font_size: Some(Length::Px(28.0)),
324 white_space,
325 ..Default::default()
326 },
327 timeline: Vec::new(),
328 stagger: None,
329 }
330 }
331
332 fn alpha_grid(surface: &mut skia_safe::Surface, width: i32, height: i32) -> Vec<u8> {
333 let snapshot = surface.image_snapshot();
334 let info = skia_safe::ImageInfo::new(
335 (width, height),
336 skia_safe::ColorType::RGBA8888,
337 skia_safe::AlphaType::Premul,
338 None,
339 );
340 let mut buf = vec![0u8; (width * height * 4) as usize];
341 let ok = snapshot.read_pixels(
342 &info,
343 &mut buf,
344 (width * 4) as usize,
345 skia_safe::IPoint::new(0, 0),
346 skia_safe::image::CachingHint::Disallow,
347 );
348 assert!(ok, "pixel read should succeed");
349 (0..(width * height) as usize)
350 .map(|i| buf[i * 4 + 3])
351 .collect()
352 }
353
354 fn test_ctx() -> PaintCtx {
355 PaintCtx {
356 time: 0.0,
357 scenario_time: 0.0,
358 scene_duration: 1.0,
359 frame_index: 0,
360 fps: 30,
361 video_width: 1920,
362 video_height: 1080,
363 stagger_offset: 0.0,
364 }
365 }
366
367 fn has_ink_in(grid: &[u8], surface_width: i32, x0: i32, x1: i32, y0: i32, y1: i32) -> bool {
368 for y in y0..y1 {
369 for x in x0..x1 {
370 if grid[(y * surface_width + x) as usize] > 0 {
371 return true;
372 }
373 }
374 }
375 false
376 }
377
378 fn ink_x_span(grid: &[u8], surface_width: i32, height: i32) -> Option<(i32, i32)> {
379 let mut span: Option<(i32, i32)> = None;
380 for y in 0..height {
381 for x in 0..surface_width {
382 if grid[(y * surface_width + x) as usize] > 0 {
383 span = Some(match span {
384 None => (x, x),
385 Some((lo, hi)) => (lo.min(x), hi.max(x)),
386 });
387 }
388 }
389 }
390 span
391 }
392
393 fn ink_y_span(grid: &[u8], surface_width: i32, height: i32) -> Option<(i32, i32)> {
394 let mut span: Option<(i32, i32)> = None;
395 for y in 0..height {
396 for x in 0..surface_width {
397 if grid[(y * surface_width + x) as usize] > 0 {
398 span = Some(match span {
399 None => (y, y),
400 Some((lo, hi)) => (lo.min(y), hi.max(y)),
401 });
402 }
403 }
404 }
405 span
406 }
407
408 fn render_unpremul(gt: &GradientText, w: i32, h: i32) -> (Vec<u8>, Vec<u8>) {
409 render_unpremul_at(gt, w, h, 0.0, &AnimatedProperties::default())
410 }
411
412 fn render_unpremul_at(
413 gt: &GradientText,
414 w: i32,
415 h: i32,
416 time: f64,
417 props: &AnimatedProperties,
418 ) -> (Vec<u8>, Vec<u8>) {
419 let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface");
420 gt.paint(surface.canvas(), w as f32, None, time, props, &test_ctx());
421 let snapshot = surface.image_snapshot();
422 let info = skia_safe::ImageInfo::new(
423 (w, h),
424 skia_safe::ColorType::RGBA8888,
425 skia_safe::AlphaType::Unpremul,
426 None,
427 );
428 let mut buf = vec![0u8; (w * h * 4) as usize];
429 assert!(snapshot.read_pixels(
430 &info,
431 &mut buf,
432 (w * 4) as usize,
433 skia_safe::IPoint::new(0, 0),
434 skia_safe::image::CachingHint::Disallow,
435 ));
436 let alpha: Vec<u8> = (0..(w * h) as usize).map(|i| buf[i * 4 + 3]).collect();
437 (buf, alpha)
438 }
439
440 fn best_pixel_in_column(buf: &[u8], alpha: &[u8], w: i32, h: i32, x: i32) -> (u8, u8, u8) {
441 let (mut best_y, mut best_a) = (0i32, 0u8);
442 for y in 0..h {
443 let a = alpha[(y * w + x) as usize];
444 if a > best_a {
445 best_a = a;
446 best_y = y;
447 }
448 }
449 let i = ((best_y * w + x) * 4) as usize;
450 (buf[i], buf[i + 1], buf[i + 2])
451 }
452
453 fn best_pixel_in_row(
454 buf: &[u8],
455 alpha: &[u8],
456 w: i32,
457 y: i32,
458 x0: i32,
459 x1: i32,
460 ) -> (u8, u8, u8) {
461 let (mut best_x, mut best_a) = (x0, 0u8);
462 for x in x0..x1 {
463 let a = alpha[(y * w + x) as usize];
464 if a > best_a {
465 best_a = a;
466 best_x = x;
467 }
468 }
469 let i = ((y * w + best_x) * 4) as usize;
470 (buf[i], buf[i + 1], buf[i + 2])
471 }
472
473 fn nearest_inked_column(alpha: &[u8], w: i32, h: i32, target: i32) -> i32 {
474 for radius in 0..w {
475 for cand in [target - radius, target + radius] {
476 if cand < 0 || cand >= w {
477 continue;
478 }
479 if (0..h).any(|y| alpha[(y * w + cand) as usize] > 0) {
480 return cand;
481 }
482 }
483 }
484 target
485 }
486
487 #[test]
488 fn text_align_center_centres_the_line_in_the_box() {
489 const W: i32 = 700;
490 const H: i32 = 80;
491 const BOX_W: f32 = 600.0;
492
493 let mut gt = make_gradient_text("GRADIENT", Some(CssWhiteSpace::Nowrap));
494 gt.style.text_align = Some(CssTextAlign::Center);
495
496 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
497 gt.paint(
498 surface.canvas(),
499 BOX_W,
500 None,
501 0.0,
502 &AnimatedProperties::default(),
503 &test_ctx(),
504 );
505 let grid = alpha_grid(&mut surface, W, H);
506 let (lo, hi) = ink_x_span(&grid, W, H).expect("centred gradient_text must paint something");
507
508 let ink_centre = (lo + hi) as f32 / 2.0;
509 let box_centre = BOX_W / 2.0;
510 assert!(
511 (ink_centre - box_centre).abs() <= 4.0,
512 "centred gradient_text should sit on the box centre {box_centre}, \
513 painted [{lo}, {hi}] with centre {ink_centre}"
514 );
515 assert!(
516 lo > 40,
517 "centred gradient_text must leave a left margin, first ink at {lo}"
518 );
519 }
520
521 #[test]
522 fn text_align_right_ends_the_line_on_the_box_edge() {
523 const W: i32 = 700;
524 const H: i32 = 80;
525 const BOX_W: f32 = 600.0;
526
527 let mut gt = make_gradient_text("GRADIENT", Some(CssWhiteSpace::Nowrap));
528 gt.style.text_align = Some(CssTextAlign::Right);
529
530 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
531 gt.paint(
532 surface.canvas(),
533 BOX_W,
534 None,
535 0.0,
536 &AnimatedProperties::default(),
537 &test_ctx(),
538 );
539 let grid = alpha_grid(&mut surface, W, H);
540 let (lo, hi) = ink_x_span(&grid, W, H).expect("right-aligned gradient_text must paint");
541
542 assert!(
543 (hi as f32 - BOX_W).abs() <= 6.0,
544 "right-aligned gradient_text should end on the box edge {BOX_W}, \
545 painted [{lo}, {hi}]"
546 );
547 }
548
549 #[test]
550 fn no_text_align_still_starts_at_the_box_left_edge() {
551 const W: i32 = 700;
552 const H: i32 = 80;
553
554 let gt = make_gradient_text("GRADIENT", Some(CssWhiteSpace::Nowrap));
555 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
556 gt.paint(
557 surface.canvas(),
558 600.0,
559 None,
560 0.0,
561 &AnimatedProperties::default(),
562 &test_ctx(),
563 );
564 let grid = alpha_grid(&mut surface, W, H);
565 let (lo, _) = ink_x_span(&grid, W, H).expect("gradient_text must paint");
566
567 assert!(
568 lo < 8,
569 "with no text-align the first ink should hug the left edge, got {lo}"
570 );
571 }
572
573 #[test]
574 fn centring_keeps_the_gradient_over_the_glyphs() {
575 const W: i32 = 700;
576 const H: i32 = 80;
577 const BOX_W: f32 = 600.0;
578
579 fn last_glyph_rgb(align: Option<CssTextAlign>) -> (u8, u8, u8) {
580 let mut gt = make_gradient_text("GRADIENT", Some(CssWhiteSpace::Nowrap));
581 gt.style.text_align = align;
582 let mut surface =
583 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
584 gt.paint(
585 surface.canvas(),
586 BOX_W,
587 None,
588 0.0,
589 &AnimatedProperties::default(),
590 &test_ctx(),
591 );
592
593 let snapshot = surface.image_snapshot();
594 let info = skia_safe::ImageInfo::new(
595 (W, H),
596 skia_safe::ColorType::RGBA8888,
597 skia_safe::AlphaType::Premul,
598 None,
599 );
600 let mut buf = vec![0u8; (W * H * 4) as usize];
601 assert!(snapshot.read_pixels(
602 &info,
603 &mut buf,
604 (W * 4) as usize,
605 skia_safe::IPoint::new(0, 0),
606 skia_safe::image::CachingHint::Disallow,
607 ));
608 let alpha: Vec<u8> = (0..(W * H) as usize).map(|i| buf[i * 4 + 3]).collect();
609 let (_, hi) = ink_x_span(&alpha, W, H).expect("must paint");
610 let column_peak = |x: i32| {
611 (0..H)
612 .map(|y| (alpha[(y * W + x) as usize], y))
613 .max()
614 .expect("H > 0")
615 };
616 let (peak_a, peak_y, peak_x) = (0..=hi)
617 .rev()
618 .find_map(|x| {
619 let (a, y) = column_peak(x);
620 (a >= 200).then_some((a, y, x))
621 })
622 .expect("the last glyph must have a covered column to read a colour from");
623 let i = ((peak_y * W + peak_x) * 4) as usize;
624 let straight = |c: u8| ((c as u32 * 255 + peak_a as u32 / 2) / peak_a as u32) as u8;
625 (straight(buf[i]), straight(buf[i + 1]), straight(buf[i + 2]))
626 }
627
628 let left = last_glyph_rgb(None);
629 let centre = last_glyph_rgb(Some(CssTextAlign::Center));
630 let d = |a: u8, b: u8| (a as i32 - b as i32).abs();
631 assert!(
632 d(left.0, centre.0) <= 12 && d(left.1, centre.1) <= 12 && d(left.2, centre.2) <= 12,
633 "the gradient must follow the aligned block: last glyph is {left:?} \
634 left-aligned but {centre:?} centred"
635 );
636 }
637
638 #[test]
639 fn nowrap_paints_a_single_line_past_the_layout_width() {
640 let gt = make_gradient_text(
641 "the quick brown fox jumps over the lazy dog",
642 Some(CssWhiteSpace::Nowrap),
643 );
644 const W: i32 = 600;
645 const H: i32 = 200;
646 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
647 let canvas = surface.canvas();
648 gt.paint(
649 canvas,
650 80.0,
651 None,
652 0.0,
653 &AnimatedProperties::default(),
654 &test_ctx(),
655 );
656 let grid = alpha_grid(&mut surface, W, H);
657
658 assert!(
659 has_ink_in(&grid, W, 300, W, 0, 45),
660 "nowrap gradient_text must paint past its 80px box on line 1"
661 );
662 assert!(
663 !has_ink_in(&grid, W, 0, W, 55, H),
664 "nowrap gradient_text must stay on a single line"
665 );
666 }
667
668 #[test]
669 fn normal_white_space_wraps_within_the_layout_width() {
670 let gt = make_gradient_text("the quick brown fox jumps over the lazy dog", None);
671 const W: i32 = 600;
672 const H: i32 = 200;
673 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
674 let canvas = surface.canvas();
675 gt.paint(
676 canvas,
677 80.0,
678 None,
679 0.0,
680 &AnimatedProperties::default(),
681 &test_ctx(),
682 );
683 let grid = alpha_grid(&mut surface, W, H);
684
685 assert!(
686 !has_ink_in(&grid, W, 300, W, 0, 45),
687 "wrapped gradient_text must not reach x∈[300,600) on line 1 within an 80px box"
688 );
689 assert!(
690 has_ink_in(&grid, W, 0, W, 55, H),
691 "wrapped gradient_text must spill onto a second line within the box width"
692 );
693 }
694
695 #[test]
696 fn rem_font_size_paints_visible_ink() {
697 let gt = GradientText {
698 content: "HELLO".into(),
699 colors: default_colors(),
700 stops: None,
701 angle: default_angle(),
702 animate_angle: false,
703 speed: default_speed(),
704 timing: Default::default(),
705 style: CssStyle {
706 font_size: Some(Length::String("2rem".into())),
707 ..Default::default()
708 },
709 timeline: Vec::new(),
710 stagger: None,
711 };
712 const W: i32 = 400;
713 const H: i32 = 200;
714 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
715 let canvas = surface.canvas();
716 gt.paint(
717 canvas,
718 300.0,
719 None,
720 0.0,
721 &AnimatedProperties::default(),
722 &test_ctx(),
723 );
724 let grid = alpha_grid(&mut surface, W, H);
725
726 assert!(
727 has_ink_in(&grid, W, 0, W, 0, 60),
728 "gradient_text at font-size: 2rem must paint visible ink"
729 );
730 }
731
732 fn autofit_gradient_text(
733 content: &str,
734 font_size: f32,
735 white_space: Option<CssWhiteSpace>,
736 ) -> GradientText {
737 let mut gt = make_gradient_text(content, white_space);
738 gt.style.font_size = Some(Length::Px(font_size));
739 gt.style.text_autofit = Some(true);
740 gt
741 }
742
743 fn max_ink_x(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
744 let mut max_x: Option<i32> = None;
745 for y in 0..height {
746 for x in (0..surface_width).rev() {
747 if grid[(y * surface_width + x) as usize] > 0 {
748 max_x = Some(max_x.map_or(x, |m| m.max(x)));
749 break;
750 }
751 }
752 }
753 max_x
754 }
755
756 #[test]
757 fn autofit_shrinks_a_nowrap_line_to_fit_and_paint_agrees_with_measure() {
758 use crate::intrinsic::GradientTextIntrinsic;
759 use rustmotion_core::engine::box_tree::{AvailableSpace, IntrinsicMeasure};
760
761 let gt = autofit_gradient_text(
762 "the quick brown fox jumps over the lazy dog",
763 90.0,
764 Some(CssWhiteSpace::Nowrap),
765 );
766 const BOX_W: f32 = 300.0;
767
768 let (measured_w, _) = GradientTextIntrinsic::from_gradient_text(>).measure(
769 (None, None),
770 (AvailableSpace::Definite(BOX_W), AvailableSpace::MaxContent),
771 );
772 assert!(
773 measured_w <= BOX_W + 0.5,
774 "GradientTextIntrinsic itself must report a fit once autofit is on, got {measured_w}"
775 );
776
777 const W: i32 = 900;
778 const H: i32 = 300;
779 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
780 let canvas = surface.canvas();
781 gt.paint(
782 canvas,
783 BOX_W,
784 None,
785 0.0,
786 &AnimatedProperties::default(),
787 &test_ctx(),
788 );
789 let grid = alpha_grid(&mut surface, W, H);
790 let ink_right = max_ink_x(&grid, W, H).expect("gradient_text must paint some ink");
791
792 assert!(
793 (ink_right as f32) <= measured_w + 3.0,
794 "painted ink (right edge {ink_right}) must not exceed the box the intrinsic reserved \
795 ({measured_w})"
796 );
797 assert!(
798 (ink_right as f32) >= measured_w - 15.0,
799 "painted ink (right edge {ink_right}) should land close to the measured width \
800 ({measured_w}) — a big gap means measure and paint disagree on the resolved size"
801 );
802 }
803
804 #[test]
805 fn without_text_autofit_nowrap_still_bleeds_past_the_box_exactly_as_before() {
806 let gt = make_gradient_text(
807 "the quick brown fox jumps over the lazy dog",
808 Some(CssWhiteSpace::Nowrap),
809 );
810 const W: i32 = 600;
811 const H: i32 = 200;
812 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
813 let canvas = surface.canvas();
814 gt.paint(
815 canvas,
816 80.0,
817 Some(45.0),
818 0.0,
819 &AnimatedProperties::default(),
820 &test_ctx(),
821 );
822 let grid = alpha_grid(&mut surface, W, H);
823
824 assert!(
825 has_ink_in(&grid, W, 300, W, 0, 45),
826 "without text-autofit, nowrap gradient_text must still bleed past its box"
827 );
828 }
829
830 #[test]
831 fn autofit_is_stable_across_frames_for_fixed_content() {
832 let gt = autofit_gradient_text(
833 "the quick brown fox jumps over the lazy dog",
834 90.0,
835 Some(CssWhiteSpace::Nowrap),
836 );
837 const W: i32 = 900;
838 const H: i32 = 300;
839
840 let render_at = |t: f64| -> Vec<u8> {
841 let mut surface =
842 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
843 let canvas = surface.canvas();
844 gt.paint(
845 canvas,
846 300.0,
847 Some(60.0),
848 t,
849 &AnimatedProperties::default(),
850 &test_ctx(),
851 );
852 alpha_grid(&mut surface, W, H)
853 };
854
855 let frame_a = render_at(0.0);
856 let frame_b = render_at(0.9);
857 assert_eq!(
858 frame_a, frame_b,
859 "fixed content in a fixed box must render byte-identically regardless of ctx.time"
860 );
861 }
862
863 fn two_color_gradient(angle: f32) -> GradientText {
864 GradientText {
865 content: "IIIIIIIIIIIIIIII".into(),
866 colors: vec!["#FF0000".into(), "#0000FF".into()],
867 stops: None,
868 angle,
869 animate_angle: false,
870 speed: default_speed(),
871 timing: Default::default(),
872 style: CssStyle {
873 font_size: Some(Length::Px(120.0)),
874 ..Default::default()
875 },
876 timeline: Vec::new(),
877 stagger: None,
878 }
879 }
880
881 #[test]
882 fn default_angle_ramps_left_to_right_across_the_glyphs() {
883 let gt = two_color_gradient(default_angle());
884
885 const W: i32 = 1200;
886 const H: i32 = 300;
887 let (buf, alpha) = render_unpremul(>, W, H);
888 let (lo, hi) = ink_x_span(&alpha, W, H).expect("must paint some ink");
889
890 let left = best_pixel_in_column(&buf, &alpha, W, H, (lo + 2).min(hi));
891 let right = best_pixel_in_column(&buf, &alpha, W, H, (hi - 2).max(lo));
892
893 assert!(
894 left.0 as i32 - left.2 as i32 > 60,
895 "with the default angle (CSS 90deg = to right), the left glyph edge should read \
896 close to the first color (red), got {left:?}"
897 );
898 assert!(
899 right.2 as i32 - right.0 as i32 > 60,
900 "with the default angle (CSS 90deg = to right), the right glyph edge should read \
901 close to the last color (blue), got {right:?}"
902 );
903 }
904
905 #[test]
906 fn gradient_line_length_is_the_box_projection_not_the_diagonal() {
907 let gt = two_color_gradient(0.0);
908
909 const W: i32 = 1200;
910 const H: i32 = 300;
911 let (buf, alpha) = render_unpremul(>, W, H);
912 let (top, bottom) = ink_y_span(&alpha, W, H).expect("must paint some ink");
913 let (lo_x, hi_x) = ink_x_span(&alpha, W, H).expect("must paint some ink");
914
915 let top_row = (top + 3).min(bottom);
916 let bottom_row = (bottom - 3).max(top);
917 let top_px = best_pixel_in_row(&buf, &alpha, W, top_row, lo_x, hi_x + 1);
918 let bottom_px = best_pixel_in_row(&buf, &alpha, W, bottom_row, lo_x, hi_x + 1);
919
920 assert!(
921 bottom_px.0 as i32 - bottom_px.2 as i32 > 60,
922 "angle: 0 (CSS 'to top') must put the first color at the bottom edge of the glyph \
923 box, got {bottom_px:?}"
924 );
925 assert!(
926 top_px.2 as i32 - top_px.0 as i32 > 60,
927 "angle: 0 (CSS 'to top') must reach the last color at the top edge — a gradient \
928 line as long as the box diagonal would leave the glyphs sampling only the \
929 unsaturated middle of the ramp, got {top_px:?}"
930 );
931 }
932
933 #[test]
934 fn explicit_stops_override_even_color_spacing() {
935 fn three_color_gradient(stops: Option<Vec<GradientTextStop>>) -> GradientText {
936 GradientText {
937 content: "IIIIIIIIIIIIIIII".into(),
938 colors: vec!["#FF0000".into(), "#00FF00".into(), "#0000FF".into()],
939 stops,
940 angle: 90.0,
941 animate_angle: false,
942 speed: default_speed(),
943 timing: Default::default(),
944 style: CssStyle {
945 font_size: Some(Length::Px(120.0)),
946 ..Default::default()
947 },
948 timeline: Vec::new(),
949 stagger: None,
950 }
951 }
952
953 const W: i32 = 1200;
954 const H: i32 = 300;
955
956 let even = three_color_gradient(None);
957 let (buf_even, alpha_even) = render_unpremul(&even, W, H);
958 let (lo, hi) = ink_x_span(&alpha_even, W, H).expect("must paint some ink");
959 let mid_x = nearest_inked_column(&alpha_even, W, H, (lo + hi) / 2);
960 let mid_even = best_pixel_in_column(&buf_even, &alpha_even, W, H, mid_x);
961
962 let skewed = three_color_gradient(Some(vec![
963 GradientTextStop {
964 color: "#FF0000".into(),
965 position: 0.0,
966 },
967 GradientTextStop {
968 color: "#00FF00".into(),
969 position: 0.9,
970 },
971 GradientTextStop {
972 color: "#0000FF".into(),
973 position: 1.0,
974 },
975 ]));
976 let (buf_skewed, alpha_skewed) = render_unpremul(&skewed, W, H);
977 let mid_skewed = best_pixel_in_column(&buf_skewed, &alpha_skewed, W, H, mid_x);
978
979 assert!(
980 mid_even.1 as i32 > mid_skewed.1 as i32 + 40,
981 "moving the middle stop from its even-spacing position (0.5) to 0.9 must move the \
982 ramp: the text's midpoint should read far less green than the even-spacing case \
983 (even={mid_even:?}, skewed={mid_skewed:?})"
984 );
985 }
986
987 #[test]
988 fn a_stop_written_with_offset_deserializes_the_same_as_one_written_with_position() {
989 let json = r##"{"color": "#7C3AED", "offset": 0.7}"##;
990 let stop: GradientTextStop =
991 serde_json::from_str(json).expect("offset must be accepted as an alias for position");
992 assert_eq!(
993 stop.position, 0.7,
994 "a stop using the background gradient's `offset` vocabulary must resolve to the \
995 same `position` field gradient_text reads at paint time"
996 );
997 }
998
999 fn props_for(gt: &GradientText) -> AnimatedProperties {
1000 AnimatedProperties {
1001 char_animation: rustmotion_core::engine::animator::extract_effects(>.style.animation)
1002 .char_animation,
1003 ..Default::default()
1004 }
1005 }
1006
1007 fn soft_pixel_fraction(
1008 grid: &[u8],
1009 surface_width: i32,
1010 x0: i32,
1011 x1: i32,
1012 y0: i32,
1013 y1: i32,
1014 ) -> f32 {
1015 let mut inked = 0u32;
1016 let mut soft = 0u32;
1017 for y in y0..y1 {
1018 for x in x0..x1 {
1019 let a = grid[(y * surface_width + x) as usize];
1020 if a > 0 {
1021 inked += 1;
1022 if a < 250 {
1023 soft += 1;
1024 }
1025 }
1026 }
1027 }
1028 if inked == 0 {
1029 return 0.0;
1030 }
1031 soft as f32 / inked as f32
1032 }
1033
1034 #[test]
1035 fn char_blur_in_animates_a_gradient_text_word_instead_of_painting_it_sharp_immediately() {
1036 let mut gt = make_gradient_text("BLUR", Some(CssWhiteSpace::Nowrap));
1037 gt.style.font_size = Some(Length::Px(100.0));
1038 gt.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming {
1039 delay: 0.0,
1040 duration: 0.5,
1041 stagger: 0.03,
1042 granularity: TextAnimGranularity::Word,
1043 easing: EasingType::Linear,
1044 ..Default::default()
1045 })];
1046
1047 const W: i32 = 700;
1048 const H: i32 = 220;
1049 let props = props_for(>);
1050
1051 let early = alpha_grid(
1052 &mut {
1053 let mut s = skia_safe::surfaces::raster_n32_premul((W, H)).expect("surface");
1054 gt.paint(s.canvas(), W as f32, None, 0.15, &props, &test_ctx());
1055 s
1056 },
1057 W,
1058 H,
1059 );
1060 let settled = alpha_grid(
1061 &mut {
1062 let mut s = skia_safe::surfaces::raster_n32_premul((W, H)).expect("surface");
1063 gt.paint(s.canvas(), W as f32, None, 1.0, &props, &test_ctx());
1064 s
1065 },
1066 W,
1067 H,
1068 );
1069
1070 assert!(
1071 has_ink_in(&early, W, 0, W, 0, H),
1072 "the word must have started painting by t=0.15 — a char_* preset on gradient_text \
1073 used to be silently ignored and paint the word fully sharp from frame 0"
1074 );
1075
1076 let early_soft = soft_pixel_fraction(&early, W, 0, W, 0, H);
1077 let settled_soft = soft_pixel_fraction(&settled, W, 0, W, 0, H);
1078 assert!(
1079 early_soft > settled_soft + 0.15,
1080 "mid-reveal soft-pixel fraction ({early_soft:.3}) must be clearly higher than the \
1081 settled fraction ({settled_soft:.3}) — gradient_text must actually blur while \
1082 animating, not just render the sharp glyph unconditionally"
1083 );
1084 assert!(
1085 settled_soft < 0.25,
1086 "settled frame should read as sharp text, not blur (soft fraction {settled_soft:.3})"
1087 );
1088 }
1089
1090 #[test]
1091 fn char_animation_never_restarts_the_gradient_ramp_per_unit() {
1092 let mut gt = two_color_gradient(default_angle());
1093 gt.content = "IIII IIII IIII IIII".into();
1094 gt.style.white_space = Some(CssWhiteSpace::Nowrap);
1095 gt.style.animation = vec![AnimationEffect::CharFadeIn(CharAnimationTiming {
1096 delay: 0.0,
1097 duration: 0.1,
1098 stagger: 0.0,
1099 granularity: TextAnimGranularity::Word,
1100 easing: EasingType::Linear,
1101 ..Default::default()
1102 })];
1103
1104 const W: i32 = 1400;
1105 const H: i32 = 300;
1106 let props = props_for(>);
1107 let (buf, alpha) = render_unpremul_at(>, W, H, 5.0, &props);
1108 let (lo, hi) = ink_x_span(&alpha, W, H).expect("must paint some ink");
1109
1110 let left = best_pixel_in_column(&buf, &alpha, W, H, (lo + 2).min(hi));
1111 let right = best_pixel_in_column(&buf, &alpha, W, H, (hi - 2).max(lo));
1112
1113 assert!(
1114 left.0 as i32 - left.2 as i32 > 60,
1115 "the leftmost word must still read close to the ramp's first colour (red) once \
1116 settled — got {left:?}"
1117 );
1118 assert!(
1119 right.2 as i32 - right.0 as i32 > 60,
1120 "the rightmost word must still read close to the ramp's last colour (blue) — \
1121 splitting the run into per-word animated units must not restart the gradient for \
1122 each word, got {right:?}"
1123 );
1124 }
1125}