1use std::collections::HashSet;
2use std::sync::{Arc, Mutex, OnceLock};
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use skia_safe::{
7 Canvas, ColorType, ImageInfo, Matrix, Paint, PaintCap, PaintJoin, PaintStyle, Path,
8 PathBuilder, PathMeasure, Rect,
9};
10
11use rustmotion_core::css::CssStyle;
12use rustmotion_core::engine::animator::{ease, AnimatedProperties};
13use rustmotion_core::engine::layout_pass::BoxLayout;
14use rustmotion_core::engine::renderer::{asset_cache, interpolate_path_data};
15use rustmotion_core::schema::{SvgPathMorph, TimelineStep};
16use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
19#[serde(rename_all = "snake_case")]
20#[derive(Default)]
21pub enum SvgReveal {
22 #[default]
24 Stroke,
25 Fill,
28}
29
30#[derive(Debug, Serialize, Deserialize, JsonSchema)]
31pub struct Svg {
32 #[serde(default)]
33 pub src: Option<String>,
34 #[serde(default)]
35 pub data: Option<String>,
36 #[serde(flatten)]
37 pub timing: TimingConfig,
38 #[serde(default)]
39 pub style: CssStyle,
40 #[serde(default)]
41 pub timeline: Vec<TimelineStep>,
42 #[serde(default)]
43 pub stagger: Option<f32>,
44 #[serde(default)]
49 pub draw: bool,
50 #[serde(default = "default_draw_stroke_width")]
52 pub draw_stroke_width: f32,
53 #[serde(default)]
56 pub draw_overlap: f32,
57 #[serde(default)]
60 pub reveal: SvgReveal,
61 #[serde(default)]
65 pub path_morph: Option<SvgPathMorph>,
66}
67
68fn default_draw_stroke_width() -> f32 {
69 2.0
70}
71
72fn shared_svg_fontdb() -> Arc<usvg::fontdb::Database> {
73 static DB: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
74 DB.get_or_init(|| {
75 let mut db = usvg::fontdb::Database::new();
76 db.load_system_fonts();
77 Arc::new(db)
78 })
79 .clone()
80}
81
82fn svg_parse_options() -> usvg::Options<'static> {
83 usvg::Options {
84 fontdb: shared_svg_fontdb(),
85 ..Default::default()
86 }
87}
88
89fn count_svg_text_elements(svg_data: &[u8]) -> usize {
90 let Ok(source) = std::str::from_utf8(svg_data) else {
91 return 0;
92 };
93 let Ok(doc) = usvg::roxmltree::Document::parse(source) else {
94 return 0;
95 };
96 doc.descendants()
97 .filter(|node| node.is_element() && node.tag_name().name() == "text")
98 .count()
99}
100
101fn count_resolved_text_nodes(group: &usvg::Group) -> usize {
102 group.children().iter().fold(0, |count, node| {
103 count
104 + match node {
105 usvg::Node::Group(g) => count_resolved_text_nodes(g),
106 usvg::Node::Text(_) => 1,
107 _ => 0,
108 }
109 })
110}
111
112fn already_warned_svg_texts() -> &'static Mutex<HashSet<u64>> {
113 static WARNED: OnceLock<Mutex<HashSet<u64>>> = OnceLock::new();
114 WARNED.get_or_init(|| Mutex::new(HashSet::new()))
115}
116
117fn svg_data_fingerprint(svg_data: &[u8]) -> u64 {
118 use std::hash::{Hash, Hasher};
119 let mut hasher = std::collections::hash_map::DefaultHasher::new();
120 svg_data.hash(&mut hasher);
121 hasher.finish()
122}
123
124fn warn_on_unresolved_svg_text(svg_data: &[u8], tree: &usvg::Tree) {
125 let declared = count_svg_text_elements(svg_data);
126 if declared == 0 {
127 return;
128 }
129
130 let resolved = count_resolved_text_nodes(tree.root());
131 if resolved >= declared {
132 return;
133 }
134
135 let fingerprint = svg_data_fingerprint(svg_data);
136 let mut warned = already_warned_svg_texts()
137 .lock()
138 .unwrap_or_else(|e| e.into_inner());
139 if !warned.insert(fingerprint) {
140 return;
141 }
142 drop(warned);
143
144 eprintln!(
145 "Warning: svg: {} of {} <text> element(s) have no matching font face for their font-family and will not be drawn. Declare the family in the scenario's `fonts` list (a \"google\" source or a local `path`), or use a font already installed on the system.",
146 declared - resolved,
147 declared
148 );
149}
150
151rustmotion_core::impl_traits!(Svg {
152 Animatable => animation,
153 Timed => timing,
154 Styled => style,
155});
156
157fn tiny_path_to_skia(tsp: &tiny_skia::Path, abs_transform: tiny_skia::Transform) -> Path {
158 let t = abs_transform;
159 let matrix = Matrix::new_all(t.sx, t.kx, t.tx, t.ky, t.sy, t.ty, 0.0, 0.0, 1.0);
160
161 let mut skia_path = PathBuilder::new();
162 for segment in tsp.segments() {
163 match segment {
164 tiny_skia::PathSegment::MoveTo(p) => {
165 let pt = matrix.map_point((p.x, p.y));
166 skia_path.move_to(pt);
167 }
168 tiny_skia::PathSegment::LineTo(p) => {
169 let pt = matrix.map_point((p.x, p.y));
170 skia_path.line_to(pt);
171 }
172 tiny_skia::PathSegment::QuadTo(p1, p2) => {
173 let cp = matrix.map_point((p1.x, p1.y));
174 let ep = matrix.map_point((p2.x, p2.y));
175 skia_path.quad_to(cp, ep);
176 }
177 tiny_skia::PathSegment::CubicTo(p1, p2, p3) => {
178 let cp1 = matrix.map_point((p1.x, p1.y));
179 let cp2 = matrix.map_point((p2.x, p2.y));
180 let ep = matrix.map_point((p3.x, p3.y));
181 skia_path.cubic_to(cp1, cp2, ep);
182 }
183 tiny_skia::PathSegment::Close => {
184 skia_path.close();
185 }
186 }
187 }
188 skia_path.detach()
189}
190
191struct DrawSegment {
192 path: Path,
193 color: skia_safe::Color,
194 stroke_width: f32,
195 cap: PaintCap,
196 join: PaintJoin,
197}
198
199fn to_skia_cap(cap: usvg::LineCap) -> PaintCap {
200 match cap {
201 usvg::LineCap::Butt => PaintCap::Butt,
202 usvg::LineCap::Round => PaintCap::Round,
203 usvg::LineCap::Square => PaintCap::Square,
204 }
205}
206
207fn to_skia_join(join: usvg::LineJoin) -> PaintJoin {
208 match join {
209 usvg::LineJoin::Miter | usvg::LineJoin::MiterClip => PaintJoin::Miter,
210 usvg::LineJoin::Round => PaintJoin::Round,
211 usvg::LineJoin::Bevel => PaintJoin::Bevel,
212 }
213}
214
215fn collect_paths(group: &usvg::Group, draw_stroke_width: f32, out: &mut Vec<DrawSegment>) {
216 for node in group.children() {
217 match node {
218 usvg::Node::Group(g) => {
219 collect_paths(g, draw_stroke_width, out);
220 }
221 usvg::Node::Path(p) => {
222 if !p.is_visible() {
223 continue;
224 }
225 let skia_path = tiny_path_to_skia(p.data(), p.abs_transform());
226 let (color, stroke_width, cap, join) = if let Some(stroke) = p.stroke() {
227 let sw = stroke.width().get();
228 let c = match stroke.paint() {
229 usvg::Paint::Color(col) => {
230 let alpha = (stroke.opacity().get() * 255.0) as u8;
231 skia_safe::Color::from_argb(alpha, col.red, col.green, col.blue)
232 }
233 _ => skia_safe::Color::WHITE,
234 };
235 (
236 c,
237 sw,
238 to_skia_cap(stroke.linecap()),
239 to_skia_join(stroke.linejoin()),
240 )
241 } else if let Some(fill) = p.fill() {
242 let c = match fill.paint() {
243 usvg::Paint::Color(col) => {
244 let alpha = (fill.opacity().get() * 255.0) as u8;
245 skia_safe::Color::from_argb(alpha, col.red, col.green, col.blue)
246 }
247 _ => skia_safe::Color::WHITE,
248 };
249 (c, draw_stroke_width, PaintCap::Butt, PaintJoin::Miter)
250 } else {
251 (
252 skia_safe::Color::WHITE,
253 draw_stroke_width,
254 PaintCap::Butt,
255 PaintJoin::Miter,
256 )
257 };
258 out.push(DrawSegment {
259 path: skia_path,
260 color,
261 stroke_width,
262 cap,
263 join,
264 });
265 }
266 _ => {}
267 }
268 }
269}
270
271fn collect_paths_for_fill(group: &usvg::Group, out: &mut Vec<Path>) {
272 for node in group.children() {
273 match node {
274 usvg::Node::Group(g) => {
275 collect_paths_for_fill(g, out);
276 }
277 usvg::Node::Path(p) => {
278 if !p.is_visible() {
279 continue;
280 }
281 let mut skia_path = tiny_path_to_skia(p.data(), p.abs_transform());
282 let fill_type = match p.fill().map(|f| f.rule()) {
283 Some(usvg::FillRule::EvenOdd) => skia_safe::PathFillType::EvenOdd,
284 _ => skia_safe::PathFillType::Winding,
285 };
286 skia_path.set_fill_type(fill_type);
287 out.push(skia_path);
288 }
289 _ => {}
290 }
291 }
292}
293
294fn paint_fill_reveal(
295 canvas: &Canvas,
296 group: &usvg::Group,
297 svg_size: usvg::Size,
298 layout: &BoxLayout,
299 progress: f32,
300 draw_overlap: f32,
301 full_image: &skia_safe::Image,
302) {
303 let progress = progress.clamp(0.0, 1.0);
304
305 let mut paths: Vec<Path> = Vec::new();
306 collect_paths_for_fill(group, &mut paths);
307
308 if paths.is_empty() {
309 return;
310 }
311
312 let scale_x = if svg_size.width() > 0.0 {
313 layout.width / svg_size.width()
314 } else {
315 1.0
316 };
317 let scale_y = if svg_size.height() > 0.0 {
318 layout.height / svg_size.height()
319 } else {
320 1.0
321 };
322
323 let lengths: Vec<f32> = paths
324 .iter()
325 .map(|path| {
326 let mut pm = PathMeasure::new(path, false, None);
327 pm.length()
328 })
329 .collect();
330
331 let total_length: f32 = lengths.iter().sum();
332 if total_length <= 0.0 {
333 return;
334 }
335
336 let overlap = draw_overlap.clamp(0.0, 1.0);
337 let image_dst = Rect::from_xywh(0.0, 0.0, svg_size.width(), svg_size.height());
338 let paint = Paint::default();
339
340 let mut cumulative = 0.0f32;
341 for (path, length) in paths.iter().zip(lengths.iter()) {
342 let base_frac = length / total_length;
343 let window_size = base_frac * (1.0 - overlap) + overlap;
344 let start_frac = cumulative * (1.0 - overlap);
345 cumulative += base_frac;
346
347 let local_t = if window_size > 0.0 {
348 ((progress - start_frac) / window_size).clamp(0.0, 1.0)
349 } else if progress >= start_frac {
350 1.0
351 } else {
352 0.0
353 };
354
355 if local_t <= 0.0 {
356 continue;
357 }
358
359 canvas.save();
360 canvas.scale((scale_x, scale_y));
361 canvas.clip_path(path, None, true);
362
363 if local_t < 1.0 {
364 let bounds = path.bounds();
365 let revealed_w = bounds.width() * local_t;
366 let sweep = Rect::from_ltrb(
367 bounds.left,
368 bounds.top - 1.0,
369 bounds.left + revealed_w,
370 bounds.bottom + 1.0,
371 );
372 canvas.clip_rect(sweep, None, true);
373 }
374
375 canvas.draw_image_rect(full_image, None, image_dst, &paint);
376 canvas.restore();
377 }
378}
379
380fn paint_draw_on(
381 canvas: &Canvas,
382 group: &usvg::Group,
383 svg_size: usvg::Size,
384 layout: &BoxLayout,
385 progress: f32,
386 start_progress: f32,
387 draw_stroke_width: f32,
388 draw_overlap: f32,
389) {
390 let progress = progress.clamp(0.0, 1.0);
391 let start_progress = start_progress.clamp(0.0, 1.0);
392
393 let mut segments: Vec<DrawSegment> = Vec::new();
394 collect_paths(group, draw_stroke_width, &mut segments);
395
396 if segments.is_empty() {
397 return;
398 }
399
400 let scale_x = if svg_size.width() > 0.0 {
401 layout.width / svg_size.width()
402 } else {
403 1.0
404 };
405 let scale_y = if svg_size.height() > 0.0 {
406 layout.height / svg_size.height()
407 } else {
408 1.0
409 };
410
411 canvas.save();
412 canvas.scale((scale_x, scale_y));
413
414 let lengths: Vec<f32> = segments
415 .iter()
416 .map(|segment| {
417 let mut pm = PathMeasure::new(&segment.path, false, None);
418 pm.length()
419 })
420 .collect();
421
422 let total_length: f32 = lengths.iter().sum();
423 if total_length <= 0.0 {
424 canvas.restore();
425 return;
426 }
427
428 let overlap = draw_overlap.clamp(0.0, 1.0);
429
430 let mut cumulative = 0.0f32;
431 for (segment, length) in segments.iter().zip(lengths.iter()) {
432 let base_frac = length / total_length;
433 let window_size = base_frac * (1.0 - overlap) + overlap;
434 let start_frac = cumulative * (1.0 - overlap);
435 cumulative += base_frac;
436
437 let local_of = |global: f32| {
438 if window_size > 0.0 {
439 ((global - start_frac) / window_size).clamp(0.0, 1.0)
440 } else if global >= start_frac {
441 1.0
442 } else {
443 0.0
444 }
445 };
446 let local_t = local_of(progress);
447 let local_start = local_of(start_progress);
448
449 if local_t <= local_start {
450 continue;
451 }
452
453 let mut paint = Paint::default();
454 paint.set_color(segment.color);
455 paint.set_style(PaintStyle::Stroke);
456 paint.set_stroke_width(segment.stroke_width);
457 paint.set_stroke_cap(segment.cap);
458 paint.set_stroke_join(segment.join);
459 paint.set_anti_alias(true);
460
461 if local_t < 1.0 || local_start > 0.0 {
462 let trimmed = rustmotion_core::engine::renderer::trim_path_between(
463 &segment.path,
464 local_start,
465 local_t,
466 );
467 canvas.draw_path(&trimmed, &paint);
468 } else {
469 canvas.draw_path(&segment.path, &paint);
470 }
471 }
472
473 canvas.restore();
474}
475
476impl Painter for Svg {
477 fn paint_content(
478 &self,
479 canvas: &Canvas,
480 layout: &BoxLayout,
481 props: &AnimatedProperties,
482 ctx: &PaintCtx,
483 ) {
484 if let Some(morph) = &self.path_morph {
485 self.paint_with_path_morph(canvas, layout, props, ctx, morph);
486 return;
487 }
488
489 let draw_active = self.draw
490 || (props.draw_progress >= 0.0 && props.draw_progress < 1.0)
491 || props.draw_start > 0.0
492 || props.draw_offset.abs() > 0.0005;
493
494 if draw_active {
495 let (window_start, window_end) = draw_window(props);
496
497 if window_end <= window_start {
498 return;
499 }
500
501 let Some(svg_data) = self.raw_svg_data() else {
502 return;
503 };
504
505 let opt = svg_parse_options();
506 let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) else {
507 return;
508 };
509 warn_on_unresolved_svg_text(&svg_data, &tree);
510
511 let svg_size = tree.size();
512
513 if window_end >= 1.0 && window_start <= 0.0 {
514 self.paint_resvg(canvas, layout, &svg_data, &tree, svg_size);
515 } else if self.reveal == SvgReveal::Fill {
516 let Some(full_image) = self.cached_full_image(layout) else {
517 return;
518 };
519 paint_fill_reveal(
520 canvas,
521 tree.root(),
522 svg_size,
523 layout,
524 window_end,
525 self.draw_overlap,
526 &full_image,
527 );
528 } else {
529 paint_draw_on(
530 canvas,
531 tree.root(),
532 svg_size,
533 layout,
534 window_end,
535 window_start,
536 self.draw_stroke_width,
537 self.draw_overlap,
538 );
539 }
540 } else {
541 self.paint_static(canvas, layout);
542 }
543 }
544}
545
546fn draw_window(props: &AnimatedProperties) -> (f32, f32) {
547 let progress = if props.draw_progress >= 0.0 {
548 props.draw_progress.clamp(0.0, 1.0)
549 } else {
550 1.0
551 };
552 let draw_start = props.draw_start.max(0.0);
553 let draw_offset = props.draw_offset;
554 let window_start = (draw_start + draw_offset).clamp(0.0, 1.0);
555 let window_end = (progress + draw_offset).clamp(0.0, 1.0);
556 (window_start, window_end)
557}
558
559fn resolve_svg_path_morph(morph: &SvgPathMorph, time: f64) -> Option<String> {
560 let keyframes = &morph.keyframes;
561 let first = keyframes.first()?;
562 if keyframes.len() == 1 {
563 return Some(first.value.clone());
564 }
565 let last = keyframes.last()?;
566 let first_time = first.time;
567 let last_time = last.time;
568 let span = (last_time - first_time).max(1e-9);
569
570 let sample_time = if morph.repeat && time > first_time {
571 let elapsed = time - first_time;
572 let cycle = elapsed.rem_euclid(span);
573 let forward = elapsed.div_euclid(span) as i64 % 2 == 0;
574 if morph.yoyo && !forward {
575 last_time - cycle
576 } else {
577 first_time + cycle
578 }
579 } else {
580 time.clamp(first_time, last_time)
581 };
582
583 let mut lower = first;
584 let mut upper = last;
585 for pair in keyframes.windows(2) {
586 if sample_time >= pair[0].time && sample_time <= pair[1].time {
587 lower = &pair[0];
588 upper = &pair[1];
589 break;
590 }
591 }
592
593 if lower.value == upper.value {
594 return Some(lower.value.clone());
595 }
596 let segment_span = (upper.time - lower.time).max(1e-9);
597 let local_t = ((sample_time - lower.time) / segment_span).clamp(0.0, 1.0);
598 let eased = ease(local_t, &morph.easing) as f32;
599 interpolate_path_data(&lower.value, &upper.value, eased).map(|p| p.to_svg())
600}
601
602fn find_tag_bounds_for_id(source: &str, target_id: &str) -> Option<(usize, usize)> {
603 let needle_double = format!("id=\"{target_id}\"");
604 let needle_single = format!("id='{target_id}'");
605 let id_pos = source
606 .find(&needle_double)
607 .or_else(|| source.find(&needle_single))?;
608 let tag_start = source[..id_pos].rfind('<')?;
609 let tag_end = id_pos + source[id_pos..].find('>')?;
610 Some((tag_start, tag_end))
611}
612
613fn find_d_attribute_value(tag: &str) -> Option<(usize, usize)> {
614 let bytes = tag.as_bytes();
615 let mut i = 0;
616 while i + 2 < bytes.len() {
617 if bytes[i] == b'd' && bytes[i + 1] == b'=' {
618 let boundary_ok = i == 0 || bytes[i - 1].is_ascii_whitespace();
619 if boundary_ok {
620 let quote = bytes[i + 2] as char;
621 if quote == '"' || quote == '\'' {
622 let value_start = i + 3;
623 if let Some(rel_end) = tag[value_start..].find(quote) {
624 return Some((value_start, value_start + rel_end));
625 }
626 }
627 }
628 }
629 i += 1;
630 }
631 None
632}
633
634fn patch_path_d(svg_source: &[u8], target: &str, new_d: &str) -> Option<Vec<u8>> {
635 let source = std::str::from_utf8(svg_source).ok()?;
636 let clean_target = target.strip_prefix('#').unwrap_or(target);
637 let (tag_start, tag_end) = find_tag_bounds_for_id(source, clean_target)?;
638 let tag = &source[tag_start..=tag_end];
639 let (value_start_rel, value_end_rel) = find_d_attribute_value(tag)?;
640
641 let mut patched = String::with_capacity(source.len() + new_d.len());
642 patched.push_str(&source[..tag_start]);
643 patched.push_str(&tag[..value_start_rel]);
644 patched.push_str(new_d);
645 patched.push_str(&tag[value_end_rel..]);
646 patched.push_str(&source[tag_end + 1..]);
647 Some(patched.into_bytes())
648}
649
650fn render_svg_to_image(
651 svg_data: &[u8],
652 target_w_opt: Option<u32>,
653 target_h_opt: Option<u32>,
654) -> Option<skia_safe::Image> {
655 let opt = svg_parse_options();
656 let tree = usvg::Tree::from_data(svg_data, &opt).ok()?;
657 warn_on_unresolved_svg_text(svg_data, &tree);
658
659 let svg_size = tree.size();
660 let target_w = target_w_opt.unwrap_or(svg_size.width() as u32);
661 let target_h = target_h_opt.unwrap_or(svg_size.height() as u32);
662
663 let mut pixmap = tiny_skia::Pixmap::new(target_w, target_h)?;
664 let scale_x = target_w as f32 / svg_size.width();
665 let scale_y = target_h as f32 / svg_size.height();
666 let transform = tiny_skia::Transform::from_scale(scale_x, scale_y);
667 resvg::render(&tree, transform, &mut pixmap.as_mut());
668
669 let img_data = skia_safe::Data::new_copy(pixmap.data());
670 let img_info = ImageInfo::new(
671 (target_w as i32, target_h as i32),
672 ColorType::RGBA8888,
673 skia_safe::AlphaType::Premul,
674 None,
675 );
676 skia_safe::images::raster_from_data(&img_info, img_data, target_w as usize * 4)
677}
678
679impl Svg {
680 fn raw_svg_data(&self) -> Option<Vec<u8>> {
681 if let Some(ref src) = self.src {
682 std::fs::read(src).ok()
683 } else {
684 self.data.as_ref().map(|d| d.as_bytes().to_vec())
685 }
686 }
687
688 fn paint_with_path_morph(
689 &self,
690 canvas: &Canvas,
691 layout: &BoxLayout,
692 props: &AnimatedProperties,
693 ctx: &PaintCtx,
694 morph: &SvgPathMorph,
695 ) {
696 let Some(raw) = self.raw_svg_data() else {
697 return;
698 };
699 let patched = resolve_svg_path_morph(morph, ctx.time)
700 .and_then(|d| patch_path_d(&raw, &morph.target, &d))
701 .unwrap_or(raw);
702
703 let draw_active = self.draw
704 || (props.draw_progress >= 0.0 && props.draw_progress < 1.0)
705 || props.draw_start > 0.0
706 || props.draw_offset.abs() > 0.0005;
707
708 let target_w_opt = if layout.width > 0.0 {
709 Some(layout.width as u32)
710 } else {
711 None
712 };
713 let target_h_opt = if layout.height > 0.0 {
714 Some(layout.height as u32)
715 } else {
716 None
717 };
718
719 if !draw_active {
720 let Some(img) = render_svg_to_image(&patched, target_w_opt, target_h_opt) else {
721 return;
722 };
723 let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
724 canvas.draw_image_rect(img, None, dst, &Paint::default());
725 return;
726 }
727
728 let (window_start, window_end) = draw_window(props);
729 if window_end <= window_start {
730 return;
731 }
732
733 let opt = svg_parse_options();
734 let Ok(tree) = usvg::Tree::from_data(&patched, &opt) else {
735 return;
736 };
737 warn_on_unresolved_svg_text(&patched, &tree);
738 let svg_size = tree.size();
739
740 if window_end >= 1.0 && window_start <= 0.0 {
741 self.paint_resvg(canvas, layout, &patched, &tree, svg_size);
742 } else if self.reveal == SvgReveal::Fill {
743 let Some(full_image) = render_svg_to_image(&patched, target_w_opt, target_h_opt) else {
744 return;
745 };
746 paint_fill_reveal(
747 canvas,
748 tree.root(),
749 svg_size,
750 layout,
751 window_end,
752 self.draw_overlap,
753 &full_image,
754 );
755 } else {
756 paint_draw_on(
757 canvas,
758 tree.root(),
759 svg_size,
760 layout,
761 window_end,
762 window_start,
763 self.draw_stroke_width,
764 self.draw_overlap,
765 );
766 }
767 }
768
769 fn paint_static(&self, canvas: &Canvas, layout: &BoxLayout) {
770 let Some(img) = self.cached_full_image(layout) else {
771 return;
772 };
773
774 let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
775 let paint = Paint::default();
776 canvas.draw_image_rect(img, None, dst, &paint);
777 }
778
779 fn cached_full_image(&self, layout: &BoxLayout) -> Option<skia_safe::Image> {
780 let target_w_opt: Option<u32> = if layout.width > 0.0 {
781 Some(layout.width as u32)
782 } else {
783 None
784 };
785 let target_h_opt: Option<u32> = if layout.height > 0.0 {
786 Some(layout.height as u32)
787 } else {
788 None
789 };
790
791 let cache_key = if let Some(ref src) = self.src {
792 format!(
793 "svg:{}:{}x{}",
794 src,
795 target_w_opt.unwrap_or(0),
796 target_h_opt.unwrap_or(0)
797 )
798 } else {
799 let data = self.data.as_ref()?;
800 use std::collections::hash_map::DefaultHasher;
801 use std::hash::{Hash, Hasher};
802 let mut hasher = DefaultHasher::new();
803 data.hash(&mut hasher);
804 format!(
805 "svg-inline:{}:{}x{}",
806 hasher.finish(),
807 target_w_opt.unwrap_or(0),
808 target_h_opt.unwrap_or(0)
809 )
810 };
811
812 let cache = asset_cache();
813 if let Some(cached) = cache.get(&cache_key) {
814 return Some(cached.clone());
815 }
816
817 let svg_data = if let Some(ref src) = self.src {
818 std::fs::read(src).ok()?
819 } else {
820 self.data.as_ref()?.as_bytes().to_vec()
821 };
822
823 let opt = svg_parse_options();
824 let tree = usvg::Tree::from_data(&svg_data, &opt).ok()?;
825 warn_on_unresolved_svg_text(&svg_data, &tree);
826
827 let svg_size = tree.size();
828 let target_w = target_w_opt.unwrap_or(svg_size.width() as u32);
829 let target_h = target_h_opt.unwrap_or(svg_size.height() as u32);
830
831 let mut pixmap = tiny_skia::Pixmap::new(target_w, target_h)?;
832
833 let scale_x = target_w as f32 / svg_size.width();
834 let scale_y = target_h as f32 / svg_size.height();
835 let transform = tiny_skia::Transform::from_scale(scale_x, scale_y);
836
837 resvg::render(&tree, transform, &mut pixmap.as_mut());
838
839 let img_data = skia_safe::Data::new_copy(pixmap.data());
840 let img_info = ImageInfo::new(
841 (target_w as i32, target_h as i32),
842 ColorType::RGBA8888,
843 skia_safe::AlphaType::Premul,
844 None,
845 );
846 let decoded =
847 skia_safe::images::raster_from_data(&img_info, img_data, target_w as usize * 4)?;
848 cache.insert(cache_key, decoded.clone());
849 Some(decoded)
850 }
851
852 fn paint_resvg(
853 &self,
854 canvas: &Canvas,
855 layout: &BoxLayout,
856 svg_data: &[u8],
857 tree: &usvg::Tree,
858 svg_size: usvg::Size,
859 ) {
860 let target_w = if layout.width > 0.0 {
861 layout.width as u32
862 } else {
863 svg_size.width() as u32
864 };
865 let target_h = if layout.height > 0.0 {
866 layout.height as u32
867 } else {
868 svg_size.height() as u32
869 };
870
871 if target_w == 0 || target_h == 0 {
872 return;
873 }
874
875 let Some(mut pixmap) = tiny_skia::Pixmap::new(target_w, target_h) else {
876 return;
877 };
878
879 let scale_x = target_w as f32 / svg_size.width();
880 let scale_y = target_h as f32 / svg_size.height();
881 let transform = tiny_skia::Transform::from_scale(scale_x, scale_y);
882 resvg::render(tree, transform, &mut pixmap.as_mut());
883
884 let img_data = skia_safe::Data::new_copy(pixmap.data());
885 let img_info = ImageInfo::new(
886 (target_w as i32, target_h as i32),
887 ColorType::RGBA8888,
888 skia_safe::AlphaType::Premul,
889 None,
890 );
891 let Some(img) =
892 skia_safe::images::raster_from_data(&img_info, img_data, target_w as usize * 4)
893 else {
894 return;
895 };
896
897 let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
898 let paint = Paint::default();
899 canvas.draw_image_rect(img, None, dst, &paint);
900
901 let _ = svg_data;
902 }
903}
904
905#[cfg(test)]
906mod tests {
907 use super::*;
908 use rustmotion_core::engine::layout_pass::Insets;
909
910 const W: i32 = 100;
911 const H: i32 = 100;
912
913 fn filled_square_svg() -> Svg {
914 Svg {
915 src: None,
916 data: Some(
917 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
918 <rect x="10" y="10" width="80" height="80" fill="#ff0000"/>
919 </svg>"##
920 .to_string(),
921 ),
922 timing: Default::default(),
923 style: Default::default(),
924 timeline: Vec::new(),
925 stagger: None,
926 draw: false,
927 draw_stroke_width: default_draw_stroke_width(),
928 draw_overlap: 0.0,
929 reveal: SvgReveal::Fill,
930 path_morph: None,
931 }
932 }
933
934 fn test_layout() -> BoxLayout {
935 BoxLayout {
936 x: 0.0,
937 y: 0.0,
938 width: W as f32,
939 height: H as f32,
940 border: Insets::default(),
941 padding: Insets::default(),
942 }
943 }
944
945 fn test_ctx() -> PaintCtx {
946 PaintCtx {
947 time: 0.0,
948 scenario_time: 0.0,
949 scene_duration: 1.0,
950 frame_index: 0,
951 fps: 30,
952 video_width: 1920,
953 video_height: 1080,
954 stagger_offset: 0.0,
955 }
956 }
957
958 fn red_alpha_at(surface: &mut skia_safe::Surface, x: i32, y: i32) -> (u8, u8, u8, u8) {
959 let snapshot = surface.image_snapshot();
960 let info = skia_safe::ImageInfo::new(
961 (W, H),
962 skia_safe::ColorType::RGBA8888,
963 skia_safe::AlphaType::Unpremul,
964 None,
965 );
966 let mut buf = vec![0u8; (W * H * 4) as usize];
967 let ok = snapshot.read_pixels(
968 &info,
969 &mut buf,
970 (W * 4) as usize,
971 skia_safe::IPoint::new(0, 0),
972 skia_safe::image::CachingHint::Disallow,
973 );
974 assert!(ok, "pixel read should succeed");
975 let idx = ((y * W + x) * 4) as usize;
976 (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3])
977 }
978
979 #[test]
980 fn fill_reveal_paints_interior_pixels_at_partial_progress() {
981 let svg = filled_square_svg();
982 let layout = test_layout();
983 let props = AnimatedProperties {
984 draw_progress: 0.5,
985 ..Default::default()
986 };
987 let ctx = test_ctx();
988
989 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
990 {
991 let canvas = surface.canvas();
992 svg.paint_content(canvas, &layout, &props, &ctx);
993 }
994
995 let (r, g, b, a) = red_alpha_at(&mut surface, 30, 50);
996 assert!(
997 a > 200 && r > 200 && g < 50 && b < 50,
998 "fill reveal at draw_progress=0.5 must paint filled interior pixels, got rgba=({r},{g},{b},{a}) at (30,50)"
999 );
1000 }
1001
1002 #[test]
1003 fn stroke_reveal_default_leaves_interior_unfilled_at_partial_progress() {
1004 let mut svg = filled_square_svg();
1005 svg.reveal = SvgReveal::Stroke;
1006 let layout = test_layout();
1007 let props = AnimatedProperties {
1008 draw_progress: 0.5,
1009 ..Default::default()
1010 };
1011 let ctx = test_ctx();
1012
1013 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1014 {
1015 let canvas = surface.canvas();
1016 svg.paint_content(canvas, &layout, &props, &ctx);
1017 }
1018
1019 let (_, _, _, a) = red_alpha_at(&mut surface, 50, 50);
1020 assert!(
1021 a < 50,
1022 "default stroke reveal must not fill the interior at partial progress, got alpha={a} at (50,50)"
1023 );
1024 }
1025
1026 fn line_stroke_svg(stroke_width: f32, cap: &str) -> Svg {
1027 Svg {
1028 src: None,
1029 data: Some(format!(
1030 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M10 50 L90 50" stroke="#000000" stroke-width="{stroke_width}" stroke-linecap="{cap}" fill="none"/></svg>"##
1031 )),
1032 timing: Default::default(),
1033 style: Default::default(),
1034 timeline: Vec::new(),
1035 stagger: None,
1036 draw: false,
1037 draw_stroke_width: default_draw_stroke_width(),
1038 draw_overlap: 0.0,
1039 reveal: SvgReveal::Stroke,
1040 path_morph: None,
1041 }
1042 }
1043
1044 fn layout_for(size: f32) -> BoxLayout {
1045 BoxLayout {
1046 x: 0.0,
1047 y: 0.0,
1048 width: size,
1049 height: size,
1050 border: Insets::default(),
1051 padding: Insets::default(),
1052 }
1053 }
1054
1055 fn render_alpha_buffer(
1056 svg: &Svg,
1057 layout: &BoxLayout,
1058 draw_progress: f32,
1059 size: i32,
1060 ) -> Vec<u8> {
1061 let props = AnimatedProperties {
1062 draw_progress,
1063 ..Default::default()
1064 };
1065 let ctx = test_ctx();
1066
1067 let mut surface =
1068 skia_safe::surfaces::raster_n32_premul((size, size)).expect("raster surface");
1069 {
1070 let canvas = surface.canvas();
1071 svg.paint_content(canvas, layout, &props, &ctx);
1072 }
1073
1074 let snapshot = surface.image_snapshot();
1075 let info = skia_safe::ImageInfo::new(
1076 (size, size),
1077 skia_safe::ColorType::RGBA8888,
1078 skia_safe::AlphaType::Unpremul,
1079 None,
1080 );
1081 let mut buf = vec![0u8; (size * size * 4) as usize];
1082 let ok = snapshot.read_pixels(
1083 &info,
1084 &mut buf,
1085 (size * 4) as usize,
1086 skia_safe::IPoint::new(0, 0),
1087 skia_safe::image::CachingHint::Disallow,
1088 );
1089 assert!(ok, "pixel read should succeed");
1090 buf
1091 }
1092
1093 fn buffer_alpha_at(buf: &[u8], size: i32, x: i32, y: i32) -> u8 {
1094 let idx = ((y * size + x) * 4 + 3) as usize;
1095 buf[idx]
1096 }
1097
1098 fn vertical_ink_thickness(buf: &[u8], size: i32, x: i32) -> i32 {
1099 (0..size)
1100 .filter(|&y| buffer_alpha_at(buf, size, x, y) > 40)
1101 .count() as i32
1102 }
1103
1104 fn leftmost_ink_x(buf: &[u8], size: i32, y: i32) -> Option<i32> {
1105 (0..size).find(|&x| buffer_alpha_at(buf, size, x, y) > 40)
1106 }
1107
1108 #[test]
1109 fn draw_on_stroke_width_scales_with_the_nodes_transform_like_the_finished_render() {
1110 let svg = line_stroke_svg(4.0, "butt");
1111
1112 let small_buf = render_alpha_buffer(&svg, &layout_for(100.0), 0.5, 100);
1113 let small_thickness = vertical_ink_thickness(&small_buf, 100, 30);
1114
1115 let large_buf = render_alpha_buffer(&svg, &layout_for(400.0), 0.5, 400);
1116 let large_thickness = vertical_ink_thickness(&large_buf, 400, 120);
1117
1118 assert!(
1119 small_thickness > 0 && large_thickness > 0,
1120 "both renders must show ink on the drawn segment, got small={small_thickness} large={large_thickness}"
1121 );
1122 assert!(
1123 (large_thickness as f32) > (small_thickness as f32) * 2.0,
1124 "stroke width while drawing must scale with the node's viewBox transform \
1125 (4x layout should read ~4x thicker), got small={small_thickness}px large={large_thickness}px"
1126 );
1127 }
1128
1129 #[test]
1130 fn draw_on_uses_the_finished_marks_stroke_cap() {
1131 let svg = line_stroke_svg(16.0, "round");
1132 let layout = layout_for(100.0);
1133
1134 let drawing_buf = render_alpha_buffer(&svg, &layout, 0.999, 100);
1135 let drawing_left = leftmost_ink_x(&drawing_buf, 100, 50)
1136 .expect("the almost-finished drawing frame must have ink on the line's row");
1137
1138 let finished_buf = render_alpha_buffer(&svg, &layout, 1.0, 100);
1139 let finished_left = leftmost_ink_x(&finished_buf, 100, 50)
1140 .expect("the finished frame must have ink on the line's row");
1141
1142 assert!(
1143 (drawing_left - finished_left).abs() <= 2,
1144 "the last drawing frame must reach as far left as the finished mark's round cap, \
1145 got drawing_left={drawing_left} finished_left={finished_left}"
1146 );
1147 }
1148
1149 fn a_family_this_host_actually_has() -> Option<String> {
1150 shared_svg_fontdb()
1151 .faces()
1152 .next()
1153 .and_then(|face| face.families.first().map(|(name, _)| name.clone()))
1154 }
1155
1156 fn text_svg(font_family: &str) -> Svg {
1157 Svg {
1158 src: None,
1159 data: Some(format!(
1160 r##"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 100' width='300' height='100'><text x='10' y='70' font-size='60' font-family='{font_family}' fill='#000000'>SVG</text></svg>"##
1161 )),
1162 timing: Default::default(),
1163 style: Default::default(),
1164 timeline: Vec::new(),
1165 stagger: None,
1166 draw: false,
1167 draw_stroke_width: default_draw_stroke_width(),
1168 draw_overlap: 0.0,
1169 reveal: SvgReveal::Stroke,
1170 path_morph: None,
1171 }
1172 }
1173
1174 #[test]
1175 fn svg_text_with_a_system_font_is_rasterized() {
1176 let Some(family) = a_family_this_host_actually_has() else {
1177 return;
1178 };
1179 let svg = text_svg(&family);
1180 let layout = BoxLayout {
1181 x: 0.0,
1182 y: 0.0,
1183 width: 300.0,
1184 height: 100.0,
1185 border: Insets::default(),
1186 padding: Insets::default(),
1187 };
1188 let props = AnimatedProperties::default();
1189 let ctx = test_ctx();
1190
1191 let mut surface =
1192 skia_safe::surfaces::raster_n32_premul((300, 100)).expect("raster surface");
1193 {
1194 let canvas = surface.canvas();
1195 svg.paint_content(canvas, &layout, &props, &ctx);
1196 }
1197
1198 let snapshot = surface.image_snapshot();
1199 let info = skia_safe::ImageInfo::new(
1200 (300, 100),
1201 skia_safe::ColorType::RGBA8888,
1202 skia_safe::AlphaType::Unpremul,
1203 None,
1204 );
1205 let mut buf = vec![0u8; (300 * 100 * 4) as usize];
1206 let ok = snapshot.read_pixels(
1207 &info,
1208 &mut buf,
1209 (300 * 4) as usize,
1210 skia_safe::IPoint::new(0, 0),
1211 skia_safe::image::CachingHint::Disallow,
1212 );
1213 assert!(ok, "pixel read should succeed");
1214
1215 let dark_pixels = (0..100)
1216 .flat_map(|y| (0..300).map(move |x| (x, y)))
1217 .filter(|&(x, y)| {
1218 let idx = ((y * 300 + x) * 4) as usize;
1219 let (r, g, b, a) = (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]);
1220 a > 200 && r < 60 && g < 60 && b < 60
1221 })
1222 .count();
1223
1224 assert!(
1225 dark_pixels > 20,
1226 "the <text> element must be rasterized into dark pixels with a system font \
1227 available, got {dark_pixels} matching pixels"
1228 );
1229 }
1230
1231 #[test]
1232 fn a_host_with_no_font_at_all_says_so_instead_of_dropping_the_text() {
1233 let svg_data = text_svg("Helvetica").data.unwrap().into_bytes();
1234 let empty_db = usvg::Options::default();
1235 let tree = usvg::Tree::from_data(&svg_data, &empty_db).expect("valid svg");
1236
1237 assert_eq!(count_svg_text_elements(&svg_data), 1);
1238 assert_eq!(
1239 count_resolved_text_nodes(tree.root()),
1240 0,
1241 "with an empty fontdb usvg drops the <text> node from the tree entirely — this \
1242 is the condition the warning exists for, and the one a bare CI container is in"
1243 );
1244 }
1245
1246 #[test]
1247 fn a_resolvable_font_family_matches_declared_and_resolved_text_counts() {
1248 let Some(family) = a_family_this_host_actually_has() else {
1249 return;
1250 };
1251 let svg = text_svg(&family);
1252 let svg_data = svg.data.as_ref().unwrap().as_bytes().to_vec();
1253 let opt = svg_parse_options();
1254 let tree = usvg::Tree::from_data(&svg_data, &opt).expect("valid svg");
1255
1256 assert_eq!(count_svg_text_elements(&svg_data), 1);
1257 assert_eq!(
1258 count_resolved_text_nodes(tree.root()),
1259 1,
1260 "a family this host actually has must resolve once system fonts are loaded"
1261 );
1262 }
1263
1264 #[test]
1265 fn an_empty_font_database_is_detected_as_undrawn_text() {
1266 let svg = text_svg("Helvetica");
1267 let svg_data = svg.data.as_ref().unwrap().as_bytes().to_vec();
1268 let opt = usvg::Options::default();
1269 let tree = usvg::Tree::from_data(&svg_data, &opt).expect("valid svg");
1270
1271 assert_eq!(count_svg_text_elements(&svg_data), 1);
1272 assert_eq!(
1273 count_resolved_text_nodes(tree.root()),
1274 0,
1275 "usvg::Options::default() carries an empty font database (the pre-fix behavior \
1276 behind issue #374): no <text> can resolve, which is exactly the condition that \
1277 must trigger the stderr warning"
1278 );
1279 }
1280 #[test]
1281 fn draw_with_nothing_driving_progress_is_pixel_identical_to_no_draw() {
1282 fn render(draw: bool) -> Vec<u8> {
1283 let mut svg = filled_square_svg();
1284 svg.draw = draw;
1285 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).unwrap();
1286 svg.paint_content(
1287 surface.canvas(),
1288 &test_layout(),
1289 &AnimatedProperties::default(),
1290 &test_ctx(),
1291 );
1292 let snapshot = surface.image_snapshot();
1293 let info = skia_safe::ImageInfo::new(
1294 (W, H),
1295 skia_safe::ColorType::RGBA8888,
1296 skia_safe::AlphaType::Unpremul,
1297 None,
1298 );
1299 let mut buf = vec![0u8; (W * H * 4) as usize];
1300 snapshot.read_pixels(
1301 &info,
1302 &mut buf,
1303 (W * 4) as usize,
1304 skia_safe::IPoint::new(0, 0),
1305 skia_safe::image::CachingHint::Disallow,
1306 );
1307 buf
1308 }
1309
1310 assert_eq!(
1311 render(true),
1312 render(false),
1313 "with draw_progress at rest the draw branch short-circuits to the finished mark, \
1314 so the flag changes nothing — which is why validate now refuses it"
1315 );
1316 }
1317}
1318
1319#[cfg(test)]
1320mod path_drawing_regression_tests {
1321 use super::*;
1322 use rustmotion_core::engine::layout_pass::Insets;
1323 use rustmotion_core::schema::{EasingType, SvgPathMorphKeyframe};
1324
1325 const SIZE: i32 = 100;
1326
1327 fn ctx() -> PaintCtx {
1328 PaintCtx {
1329 time: 0.0,
1330 scenario_time: 0.0,
1331 scene_duration: 1.0,
1332 frame_index: 0,
1333 fps: 30,
1334 video_width: SIZE as u32,
1335 video_height: SIZE as u32,
1336 stagger_offset: 0.0,
1337 }
1338 }
1339
1340 fn ctx_at(time: f64) -> PaintCtx {
1341 PaintCtx { time, ..ctx() }
1342 }
1343
1344 fn layout() -> BoxLayout {
1345 BoxLayout {
1346 x: 0.0,
1347 y: 0.0,
1348 width: SIZE as f32,
1349 height: SIZE as f32,
1350 border: Insets::default(),
1351 padding: Insets::default(),
1352 }
1353 }
1354
1355 fn horizontal_stroke_svg() -> Svg {
1356 Svg {
1357 src: None,
1358 data: Some(
1359 "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>\
1360 <path d='M10 50 L90 50' stroke='#000000' stroke-width='8' \
1361 stroke-linecap='butt' fill='none'/></svg>"
1362 .to_string(),
1363 ),
1364 timing: Default::default(),
1365 style: Default::default(),
1366 timeline: Vec::new(),
1367 stagger: None,
1368 draw: false,
1369 draw_stroke_width: default_draw_stroke_width(),
1370 draw_overlap: 0.0,
1371 reveal: SvgReveal::Stroke,
1372 path_morph: None,
1373 }
1374 }
1375
1376 fn read_alpha(surface: &mut skia_safe::Surface) -> Vec<u8> {
1377 let info = skia_safe::ImageInfo::new(
1378 (SIZE, SIZE),
1379 skia_safe::ColorType::RGBA8888,
1380 skia_safe::AlphaType::Unpremul,
1381 None,
1382 );
1383 let mut buf = vec![0u8; (SIZE * SIZE * 4) as usize];
1384 let ok = surface.read_pixels(&info, &mut buf, (SIZE * 4) as usize, (0, 0));
1385 assert!(ok, "pixel read should succeed");
1386 buf
1387 }
1388
1389 fn render(svg: &Svg, props: &AnimatedProperties) -> Vec<u8> {
1390 let mut surface =
1391 skia_safe::surfaces::raster_n32_premul((SIZE, SIZE)).expect("raster surface");
1392 svg.paint_content(surface.canvas(), &layout(), props, &ctx());
1393 read_alpha(&mut surface)
1394 }
1395
1396 fn render_at(svg: &Svg, props: &AnimatedProperties, time: f64) -> Vec<u8> {
1397 let mut surface =
1398 skia_safe::surfaces::raster_n32_premul((SIZE, SIZE)).expect("raster surface");
1399 svg.paint_content(surface.canvas(), &layout(), props, &ctx_at(time));
1400 read_alpha(&mut surface)
1401 }
1402
1403 fn alpha_at(buf: &[u8], x: i32, y: i32) -> u8 {
1404 buf[((y * SIZE + x) * 4 + 3) as usize]
1405 }
1406
1407 #[test]
1408 fn draw_start_erases_the_head_of_an_already_finished_svg_stroke() {
1409 let svg = horizontal_stroke_svg();
1410 let props = AnimatedProperties {
1411 draw_start: 0.5,
1412 ..AnimatedProperties::default()
1413 };
1414 let buf = render(&svg, &props);
1415 assert_eq!(
1416 alpha_at(&buf, 20, 50),
1417 0,
1418 "draw_start=0.5 on a finished (never-animated) svg stroke must erase the head — \
1419 the paint_resvg branch used to ignore draw_start entirely once progress was \
1420 treated as finished"
1421 );
1422 assert!(alpha_at(&buf, 80, 50) > 40, "and leave the tail painted");
1423 }
1424
1425 #[test]
1426 fn draw_start_absent_matches_draw_start_zero_on_a_finished_stroke() {
1427 let svg = horizontal_stroke_svg();
1428 let with_zero = render(
1429 &svg,
1430 &AnimatedProperties {
1431 draw_start: 0.0,
1432 ..AnimatedProperties::default()
1433 },
1434 );
1435 let absent = render(&svg, &AnimatedProperties::default());
1436 assert_eq!(
1437 with_zero, absent,
1438 "draw_start absent and draw_start 0 must both paint the whole finished stroke"
1439 );
1440 }
1441
1442 #[test]
1443 fn draw_offset_marches_the_drawn_window_along_the_path() {
1444 let svg = horizontal_stroke_svg();
1445 let at_start = render(
1446 &svg,
1447 &AnimatedProperties {
1448 draw_progress: 0.3,
1449 ..AnimatedProperties::default()
1450 },
1451 );
1452 let shifted = render(
1453 &svg,
1454 &AnimatedProperties {
1455 draw_progress: 0.3,
1456 draw_offset: 0.5,
1457 ..AnimatedProperties::default()
1458 },
1459 );
1460 assert!(
1461 alpha_at(&at_start, 20, 50) > 40,
1462 "with no offset, the window starts at the path's own beginning"
1463 );
1464 assert_eq!(
1465 alpha_at(&shifted, 20, 50),
1466 0,
1467 "draw_offset=0.5 must march the window forward, leaving the path's start empty"
1468 );
1469 assert!(
1470 alpha_at(&shifted, 70, 50) > 40,
1471 "and paint further along the path instead"
1472 );
1473 }
1474
1475 fn two_path_svg() -> Svg {
1476 Svg {
1477 src: None,
1478 data: Some(
1479 "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>\
1480 <path id='static' d='M10 10 L20 10' stroke='#00FF00' stroke-width='4' \
1481 fill='none'/>\
1482 <path id='morphed' d='M10 90 L20 90' stroke='#FF0000' stroke-width='6' \
1483 fill='none'/></svg>"
1484 .to_string(),
1485 ),
1486 timing: Default::default(),
1487 style: Default::default(),
1488 timeline: Vec::new(),
1489 stagger: None,
1490 draw: false,
1491 draw_stroke_width: default_draw_stroke_width(),
1492 draw_overlap: 0.0,
1493 reveal: SvgReveal::Stroke,
1494 path_morph: Some(SvgPathMorph {
1495 target: "morphed".to_string(),
1496 keyframes: vec![
1497 SvgPathMorphKeyframe {
1498 time: 0.0,
1499 value: "M10 90 L20 90".to_string(),
1500 },
1501 SvgPathMorphKeyframe {
1502 time: 1.0,
1503 value: "M80 90 L90 90".to_string(),
1504 },
1505 ],
1506 easing: EasingType::Linear,
1507 repeat: false,
1508 yoyo: false,
1509 }),
1510 }
1511 }
1512
1513 fn red_alpha_at(buf: &[u8], x: i32, y: i32) -> u8 {
1514 let idx = ((y * SIZE + x) * 4) as usize;
1515 if buf[idx] > 150 && buf[idx + 1] < 60 && buf[idx + 2] < 60 {
1516 buf[idx + 3]
1517 } else {
1518 0
1519 }
1520 }
1521
1522 #[test]
1523 fn path_morph_moves_the_targeted_path_and_leaves_the_other_path_alone() {
1524 let svg = two_path_svg();
1525 let start_buf = render_at(&svg, &AnimatedProperties::default(), 0.0);
1526 let end_buf = render_at(&svg, &AnimatedProperties::default(), 1.0);
1527
1528 assert!(
1529 red_alpha_at(&start_buf, 15, 90) > 40,
1530 "at t=0 the morphed (red) path must be at its first keyframe position"
1531 );
1532 assert_eq!(
1533 red_alpha_at(&start_buf, 85, 90),
1534 0,
1535 "and not yet at its last keyframe position"
1536 );
1537 assert!(
1538 red_alpha_at(&end_buf, 85, 90) > 40,
1539 "at t=1 the morphed (red) path must have reached its last keyframe position"
1540 );
1541 assert_eq!(
1542 red_alpha_at(&end_buf, 15, 90),
1543 0,
1544 "and left its first keyframe position"
1545 );
1546 }
1547
1548 #[test]
1549 fn a_target_with_no_matching_id_falls_back_to_the_original_document_instead_of_panicking() {
1550 let mut svg = two_path_svg();
1551 svg.path_morph = Some(SvgPathMorph {
1552 target: "does-not-exist".to_string(),
1553 keyframes: vec![
1554 SvgPathMorphKeyframe {
1555 time: 0.0,
1556 value: "M10 90 L20 90".to_string(),
1557 },
1558 SvgPathMorphKeyframe {
1559 time: 1.0,
1560 value: "M80 90 L90 90".to_string(),
1561 },
1562 ],
1563 easing: EasingType::Linear,
1564 repeat: false,
1565 yoyo: false,
1566 });
1567 let buf = render_at(&svg, &AnimatedProperties::default(), 0.5);
1568 assert!(
1569 red_alpha_at(&buf, 15, 90) > 40,
1570 "an unmatched target must render the untouched document rather than panic or \
1571 blank the frame"
1572 );
1573 }
1574}