From 7ebfa86c0977fed3e5ff04ac15b27e7dc822b663 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 27 Sep 2026 17:43:40 +0200 Subject: [PATCH 1/2] fix(text): respect color alpha, rich_text whitespace/baseline, CSS gradient angle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three text components silently diverged from their own documented model: - text forced style.color's alpha to 1.0 after parsing it, so a translucent color rendered fully opaque. rich_text, backgrounds and borders already respected alpha; text was the outlier. Just stop clobbering it. - rich_text tokenized every span with split_whitespace(), which collapses runs of spaces to a single one — so white-space: pre (or nowrap) lost leading/internal spaces that the plain text component preserves. It also computed its baseline as (line_height + ascent) / 2, dropping descent, which text's formula includes; that alone put rich_text ~9px lower than an identical text at the same font size, so swapping one for the other visibly jumped. Pre/nowrap now keeps each span's text verbatim as a single token with no re-wrapping, and the baseline formula now matches text's. - gradient_text's default angle (90) rendered vertical because the angle was applied as (cos, sin) — a top/bottom axis — while CSS defines 90deg as "to right". It also always drew the gradient across the box diagonal regardless of angle, so a wide/short text sampled only the unsaturated middle of the ramp. angle now follows the CSS convention (0 = to top, 90 = to right — same meaning as view.background's linear-gradient) and the gradient line is the CSS box projection (|w*sin(a)| + |h*cos(a)|), so the first and last colors land exactly on the first and last glyph edges at any angle. This changes what the same angle number renders as; scenarios that set an explicit angle to work around the old axis need a look. A new optional `stops` field (color + position) lets a scenario place colors explicitly instead of the always-even default spacing, which stays the fallback when stops is omitted. Each fix has a pixel-level test that fails on the pre-fix code: - text::tests::a_translucent_color_alpha_channel_survives_to_the_painted_pixels - rich_text::tests::white_space_pre_keeps_leading_spaces_as_a_single_literal_token - rich_text::tests::white_space_pre_preserves_leading_spaces_at_the_pixel_level - rich_text::tests::baseline_offset_matches_the_text_components_formula - gradient_text::tests::default_angle_ramps_left_to_right_across_the_glyphs - gradient_text::tests::gradient_line_length_is_the_box_projection_not_the_diagonal - gradient_text::tests::explicit_stops_override_even_color_spacing --- .../src/gradient_text.rs | 289 ++++++++++++++++-- crates/rustmotion-components/src/intrinsic.rs | 3 + crates/rustmotion-components/src/rich_text.rs | 261 ++++++++++++++-- crates/rustmotion-components/src/text.rs | 30 +- .../skills/rules/text-component-parity.md | 47 +++ 5 files changed, 585 insertions(+), 45 deletions(-) create mode 100644 crates/rustmotion/skills/rules/text-component-parity.md diff --git a/crates/rustmotion-components/src/gradient_text.rs b/crates/rustmotion-components/src/gradient_text.rs index 15a9155..b9263b6 100644 --- a/crates/rustmotion-components/src/gradient_text.rs +++ b/crates/rustmotion-components/src/gradient_text.rs @@ -29,11 +29,23 @@ fn default_speed() -> f32 { 0.5 } +/// An explicit color stop on a `gradient_text` ramp. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct GradientTextStop { + /// Hex color at this stop, e.g. `"#7C3AED"`. + pub color: String, + /// Position along the gradient line, from `0.0` (first stop) to `1.0` (last). + pub position: f32, +} + #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct GradientText { pub content: String, #[serde(default = "default_colors")] pub colors: Vec, + /// Explicit stop positions along the ramp. When omitted, `colors` are spaced evenly. + #[serde(default)] + pub stops: Option>, #[serde(default = "default_angle")] pub angle: f32, #[serde(default)] @@ -87,7 +99,8 @@ impl GradientText { time: f64, ctx: &PaintCtx, ) { - if self.content.is_empty() || self.colors.is_empty() { + let has_stops = self.stops.as_ref().is_some_and(|s| !s.is_empty()); + if self.content.is_empty() || (self.colors.is_empty() && !has_stops) { return; } @@ -166,31 +179,44 @@ impl GradientText { }; let angle_rad = angle * std::f32::consts::PI / 180.0; + let (dir_x, dir_y) = (angle_rad.sin(), -angle_rad.cos()); let cx = block_x + text_w / 2.0; let cy = text_h / 2.0; - let half_diag = (text_w.powi(2) + text_h.powi(2)).sqrt() / 2.0; - let start = Point::new( - cx - angle_rad.cos() * half_diag, - cy - angle_rad.sin() * half_diag, - ); - let end = Point::new( - cx + angle_rad.cos() * half_diag, - cy + angle_rad.sin() * half_diag, - ); - - let skia_colors: Vec = self - .colors - .iter() - .map(|hex| { - let (r, g, b, a) = parse_hex_color(hex); - skia_safe::Color::from_argb(a, r, g, b) - }) - .collect(); + let half_len = ((text_w * angle_rad.sin()).abs() + (text_h * angle_rad.cos()).abs()) / 2.0; + let start = Point::new(cx - dir_x * half_len, cy - dir_y * half_len); + let end = Point::new(cx + dir_x * half_len, cy + dir_y * half_len); + + let (skia_colors, positions_vec): (Vec, Option>) = + if let Some(explicit_stops) = self.stops.as_ref().filter(|s| !s.is_empty()) { + let colors = explicit_stops + .iter() + .map(|stop| { + let (r, g, b, a) = parse_hex_color(&stop.color); + skia_safe::Color::from_argb(a, r, g, b) + }) + .collect(); + let positions = explicit_stops.iter().map(|stop| stop.position).collect(); + (colors, Some(positions)) + } else { + let colors = self + .colors + .iter() + .map(|hex| { + let (r, g, b, a) = parse_hex_color(hex); + skia_safe::Color::from_argb(a, r, g, b) + }) + .collect(); + (colors, None) + }; - let positions: Option<&[f32]> = None; let colors4f: Vec = skia_colors.iter().map(|c| Color4f::from(*c)).collect(); - let stops = Colors::new(&colors4f, positions, skia_safe::TileMode::Clamp, None); - let grad = Gradient::new(stops, gradient::Interpolation::default()); + let gradient_stops = Colors::new( + &colors4f, + positions_vec.as_deref(), + skia_safe::TileMode::Clamp, + None, + ); + let grad = Gradient::new(gradient_stops, gradient::Interpolation::default()); let shader = gradient::shaders::linear_gradient((start, end), &grad, None); let fill_paint = match shader { @@ -201,7 +227,14 @@ impl GradientText { p } None => { - let mut p = paint_from_hex(&self.colors[0]); + let fallback_hex = self + .stops + .as_ref() + .and_then(|s| s.first()) + .map(|stop| stop.color.as_str()) + .or_else(|| self.colors.first().map(String::as_str)) + .unwrap_or("#FFFFFF"); + let mut p = paint_from_hex(fallback_hex); p.set_anti_alias(true); p } @@ -257,6 +290,7 @@ mod tests { GradientText { content: content.into(), colors: default_colors(), + stops: None, angle: default_angle(), animate_angle: false, speed: default_speed(), @@ -332,6 +366,90 @@ mod tests { span } + fn ink_y_span(grid: &[u8], surface_width: i32, height: i32) -> Option<(i32, i32)> { + let mut span: Option<(i32, i32)> = None; + for y in 0..height { + for x in 0..surface_width { + if grid[(y * surface_width + x) as usize] > 0 { + span = Some(match span { + None => (y, y), + Some((lo, hi)) => (lo.min(y), hi.max(y)), + }); + } + } + } + span + } + + fn render_unpremul(gt: &GradientText, w: i32, h: i32) -> (Vec, Vec) { + let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface"); + gt.paint(surface.canvas(), w as f32, None, 0.0, &test_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]; + assert!(snapshot.read_pixels( + &info, + &mut buf, + (w * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + )); + let alpha: Vec = (0..(w * h) as usize).map(|i| buf[i * 4 + 3]).collect(); + (buf, alpha) + } + + fn best_pixel_in_column(buf: &[u8], alpha: &[u8], w: i32, h: i32, x: i32) -> (u8, u8, u8) { + let (mut best_y, mut best_a) = (0i32, 0u8); + for y in 0..h { + let a = alpha[(y * w + x) as usize]; + if a > best_a { + best_a = a; + best_y = y; + } + } + let i = ((best_y * w + x) * 4) as usize; + (buf[i], buf[i + 1], buf[i + 2]) + } + + fn best_pixel_in_row( + buf: &[u8], + alpha: &[u8], + w: i32, + y: i32, + x0: i32, + x1: i32, + ) -> (u8, u8, u8) { + let (mut best_x, mut best_a) = (x0, 0u8); + for x in x0..x1 { + let a = alpha[(y * w + x) as usize]; + if a > best_a { + best_a = a; + best_x = x; + } + } + let i = ((y * w + best_x) * 4) as usize; + (buf[i], buf[i + 1], buf[i + 2]) + } + + fn nearest_inked_column(alpha: &[u8], w: i32, h: i32, target: i32) -> i32 { + for radius in 0..w { + for cand in [target - radius, target + radius] { + if cand < 0 || cand >= w { + continue; + } + if (0..h).any(|y| alpha[(y * w + cand) as usize] > 0) { + return cand; + } + } + } + target + } + #[test] fn text_align_center_centres_the_line_in_the_box() { const W: i32 = 700; @@ -497,6 +615,7 @@ mod tests { let gt = GradientText { content: "HELLO".into(), colors: default_colors(), + stops: None, angle: default_angle(), animate_angle: false, speed: default_speed(), @@ -630,4 +749,128 @@ mod tests { "fixed content in a fixed box must render byte-identically regardless of ctx.time" ); } + + fn two_color_gradient(angle: f32) -> GradientText { + GradientText { + content: "IIIIIIIIIIIIIIII".into(), + colors: vec!["#FF0000".into(), "#0000FF".into()], + stops: None, + angle, + animate_angle: false, + speed: default_speed(), + timing: Default::default(), + style: CssStyle { + font_size: Some(Length::Px(120.0)), + ..Default::default() + }, + timeline: Vec::new(), + stagger: None, + } + } + + #[test] + fn default_angle_ramps_left_to_right_across_the_glyphs() { + let gt = two_color_gradient(default_angle()); + + const W: i32 = 1200; + const H: i32 = 300; + let (buf, alpha) = render_unpremul(>, W, H); + let (lo, hi) = ink_x_span(&alpha, W, H).expect("must paint some ink"); + + let left = best_pixel_in_column(&buf, &alpha, W, H, (lo + 2).min(hi)); + let right = best_pixel_in_column(&buf, &alpha, W, H, (hi - 2).max(lo)); + + assert!( + left.0 as i32 - left.2 as i32 > 60, + "with the default angle (CSS 90deg = to right), the left glyph edge should read \ + close to the first color (red), got {left:?}" + ); + assert!( + right.2 as i32 - right.0 as i32 > 60, + "with the default angle (CSS 90deg = to right), the right glyph edge should read \ + close to the last color (blue), got {right:?}" + ); + } + + #[test] + fn gradient_line_length_is_the_box_projection_not_the_diagonal() { + let gt = two_color_gradient(0.0); + + const W: i32 = 1200; + const H: i32 = 300; + let (buf, alpha) = render_unpremul(>, W, H); + let (top, bottom) = ink_y_span(&alpha, W, H).expect("must paint some ink"); + let (lo_x, hi_x) = ink_x_span(&alpha, W, H).expect("must paint some ink"); + + let top_row = (top + 3).min(bottom); + let bottom_row = (bottom - 3).max(top); + let top_px = best_pixel_in_row(&buf, &alpha, W, top_row, lo_x, hi_x + 1); + let bottom_px = best_pixel_in_row(&buf, &alpha, W, bottom_row, lo_x, hi_x + 1); + + assert!( + bottom_px.0 as i32 - bottom_px.2 as i32 > 60, + "angle: 0 (CSS 'to top') must put the first color at the bottom edge of the glyph \ + box, got {bottom_px:?}" + ); + assert!( + top_px.2 as i32 - top_px.0 as i32 > 60, + "angle: 0 (CSS 'to top') must reach the last color at the top edge — a gradient \ + line as long as the box diagonal would leave the glyphs sampling only the \ + unsaturated middle of the ramp, got {top_px:?}" + ); + } + + #[test] + fn explicit_stops_override_even_color_spacing() { + fn three_color_gradient(stops: Option>) -> GradientText { + GradientText { + content: "IIIIIIIIIIIIIIII".into(), + colors: vec!["#FF0000".into(), "#00FF00".into(), "#0000FF".into()], + stops, + angle: 90.0, + animate_angle: false, + speed: default_speed(), + timing: Default::default(), + style: CssStyle { + font_size: Some(Length::Px(120.0)), + ..Default::default() + }, + timeline: Vec::new(), + stagger: None, + } + } + + const W: i32 = 1200; + const H: i32 = 300; + + let even = three_color_gradient(None); + let (buf_even, alpha_even) = render_unpremul(&even, W, H); + let (lo, hi) = ink_x_span(&alpha_even, W, H).expect("must paint some ink"); + let mid_x = nearest_inked_column(&alpha_even, W, H, (lo + hi) / 2); + let mid_even = best_pixel_in_column(&buf_even, &alpha_even, W, H, mid_x); + + let skewed = three_color_gradient(Some(vec![ + GradientTextStop { + color: "#FF0000".into(), + position: 0.0, + }, + GradientTextStop { + color: "#00FF00".into(), + position: 0.9, + }, + GradientTextStop { + color: "#0000FF".into(), + position: 1.0, + }, + ])); + let (buf_skewed, alpha_skewed) = render_unpremul(&skewed, W, H); + let mid_skewed = best_pixel_in_column(&buf_skewed, &alpha_skewed, W, H, mid_x); + + assert!( + mid_even.1 as i32 > mid_skewed.1 as i32 + 40, + "moving the middle stop from its even-spacing position (0.5) to 0.9 must move the \ + ramp: the text's midpoint should read far less green than the even-spacing case \ + (even={mid_even:?}, skewed={mid_skewed:?})" + ); + } } diff --git a/crates/rustmotion-components/src/intrinsic.rs b/crates/rustmotion-components/src/intrinsic.rs index 858c1b6..3193f24 100644 --- a/crates/rustmotion-components/src/intrinsic.rs +++ b/crates/rustmotion-components/src/intrinsic.rs @@ -1013,6 +1013,7 @@ mod tests { let gt = GradientText { content: "the quick brown fox jumps over the lazy dog".into(), colors: vec!["#3B82F6".into(), "#8B5CF6".into()], + stops: None, angle: 90.0, animate_angle: false, speed: 0.5, @@ -1047,6 +1048,7 @@ mod tests { let gt = GradientText { content: "the quick brown fox jumps over the lazy dog".into(), colors: vec!["#3B82F6".into(), "#8B5CF6".into()], + stops: None, angle: 90.0, animate_angle: false, speed: 0.5, @@ -1586,6 +1588,7 @@ mod tests { let gt = GradientText { content: "Gradient".into(), colors: vec!["#3B82F6".into(), "#8B5CF6".into()], + stops: None, angle: 90.0, animate_angle: false, speed: 0.5, diff --git a/crates/rustmotion-components/src/rich_text.rs b/crates/rustmotion-components/src/rich_text.rs index 2b43419..6782eaa 100644 --- a/crates/rustmotion-components/src/rich_text.rs +++ b/crates/rustmotion-components/src/rich_text.rs @@ -3,7 +3,8 @@ use serde::{Deserialize, Serialize}; use skia_safe::{Canvas, Font, FontStyle}; use rustmotion_core::css::style::{ - FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw, TextAlign as CssTextAlign, + FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw, + TextAlign as CssTextAlign, WhiteSpace as CssWhiteSpace, }; use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::AnimatedProperties; @@ -136,6 +137,7 @@ pub struct RichTextLayout { pub max_width: f32, pub line_height: f32, pub max_ascent: f32, + pub max_descent: f32, } impl RichText { @@ -187,31 +189,52 @@ impl RichText { text: String, space_before: bool, } + let literal_whitespace = matches!( + style.white_space, + Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre) + ); let mut tokens: Vec = Vec::new(); - let mut prev_trailing_ws = true; - for (span_idx, text) in texts.iter().enumerate() { - if span_fonts.get(span_idx).and_then(|f| f.as_ref()).is_none() || text.is_empty() { - continue; - } - let starts_ws = text.chars().next().is_some_and(char::is_whitespace); - for (wi, w) in text.split_whitespace().enumerate() { - let space_before = if tokens.is_empty() { - false - } else if wi > 0 { - true - } else { - prev_trailing_ws || starts_ws - }; + if literal_whitespace { + for (span_idx, text) in texts.iter().enumerate() { + if span_fonts.get(span_idx).and_then(|f| f.as_ref()).is_none() || text.is_empty() { + continue; + } tokens.push(Tok { span_idx, - text: w.to_string(), - space_before, + text: text.clone(), + space_before: false, }); } - prev_trailing_ws = text.chars().last().is_none_or(char::is_whitespace); + } else { + let mut prev_trailing_ws = true; + for (span_idx, text) in texts.iter().enumerate() { + if span_fonts.get(span_idx).and_then(|f| f.as_ref()).is_none() || text.is_empty() { + continue; + } + let starts_ws = text.chars().next().is_some_and(char::is_whitespace); + for (wi, w) in text.split_whitespace().enumerate() { + let space_before = if tokens.is_empty() { + false + } else if wi > 0 { + true + } else { + prev_trailing_ws || starts_ws + }; + tokens.push(Tok { + span_idx, + text: w.to_string(), + space_before, + }); + } + prev_trailing_ws = text.chars().last().is_none_or(char::is_whitespace); + } } - let effective_wrap = wrap_width.unwrap_or(f32::INFINITY); + let effective_wrap = if literal_whitespace { + f32::INFINITY + } else { + wrap_width.unwrap_or(f32::INFINITY) + }; let mut lines: Vec = vec![RichTextLine { tokens: Vec::new(), width: 0.0, @@ -268,12 +291,21 @@ impl RichText { -m.ascent }) .fold(0.0f32, f32::max); + let max_descent = span_fonts + .iter() + .flatten() + .map(|sf| { + let (_, m) = sf.font.metrics(); + m.descent + }) + .fold(0.0f32, f32::max); RichTextLayout { lines, max_width, line_height: line_height_val, max_ascent, + max_descent, } } @@ -326,7 +358,7 @@ impl RichText { layout.max_width }; - let baseline_offset = (layout.line_height + layout.max_ascent) / 2.0; + let baseline_offset = (layout.line_height + layout.max_ascent - layout.max_descent) / 2.0; for (line_idx, line) in layout.lines.iter().enumerate() { let line_x_offset = match align { @@ -470,4 +502,193 @@ mod tests { assert_eq!(layout.lines.len(), 1); assert_eq!(layout.max_width, 0.0); } + + #[test] + fn white_space_pre_keeps_leading_spaces_as_a_single_literal_token() { + let spans = vec![span(" AB")]; + let mut s = style(20.0); + s.white_space = Some(CssWhiteSpace::Pre); + let layout = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, Some(80.0), -1.0); + + assert_eq!(layout.lines.len(), 1, "pre must not wrap onto extra lines"); + let tokens = &layout.lines[0].tokens; + assert_eq!( + tokens.len(), + 1, + "pre must not split the span into words, losing the run of spaces" + ); + assert_eq!( + tokens[0].text, " AB", + "white-space: pre must keep the leading spaces verbatim" + ); + } + + fn alpha_grid(surface: &mut skia_safe::Surface, width: i32, height: i32) -> Vec { + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (width, height), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let mut buf = vec![0u8; (width * height * 4) as usize]; + let ok = snapshot.read_pixels( + &info, + &mut buf, + (width * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + ); + assert!(ok, "pixel read should succeed"); + (0..(width * height) as usize) + .map(|i| buf[i * 4 + 3]) + .collect() + } + + fn min_ink_x(grid: &[u8], surface_width: i32, height: i32) -> Option { + for x in 0..surface_width { + for y in 0..height { + if grid[(y * surface_width + x) as usize] > 0 { + return Some(x); + } + } + } + None + } + + fn min_ink_y(grid: &[u8], surface_width: i32, height: i32) -> Option { + for y in 0..height { + for x in 0..surface_width { + if grid[(y * surface_width + x) as usize] > 0 { + return Some(y); + } + } + } + None + } + + fn test_ctx() -> PaintCtx { + PaintCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: 900, + video_height: 400, + stagger_offset: 0.0, + } + } + + #[test] + fn white_space_pre_preserves_leading_spaces_at_the_pixel_level() { + let plain = RichText { + spans: vec![span("AB")], + max_width: None, + timing: Default::default(), + style: style(60.0), + timeline: Vec::new(), + stagger: None, + }; + let padded = RichText { + spans: vec![span(" AB")], + max_width: None, + timing: Default::default(), + style: CssStyle { + white_space: Some(CssWhiteSpace::Pre), + ..style(60.0) + }, + timeline: Vec::new(), + stagger: None, + }; + + const W: i32 = 400; + const H: i32 = 150; + let ctx = test_ctx(); + let props = AnimatedProperties::default(); + + let plain_x = { + let mut surface = + skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + plain.paint(surface.canvas(), W as f32, &props, &ctx); + let grid = alpha_grid(&mut surface, W, H); + min_ink_x(&grid, W, H).expect("plain text paints") + }; + let padded_x = { + let mut surface = + skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + padded.paint(surface.canvas(), W as f32, &props, &ctx); + let grid = alpha_grid(&mut surface, W, H); + min_ink_x(&grid, W, H).expect("padded text paints") + }; + + assert!( + padded_x > plain_x + 20, + "white-space: pre must preserve the 4 leading spaces, shifting first ink right \ + (plain first ink at {plain_x}, padded first ink at {padded_x})" + ); + } + + #[test] + fn baseline_offset_matches_the_text_components_formula() { + let content = "Hamburgefonts"; + let font_px = 72.0; + let s = style(font_px); + let rt = RichText { + spans: vec![span(content)], + max_width: None, + timing: Default::default(), + style: s.clone(), + timeline: Vec::new(), + stagger: None, + }; + + const W: i32 = 900; + const H: i32 = 200; + let ctx = test_ctx(); + let props = AnimatedProperties::default(); + + let rich_text_top = { + let mut surface = + skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + rt.paint(surface.canvas(), W as f32, &props, &ctx); + let grid = alpha_grid(&mut surface, W, H); + min_ink_y(&grid, W, H).expect("rich_text must paint ink") + }; + + let typeface = + typeface_with_fallback("Inter", FontStyle::default()).expect("typeface resolves"); + let font = Font::from_typeface(typeface, font_px); + let (_, m) = font.metrics(); + let ascent = -m.ascent; + let descent = m.descent; + let base_ctx = crate::intrinsic::font_size_ctx(1920.0, 1080.0, 0.0); + let (_, _, line_height) = s.typography_px_ctx(&base_ctx, 48.0); + let expected_baseline = (line_height + ascent - descent) / 2.0; + + let reference_top = { + let mut surface = + skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + let paint = paint_from_hex("#FFFFFF"); + draw_text_with_fallback( + surface.canvas(), + content, + &font, + &None, + 0.0, + 0.0, + expected_baseline, + &paint, + ); + let grid = alpha_grid(&mut surface, W, H); + min_ink_y(&grid, W, H).expect("reference draw must paint ink") + }; + + assert!( + (rich_text_top - reference_top).abs() <= 1, + "rich_text's baseline must be computed as (line_height + ascent - descent) / 2, the \ + same formula text.rs uses — got top {rich_text_top}, expected {reference_top} \ + (formula gave baseline {expected_baseline})" + ); + } } diff --git a/crates/rustmotion-components/src/text.rs b/crates/rustmotion-components/src/text.rs index 5063c4b..b104612 100644 --- a/crates/rustmotion-components/src/text.rs +++ b/crates/rustmotion-components/src/text.rs @@ -523,8 +523,7 @@ impl Text { let font = Font::from_typeface(typeface, font_size); let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size)); - let mut paint = paint_from_hex(color); - paint.set_alpha_f(1.0); + let paint = paint_from_hex(color); let wrap_width = if nowrap { None } else { box_width }; @@ -1045,6 +1044,33 @@ mod tests { ); } + #[test] + fn a_translucent_color_alpha_channel_survives_to_the_painted_pixels() { + let mut text = make_text("A", Some(CssWhiteSpace::Nowrap)); + text.style.font_size = Some(Length::Px(120.0)); + text.style.color = Some(rustmotion_core::css::style::Color::String( + "#FFFFFF12".into(), + )); + + const W: i32 = 300; + const H: i32 = 200; + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + let canvas = surface.canvas(); + let ctx = test_ctx(); + let props = AnimatedProperties::default(); + text.paint(canvas, W as f32, None, 0.0, &props, &ctx) + .expect("paint succeeds"); + let grid = alpha_grid(&mut surface, W, H); + + let max_alpha = grid.iter().copied().max().unwrap_or(0); + assert!(max_alpha > 0, "the glyph must paint some ink"); + assert!( + max_alpha <= 40, + "style.color's alpha channel (0x12 = 18/255, about 7%) must survive to the painted \ + pixels instead of being forced fully opaque; got max alpha {max_alpha}" + ); + } + fn soft_pixel_fraction( grid: &[u8], surface_width: i32, diff --git a/crates/rustmotion/skills/rules/text-component-parity.md b/crates/rustmotion/skills/rules/text-component-parity.md new file mode 100644 index 0000000..0267b92 --- /dev/null +++ b/crates/rustmotion/skills/rules/text-component-parity.md @@ -0,0 +1,47 @@ +# Rule: text, rich_text et gradient_text alignés entre eux + +Trois composants texte qui divergaient silencieusement l'un de l'autre — même police, même taille, rendu différent. Les trois écarts ci-dessous sont corrigés ; ce qui suit documente le modèle correct pour générer avec. + +## text : l'alpha de style.color est respecté + +`style.color` avec un canal alpha (`#RRGGBBAA`) est rendu tel quel sur `text` — l'alpha n'est plus forcé à `255` (opaque) après coup. `rich_text`, les fonds et les bordures ont toujours respecté cet alpha ; `text` était l'exception. Le contournement historique (couleur opaque + `style.opacity` sur le nœud) n'est plus nécessaire : + +**GOOD:** +```json +{ "type": "text", "content": "echo", "style": { "font-size": 120, "color": "#FFFFFF12" } } +``` + +## rich_text : white-space: pre préserve les espaces, comme text + +`white-space: "pre"` (ou `"nowrap"`) sur `rich_text` garde le texte de chaque span **littéral** — espaces de tête, espaces multiples internes — au lieu de le retokeniser en mots séparés par un espace unique. C'est le même contrat que sur `text`. Sans `white-space: pre`/`nowrap`, le comportement historique (mots wrappables, espaces source collapsés à un seul) est inchangé. + +**GOOD** — indentation préservée pour un rendu façon terminal : +```json +{ "type": "rich_text", "spans": [{ "text": " $ npm install", "color": "#E5E7EB" }], + "style": { "font-size": 22, "white-space": "pre", "font-family": "monospace" } } +``` + +## rich_text : la ligne de base suit la même formule que text + +`rich_text` calcule maintenant son décalage de ligne de base comme `text` : `(line_height + ascent - descent) / 2`. Avant, `descent` n'entrait pas dans le calcul et un `rich_text` rendait plusieurs pixels plus bas qu'un `text` à police et taille identiques (~9px à 72px) — un `text` remplacé par un `rich_text` entre deux scènes, ou les deux posés côte à côte sur la même ligne, sautait visiblement. Aucun champ à changer côté JSON : c'est un correctif de rendu interne. + +## gradient_text : angle façon CSS (breaking change sur le défaut) + +`angle` suit désormais la convention CSS des `linear-gradient` — la même que `view.background` en `linear-gradient` : `0` = vers le haut, `90` = vers la droite, `180` = vers le bas, `270` = vers la gauche. La valeur par défaut reste `90` (donc horizontal gauche→droite), mais **avant ce correctif le même nombre produisait un dégradé tourné de 90° par rapport à cette convention** (l'ancien défaut de `90` rendait un dégradé vertical). Un scénario qui fixait `angle` explicitement pour compenser (typiquement `angle: 0` pour obtenir de l'horizontal) doit être revu : sous la nouvelle convention, `angle: 0` est désormais vertical. + +La ligne du dégradé est aussi recalculée : c'est la projection CSS de la boîte de texte sur l'axe choisi (`|w·sin θ| + |h·cos θ|`), plus la diagonale de la boîte. Avant, un texte large et court avec un dégradé vertical n'utilisait qu'une fraction centrale de la rampe (les glyphes lisaient une teinte quasi plate) ; désormais la première couleur touche le premier bord de glyphe et la dernière couleur le dernier bord, à tout angle. + +## gradient_text : stops explicites + +Nouveau champ optionnel `stops`, au même format que les stops de `view.background` en `linear-gradient` : + +```json +{ "type": "gradient_text", "content": "Rustmotion", "angle": 90, + "stops": [ + { "color": "#7C3AED", "position": 0 }, + { "color": "#EC4899", "position": 0.7 }, + { "color": "#F59E0B", "position": 1 } + ] } +``` + +Sans `stops`, `colors` reste réparti uniformément sur la ligne — comportement historique inchangé, `colors` seul continue de fonctionner tel quel. From fd043216a98d32820fcf07d1d4858838bf79eb84 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 27 Sep 2026 18:36:46 +0200 Subject: [PATCH 2/2] docs(skills): link the text parity rule into the index --- crates/rustmotion/skills/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 010e56e..1b07d68 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -238,6 +238,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/chromatic-aberration.md](rules/chromatic-aberration.md) - Per-element red/cyan fringe on arrival: `chromatic_aberration`'s `amount`, how its curve differs from `chromatic_wipe`'s, and the `amount`-not-`amplitude` trap - [rules/material-and-light.md](rules/material-and-light.md) - Lit surfaces: `style.material`'s three presets, the scene-wide `light` that makes them agree, and why the material follows the box and not a `shape`'s own geometry - [rules/depth-of-field.md](rules/depth-of-field.md) - Defocus by plane: `camera.focus`/`aperture` on the `style.depth` scale, rack focus by keyframe, and why nothing moves without distinct depths +- [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/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