diff --git a/crates/rustmotion-components/src/line.rs b/crates/rustmotion-components/src/line.rs index 7d73a8a..310f50f 100644 --- a/crates/rustmotion-components/src/line.rs +++ b/crates/rustmotion-components/src/line.rs @@ -50,6 +50,11 @@ rustmotion_core::impl_traits!(Line { impl Line { fn paint(&self, canvas: &Canvas, props: &AnimatedProperties) { + let drawing = props.draw_progress >= 0.0 && props.draw_progress < 1.0; + if drawing && props.draw_progress <= 0.0 { + return; + } + let mut paint = paint_from_hex(&self.color); paint.set_style(PaintStyle::Stroke); paint.set_stroke_width(self.width); @@ -64,7 +69,7 @@ impl Line { } } - if props.draw_progress >= 0.0 && props.draw_progress < 1.0 { + if drawing { let dx = self.x2 - self.x1; let dy = self.y2 - self.y1; let length = (dx * dx + dy * dy).sqrt(); @@ -90,3 +95,108 @@ impl Painter for Line { self.paint(canvas, props); } } + +#[cfg(test)] +mod tests { + use super::*; + use rustmotion_core::engine::layout_pass::Insets; + + const W: i32 = 100; + const H: i32 = 100; + + fn test_layout() -> BoxLayout { + BoxLayout { + x: 0.0, + y: 0.0, + width: W as f32, + height: H as f32, + border: Insets::default(), + padding: Insets::default(), + } + } + + fn test_ctx() -> PaintCtx { + PaintCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: 1920, + video_height: 1080, + stagger_offset: 0.0, + } + } + + fn drawable_line() -> Line { + Line { + x1: 10.0, + y1: 50.0, + x2: 90.0, + y2: 50.0, + width: 12.0, + color: "#000000".to_string(), + dashed: None, + timing: Default::default(), + style: Default::default(), + timeline: Vec::new(), + stagger: None, + } + } + + fn lit_pixel_count(line: &Line, draw_progress: f32) -> usize { + let layout = test_layout(); + let props = AnimatedProperties { + draw_progress, + ..Default::default() + }; + let ctx = test_ctx(); + + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + line.paint_content(canvas, &layout, &props, &ctx); + } + + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (W, H), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (W * H * 4) as usize]; + let ok = snapshot.read_pixels( + &info, + &mut buf, + (W * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + ); + assert!(ok, "pixel read should succeed"); + + (0..(W * H) as usize) + .filter(|&i| buf[i * 4 + 3] > 10) + .count() + } + + #[test] + fn draw_progress_zero_paints_nothing() { + let line = drawable_line(); + let lit = lit_pixel_count(&line, 0.0); + assert_eq!( + lit, 0, + "at draw_progress=0 the line must not paint a zero-length dash as a dot, got {lit} lit pixels" + ); + } + + #[test] + fn draw_progress_partial_paints_a_partial_stroke() { + let line = drawable_line(); + let lit = lit_pixel_count(&line, 0.5); + assert!( + lit > 0, + "at draw_progress=0.5 the line must paint a partial stroke, got {lit} lit pixels" + ); + } +} diff --git a/crates/rustmotion-components/src/svg.rs b/crates/rustmotion-components/src/svg.rs index 5f48706..c77fb6a 100644 --- a/crates/rustmotion-components/src/svg.rs +++ b/crates/rustmotion-components/src/svg.rs @@ -1,7 +1,11 @@ +use std::collections::HashSet; +use std::sync::{Arc, Mutex, OnceLock}; + use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use skia_safe::{ - Canvas, ColorType, ImageInfo, Matrix, Paint, PaintStyle, Path, PathBuilder, PathMeasure, Rect, + Canvas, ColorType, ImageInfo, Matrix, Paint, PaintCap, PaintJoin, PaintStyle, Path, + PathBuilder, PathMeasure, Rect, }; use rustmotion_core::css::CssStyle; @@ -57,6 +61,85 @@ fn default_draw_stroke_width() -> f32 { 2.0 } +fn shared_svg_fontdb() -> Arc { + static DB: OnceLock> = OnceLock::new(); + DB.get_or_init(|| { + let mut db = usvg::fontdb::Database::new(); + db.load_system_fonts(); + Arc::new(db) + }) + .clone() +} + +fn svg_parse_options() -> usvg::Options<'static> { + usvg::Options { + fontdb: shared_svg_fontdb(), + ..Default::default() + } +} + +fn count_svg_text_elements(svg_data: &[u8]) -> usize { + let Ok(source) = std::str::from_utf8(svg_data) else { + return 0; + }; + let Ok(doc) = usvg::roxmltree::Document::parse(source) else { + return 0; + }; + doc.descendants() + .filter(|node| node.is_element() && node.tag_name().name() == "text") + .count() +} + +fn count_resolved_text_nodes(group: &usvg::Group) -> usize { + group.children().iter().fold(0, |count, node| { + count + + match node { + usvg::Node::Group(g) => count_resolved_text_nodes(g), + usvg::Node::Text(_) => 1, + _ => 0, + } + }) +} + +fn already_warned_svg_texts() -> &'static Mutex> { + static WARNED: OnceLock>> = OnceLock::new(); + WARNED.get_or_init(|| Mutex::new(HashSet::new())) +} + +fn svg_data_fingerprint(svg_data: &[u8]) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + svg_data.hash(&mut hasher); + hasher.finish() +} + +fn warn_on_unresolved_svg_text(svg_data: &[u8], tree: &usvg::Tree) { + let declared = count_svg_text_elements(svg_data); + if declared == 0 { + return; + } + + let resolved = count_resolved_text_nodes(tree.root()); + if resolved >= declared { + return; + } + + let fingerprint = svg_data_fingerprint(svg_data); + let mut warned = already_warned_svg_texts() + .lock() + .unwrap_or_else(|e| e.into_inner()); + if !warned.insert(fingerprint) { + return; + } + drop(warned); + + eprintln!( + "Warning: svg: {} of {} 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.", + declared - resolved, + declared + ); +} + rustmotion_core::impl_traits!(Svg { Animatable => animation, Timed => timing, @@ -97,11 +180,31 @@ fn tiny_path_to_skia(tsp: &tiny_skia::Path, abs_transform: tiny_skia::Transform) skia_path.detach() } -fn collect_paths( - group: &usvg::Group, - draw_stroke_width: f32, - out: &mut Vec<(Path, skia_safe::Color, f32)>, -) { +struct DrawSegment { + path: Path, + color: skia_safe::Color, + stroke_width: f32, + cap: PaintCap, + join: PaintJoin, +} + +fn to_skia_cap(cap: usvg::LineCap) -> PaintCap { + match cap { + usvg::LineCap::Butt => PaintCap::Butt, + usvg::LineCap::Round => PaintCap::Round, + usvg::LineCap::Square => PaintCap::Square, + } +} + +fn to_skia_join(join: usvg::LineJoin) -> PaintJoin { + match join { + usvg::LineJoin::Miter | usvg::LineJoin::MiterClip => PaintJoin::Miter, + usvg::LineJoin::Round => PaintJoin::Round, + usvg::LineJoin::Bevel => PaintJoin::Bevel, + } +} + +fn collect_paths(group: &usvg::Group, draw_stroke_width: f32, out: &mut Vec) { for node in group.children() { match node { usvg::Node::Group(g) => { @@ -112,7 +215,7 @@ fn collect_paths( continue; } let skia_path = tiny_path_to_skia(p.data(), p.abs_transform()); - let (color, sw) = if let Some(stroke) = p.stroke() { + let (color, stroke_width, cap, join) = if let Some(stroke) = p.stroke() { let sw = stroke.width().get(); let c = match stroke.paint() { usvg::Paint::Color(col) => { @@ -121,7 +224,12 @@ fn collect_paths( } _ => skia_safe::Color::WHITE, }; - (c, sw) + ( + c, + sw, + to_skia_cap(stroke.linecap()), + to_skia_join(stroke.linejoin()), + ) } else if let Some(fill) = p.fill() { let c = match fill.paint() { usvg::Paint::Color(col) => { @@ -130,11 +238,22 @@ fn collect_paths( } _ => skia_safe::Color::WHITE, }; - (c, draw_stroke_width) + (c, draw_stroke_width, PaintCap::Butt, PaintJoin::Miter) } else { - (skia_safe::Color::WHITE, draw_stroke_width) + ( + skia_safe::Color::WHITE, + draw_stroke_width, + PaintCap::Butt, + PaintJoin::Miter, + ) }; - out.push((skia_path, color, sw)); + out.push(DrawSegment { + path: skia_path, + color, + stroke_width, + cap, + join, + }); } _ => {} } @@ -261,10 +380,10 @@ fn paint_draw_on( ) { let progress = progress.clamp(0.0, 1.0); - let mut paths_with_colors: Vec<(Path, skia_safe::Color, f32)> = Vec::new(); - collect_paths(group, draw_stroke_width, &mut paths_with_colors); + let mut segments: Vec = Vec::new(); + collect_paths(group, draw_stroke_width, &mut segments); - if paths_with_colors.is_empty() { + if segments.is_empty() { return; } @@ -282,10 +401,10 @@ fn paint_draw_on( canvas.save(); canvas.scale((scale_x, scale_y)); - let lengths: Vec = paths_with_colors + let lengths: Vec = segments .iter() - .map(|(path, _, _)| { - let mut pm = PathMeasure::new(path, false, None); + .map(|segment| { + let mut pm = PathMeasure::new(&segment.path, false, None); pm.length() }) .collect(); @@ -299,7 +418,7 @@ fn paint_draw_on( let overlap = draw_overlap.clamp(0.0, 1.0); let mut cumulative = 0.0f32; - for ((path, color, sw), length) in paths_with_colors.iter().zip(lengths.iter()) { + for (segment, length) in segments.iter().zip(lengths.iter()) { let base_frac = length / total_length; let window_size = base_frac * (1.0 - overlap) + overlap; let start_frac = cumulative * (1.0 - overlap); @@ -307,12 +426,10 @@ fn paint_draw_on( let local_t = if window_size > 0.0 { ((progress - start_frac) / window_size).clamp(0.0, 1.0) + } else if progress >= start_frac { + 1.0 } else { - if progress >= start_frac { - 1.0 - } else { - 0.0 - } + 0.0 }; if local_t <= 0.0 { @@ -322,9 +439,11 @@ fn paint_draw_on( let draw_len = length * local_t; let mut paint = Paint::default(); - paint.set_color(*color); + paint.set_color(segment.color); paint.set_style(PaintStyle::Stroke); - paint.set_stroke_width(*sw); + paint.set_stroke_width(segment.stroke_width); + paint.set_stroke_cap(segment.cap); + paint.set_stroke_join(segment.join); paint.set_anti_alias(true); if local_t < 1.0 && draw_len > 0.0 { @@ -335,12 +454,7 @@ fn paint_draw_on( } } - let scale_avg = (scale_x * scale_y).sqrt(); - if scale_avg > 0.0 { - paint.set_stroke_width(sw / scale_avg); - } - - canvas.draw_path(path, &paint); + canvas.draw_path(&segment.path, &paint); } canvas.restore(); @@ -378,10 +492,11 @@ impl Painter for Svg { return; }; - let opt = usvg::Options::default(); + let opt = svg_parse_options(); let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) else { return; }; + warn_on_unresolved_svg_text(&svg_data, &tree); let svg_size = tree.size(); @@ -472,8 +587,9 @@ impl Svg { self.data.as_ref()?.as_bytes().to_vec() }; - let opt = usvg::Options::default(); + let opt = svg_parse_options(); let tree = usvg::Tree::from_data(&svg_data, &opt).ok()?; + warn_on_unresolved_svg_text(&svg_data, &tree); let svg_size = tree.size(); let target_w = target_w_opt.unwrap_or(svg_size.width() as u32); @@ -672,4 +788,229 @@ mod tests { "default stroke reveal must not fill the interior at partial progress, got alpha={a} at (50,50)" ); } + + fn line_stroke_svg(stroke_width: f32, cap: &str) -> Svg { + Svg { + src: None, + data: Some(format!( + r##""## + )), + timing: Default::default(), + style: Default::default(), + timeline: Vec::new(), + stagger: None, + draw: false, + draw_stroke_width: default_draw_stroke_width(), + draw_overlap: 0.0, + reveal: SvgReveal::Stroke, + } + } + + fn layout_for(size: f32) -> BoxLayout { + BoxLayout { + x: 0.0, + y: 0.0, + width: size, + height: size, + border: Insets::default(), + padding: Insets::default(), + } + } + + fn render_alpha_buffer( + svg: &Svg, + layout: &BoxLayout, + draw_progress: f32, + size: i32, + ) -> Vec { + let props = AnimatedProperties { + draw_progress, + ..Default::default() + }; + let ctx = test_ctx(); + + let mut surface = + skia_safe::surfaces::raster_n32_premul((size, size)).expect("raster surface"); + { + let canvas = surface.canvas(); + svg.paint_content(canvas, layout, &props, &ctx); + } + + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (size, size), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (size * size * 4) as usize]; + let ok = snapshot.read_pixels( + &info, + &mut buf, + (size * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + ); + assert!(ok, "pixel read should succeed"); + buf + } + + fn buffer_alpha_at(buf: &[u8], size: i32, x: i32, y: i32) -> u8 { + let idx = ((y * size + x) * 4 + 3) as usize; + buf[idx] + } + + fn vertical_ink_thickness(buf: &[u8], size: i32, x: i32) -> i32 { + (0..size) + .filter(|&y| buffer_alpha_at(buf, size, x, y) > 40) + .count() as i32 + } + + fn leftmost_ink_x(buf: &[u8], size: i32, y: i32) -> Option { + (0..size).find(|&x| buffer_alpha_at(buf, size, x, y) > 40) + } + + #[test] + fn draw_on_stroke_width_scales_with_the_nodes_transform_like_the_finished_render() { + let svg = line_stroke_svg(4.0, "butt"); + + let small_buf = render_alpha_buffer(&svg, &layout_for(100.0), 0.5, 100); + let small_thickness = vertical_ink_thickness(&small_buf, 100, 30); + + let large_buf = render_alpha_buffer(&svg, &layout_for(400.0), 0.5, 400); + let large_thickness = vertical_ink_thickness(&large_buf, 400, 120); + + assert!( + small_thickness > 0 && large_thickness > 0, + "both renders must show ink on the drawn segment, got small={small_thickness} large={large_thickness}" + ); + assert!( + (large_thickness as f32) > (small_thickness as f32) * 2.0, + "stroke width while drawing must scale with the node's viewBox transform \ + (4x layout should read ~4x thicker), got small={small_thickness}px large={large_thickness}px" + ); + } + + #[test] + fn draw_on_uses_the_finished_marks_stroke_cap() { + let svg = line_stroke_svg(16.0, "round"); + let layout = layout_for(100.0); + + let drawing_buf = render_alpha_buffer(&svg, &layout, 0.999, 100); + let drawing_left = leftmost_ink_x(&drawing_buf, 100, 50) + .expect("the almost-finished drawing frame must have ink on the line's row"); + + let finished_buf = render_alpha_buffer(&svg, &layout, 1.0, 100); + let finished_left = leftmost_ink_x(&finished_buf, 100, 50) + .expect("the finished frame must have ink on the line's row"); + + assert!( + (drawing_left - finished_left).abs() <= 2, + "the last drawing frame must reach as far left as the finished mark's round cap, \ + got drawing_left={drawing_left} finished_left={finished_left}" + ); + } + + fn text_svg(font_family: &str) -> Svg { + Svg { + src: None, + data: Some(format!( + r##"SVG"## + )), + timing: Default::default(), + style: Default::default(), + timeline: Vec::new(), + stagger: None, + draw: false, + draw_stroke_width: default_draw_stroke_width(), + draw_overlap: 0.0, + reveal: SvgReveal::Stroke, + } + } + + #[test] + fn svg_text_with_a_system_font_is_rasterized() { + let svg = text_svg("Helvetica"); + let layout = BoxLayout { + x: 0.0, + y: 0.0, + width: 300.0, + height: 100.0, + border: Insets::default(), + padding: Insets::default(), + }; + let props = AnimatedProperties::default(); + let ctx = test_ctx(); + + let mut surface = + skia_safe::surfaces::raster_n32_premul((300, 100)).expect("raster surface"); + { + let canvas = surface.canvas(); + svg.paint_content(canvas, &layout, &props, &ctx); + } + + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (300, 100), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (300 * 100 * 4) as usize]; + let ok = snapshot.read_pixels( + &info, + &mut buf, + (300 * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + ); + assert!(ok, "pixel read should succeed"); + + let dark_pixels = (0..100) + .flat_map(|y| (0..300).map(move |x| (x, y))) + .filter(|&(x, y)| { + let idx = ((y * 300 + x) * 4) as usize; + let (r, g, b, a) = (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]); + a > 200 && r < 60 && g < 60 && b < 60 + }) + .count(); + + assert!( + dark_pixels > 20, + "the element must be rasterized into dark pixels with a system font \ + available, got {dark_pixels} matching pixels" + ); + } + + #[test] + fn a_resolvable_font_family_matches_declared_and_resolved_text_counts() { + let svg = text_svg("Helvetica"); + let svg_data = svg.data.as_ref().unwrap().as_bytes().to_vec(); + let opt = svg_parse_options(); + let tree = usvg::Tree::from_data(&svg_data, &opt).expect("valid svg"); + + assert_eq!(count_svg_text_elements(&svg_data), 1); + assert_eq!( + count_resolved_text_nodes(tree.root()), + 1, + "Helvetica is a system font and must resolve once system fonts are loaded" + ); + } + + #[test] + fn an_empty_font_database_is_detected_as_undrawn_text() { + let svg = text_svg("Helvetica"); + let svg_data = svg.data.as_ref().unwrap().as_bytes().to_vec(); + let opt = usvg::Options::default(); + let tree = usvg::Tree::from_data(&svg_data, &opt).expect("valid svg"); + + assert_eq!(count_svg_text_elements(&svg_data), 1); + assert_eq!( + count_resolved_text_nodes(tree.root()), + 0, + "usvg::Options::default() carries an empty font database (the pre-fix behavior \ + behind issue #374): no can resolve, which is exactly the condition that \ + must trigger the stderr warning" + ); + } } diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index bef8eba..9ac7261 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -241,6 +241,8 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/text-component-parity.md](rules/text-component-parity.md) - Where `text`, `rich_text` and `gradient_text` disagreed: colour alpha, literal whitespace and baseline, the CSS angle convention and explicit `stops` - [rules/iris-transition.md](rules/iris-transition.md) - `iris` beyond a centred circle: `origin`, `shape`, `fill`+`hold`, `ring` and `reverse`, and the pill coverage approximation - [rules/whip-transition.md](rules/whip-transition.md) - The whip cut: a directional slide that streaks **both** frames along its axis, unlike `zoom_blur` which streaks only the outgoing one +- [rules/draw-progress-stroke.md](rules/draw-progress-stroke.md) - `draw_progress` at 0 paints nothing, and an `svg` draw-on matches the finished mark's stroke width, cap and join +- [rules/svg-text-fonts.md](rules/svg-text-fonts.md) - Why `` inside an `svg` needs a resolvable font face, and what happens when the host has none - [rules/geometry-safety.md](rules/geometry-safety.md) - Keep all content inside the viewport: `white-space`, `auto_scroll`, `overflow` semantics + violation kinds - [rules/clip-path.md](rules/clip-path.md) - Non-rectangular masking: the six `clip-path` shapes, how their percentages resolve, and why `node-path` is not one of them yet - [rules/overlapping-scenes.md](rules/overlapping-scenes.md) - Make an element outlive a cut: overlapping `at` windows composite instead of replacing, who supplies the background, and why `snap` never creates an overlap diff --git a/crates/rustmotion/skills/rules/draw-progress-stroke.md b/crates/rustmotion/skills/rules/draw-progress-stroke.md new file mode 100644 index 0000000..439ec4d --- /dev/null +++ b/crates/rustmotion/skills/rules/draw-progress-stroke.md @@ -0,0 +1,43 @@ +# `draw_progress` : rien à 0, et un trait qui ne change pas d'apparence en finissant + +`draw_progress` (via l'animation `keyframes` sur la propriété du même nom, ou +`draw: true` sur `svg`) révèle un trait progressivement. Deux pièges à +connaître sur `line` et `svg` (`reveal: "stroke"`, celui par défaut). + +## `line` : `draw_progress: 0` ne doit rien peindre + +`Line::paint` force un cap arrondi (`PaintCap::Round`) et construit un +pointillé `[longueur_dessinée, reste]` pour révéler le trait. À +`draw_progress: 0`, `longueur_dessinée` vaut `0` — un pointillé de longueur +nulle avec un cap arrondi se peint quand même : Skia dessine un point plein +d'un diamètre égal à `width`, exactement au point de départ. Le composant +retourne maintenant sans rien peindre dès que `draw_progress <= 0` (dans la +fenêtre `[0, 1[` — `draw_progress` absent ou `>= 1` reste le trait complet, +inchangé). + +## `svg` en train de se dessiner doit ressembler au trait fini + +Pendant le tracé (`paint_draw_on`, `draw_progress` dans `]0, 1[`), le trait +doit avoir la **même** épaisseur, le même `stroke-linecap` et le même +`stroke-linejoin` que le rendu final (`progress >= 1`, peint par `resvg`) — +sinon la dernière frame du tracé et la première frame « finie » ne se +raccordent pas visuellement (saut d'épaisseur, apparition brusque d'un cap). + +Concrètement : + +- Le canevas est déjà mis à l'échelle du `viewBox` vers la taille du nœud + (`canvas.scale((scale_x, scale_y))`) avant de peindre chaque segment : le + `stroke-width` du SVG source doit être posé tel quel sur le `Paint`, sans + compensation supplémentaire. Diviser par le facteur d'échelle annule cette + mise à l'échelle et fige le trait à sa largeur SVG brute, quelle que soit + la taille du nœud — le bug qu'un remaniement futur ne doit pas + réintroduire. +- `stroke-linecap`/`stroke-linejoin` du `` source (lus sur + `usvg::Stroke`) doivent être posés sur le `Paint` de chaque segment, pas + seulement utilisés pour le rendu final. Un cap `round` sur le trait fini + mais `butt` (le défaut de Skia) pendant le tracé fait apparaître le cap + d'un coup à `draw_progress = 1`, avec une extension visible du trait + (le rayon du cap). + +`marquee` et `cursor` restent hors sujet ici : ce ne sont pas des traits +révélés par `draw_progress`. diff --git a/crates/rustmotion/skills/rules/svg-text-fonts.md b/crates/rustmotion/skills/rules/svg-text-fonts.md new file mode 100644 index 0000000..618368b --- /dev/null +++ b/crates/rustmotion/skills/rules/svg-text-fonts.md @@ -0,0 +1,69 @@ +# `svg` : le `` a besoin d'une police résolue + +Un `` dans un `svg` (`data` ou `src`) n'est jamais peint directement : +`usvg` le convertit en tracés de glyphes **au moment du parsing**, avant même +que `resvg` ne peigne quoi que ce soit. Cette conversion a besoin d'une police +présente dans sa base de polices — sans elle, l'élément est retiré de l'arbre +en silence, comme s'il n'avait jamais existé. Un `` ou un `` du +même document continue de se peindre normalement : rien ne signale que le +texte, lui, a disparu. + +## La base de polices système est chargée + +Le composant construit ses `usvg::Options` avec une base de polices système +(`fontdb::Database::load_system_fonts()`, chargée une seule fois par +processus et partagée entre tous les nœuds `svg`), ce qui couvre tout +`font-family` correspondant à une police déjà installée sur la machine +(`Helvetica`, `Arial`, la police système par défaut…) — le même principe que +ce que `text` fait déjà via le `FontMgr` de Skia. + +## Ce que ça ne couvre pas + +Cette base de polices système est **distincte** du registre de polices +personnalisées de rustmotion (`fonts:` au niveau du scénario, source +`"google"` ou `path` local) : ce registre alimente le `FontMgr` de Skia pour +le composant `text`, pas le `fontdb` d'`usvg`. Un `` de `svg` qui +référence une police déclarée dans `fonts:` mais absente du système ne +résout donc pas vers la bonne police — ce n'est pas un oubli d'implémentation +ponctuel, les deux composants s'appuient sur deux moteurs de police +complètement séparés. + +Ça ne le fait *pas* disparaître pour autant : `usvg` ajoute toujours une +police générique (`serif`) en bout de la liste de recherche, donc tant que le +`fontdb` contient au moins une police quelconque, le texte se peint — avec la +police système par défaut à la place de celle demandée, pas dans le vide. +C'est un défaut visuel (mauvaise police), pas la disparition totale +qu'était le bug original. + +Le cas encore silencieux est plus étroit : une machine de rendu sans **aucune** +police installée nulle part (un conteneur headless minimal, par exemple), où +même le repli générique n'a rien vers quoi se rabattre. Dans ce cas précis, le +composant ne reste plus silencieux : il compare le nombre de `` présents +dans le SVG source au nombre de nœuds texte effectivement résolus par `usvg`, +et si l'un d'eux a été perdu, il écrit sur stderr : + +``` +Warning: svg: 1 of 1 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. +``` + +Le message ne se répète pas à chaque frame pendant le rendu : il est déduit +une seule fois par contenu SVG distinct. + +## Contournement + +Pour obtenir la police exacte demandée par un `` de `svg` (pas un +repli), le contournement actuel est d'installer cette police sur la machine +de rendu, ou de convertir le texte en tracés avant de l'injecter dans le SVG +(export « outline » depuis l'éditeur vectoriel — le texte devient alors un +`` ordinaire, qui n'a besoin d'aucune police). Faire résoudre `fonts:` +par `usvg` demanderait de faire transiter le registre de polices +personnalisées de `text` (`rustmotion-core::engine::renderer::fonts`) jusqu'au +`fontdb` du composant `svg` — en dehors du périmètre de ce correctif. + +`usvg` journalise déjà en interne (`log::warn!`) chaque `font-family` sans +correspondance exacte avant de retomber sur le générique, mais rien dans le +binaire `rustmotion` n'installe de backend pour la crate `log` : ces traces +existent mais n'atteignent jamais stderr aujourd'hui.