Skip to main content

rustmotion_components/
svg.rs

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    /// Trace each path's outline progressively (current/legacy behavior).
23    #[default]
24    Stroke,
25    /// Sweep a mask across each path's full, already-painted shape (fills,
26    /// gradients included) instead of tracing a contour.
27    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    /// Take the draw-on path even outside an animation's own window. It needs a driver:
45    /// a `draw_in`/`stroke_reveal` preset or keyframes on `draw_progress`. On its own it
46    /// leaves `draw_progress` at rest, which paints the finished mark — `validate` rejects
47    /// that rather than let the flag look as though it did something.
48    #[serde(default)]
49    pub draw: bool,
50    /// Stroke width used when tracing fill-only paths (no stroke in the SVG).
51    #[serde(default = "default_draw_stroke_width")]
52    pub draw_stroke_width: f32,
53    /// Overlap factor between paths during draw-on animation.
54    /// 0.0 = strictly sequential (default); 1.0 = all paths drawn in parallel.
55    #[serde(default)]
56    pub draw_overlap: f32,
57    /// How draw-on animation reveals paths: `stroke` traces contours (default,
58    /// unchanged), `fill` sweeps a mask across each path's full painted shape.
59    #[serde(default)]
60    pub reveal: SvgReveal,
61    /// Animates a single path's `d` attribute inside this inline SVG document
62    /// between keyframe shapes. Independent of `draw`/`draw_progress`: it
63    /// changes the geometry that gets drawn, not how much of it is revealed.
64    #[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}