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