From e9db7249a01ca78ea94f3b9e991301920ed9843a Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 27 Sep 2026 19:26:22 +0200 Subject: [PATCH] fix(svg,line): draw-on paints nothing at zero, and gets a font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **A dot at `draw_progress: 0`.** `Line::paint` built its paint and dash effect before checking progress, and Skia renders a zero-length dash with a round cap as a dot. It now returns before constructing either. **An svg's draw-on did not match its own finished mark.** Two faults in the segment loop: a leftover `set_stroke_width(sw / scale_avg)` cancelled the canvas scale that had already been applied, so the stroke stayed the same thickness at any node size while resvg's finished render scaled with it; and `stroke-linecap` and `stroke-linejoin` were never read off the SVG, so the last drawing frame's butt cap jumped to the finished frame's round one. **An svg's `` was never drawn, with nothing said.** `usvg::Options::default()` ships an *empty* `fontdb::Database`, and when text-to-path conversion finds no face, usvg drops the `` node from the tree entirely — a sibling `` renders fine and masks the loss. The parse options now share a process-wide `fontdb` initialised once with the system fonts. usvg appends a generic serif fallback to its own query, so once any system font is loaded even an unknown `font-family` resolves — to the wrong face, but visibly. What stays invisible is a host with no usable font at all, and for that `warn_on_unresolved_svg_text` compares the `` count in the source against the resolved count in the tree and says so once per distinct payload rather than once per frame. Measured end to end rather than on the tests' word: a 12px line at `draw_progress: 0.0` lights 0 pixels, and an `` carrying `SVG` paints 1962 dark pixels where it painted none. Closes #374 Closes #376 --- crates/rustmotion-components/src/line.rs | 112 ++++- crates/rustmotion-components/src/svg.rs | 407 ++++++++++++++++-- crates/rustmotion/skills/SKILL.md | 2 + .../skills/rules/draw-progress-stroke.md | 43 ++ .../rustmotion/skills/rules/svg-text-fonts.md | 69 +++ 5 files changed, 599 insertions(+), 34 deletions(-) create mode 100644 crates/rustmotion/skills/rules/draw-progress-stroke.md create mode 100644 crates/rustmotion/skills/rules/svg-text-fonts.md 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.