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