From 22a70e44211ea1a27f87476118656c16287d3396 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 30 Sep 2026 17:52:28 +0200 Subject: [PATCH] fix(text): position glyphs at their fractional coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Font was built with Skia's defaults, which place glyphs on whole device pixels: subpixel positioning off, baseline snapping on. The box around a text moved by hundredths of a pixel, the glyphs inside it advanced in 1px jumps, so a label visibly slid against its own container under any slow motion — a wiggle of a few pixels, a float loop, a camera push-in. Measured on the reported scenario, the text centroid strayed 0.690px from a 3px linear travel while the bar sharing its translated box tracked it to the hundredth. `subpixel_font` is now the only constructor, and the 107 direct `Font::from_typeface` calls across 37 files go through it, so a new component cannot reintroduce the default by writing the obvious thing. The residue is 0.153px, which is Skia quantising subpixel positions to quarter-pixels rather than snapping. One test had to change its measurement. `centring_keeps_the_gradient _over_the_glyphs` sampled the rightmost ink column and compared premultiplied channels; that column is now an antialiased fringe rather than a hard edge, so the same violet read (33, 22, 59) through alpha 61 instead of (139, 92, 246). It now reads a covered column and unpremultiplies, which is what it meant to compare. Closes #456 --- .../rustmotion-components/src/avatar_group.rs | 7 +- crates/rustmotion-components/src/badge.rs | 14 +- .../rustmotion-components/src/box_builder.rs | 6 +- crates/rustmotion-components/src/callout.rs | 6 +- crates/rustmotion-components/src/caption.rs | 8 +- .../rustmotion-components/src/chart/axes.rs | 8 +- crates/rustmotion-components/src/chart/bar.rs | 4 +- .../rustmotion-components/src/chart/funnel.rs | 7 +- crates/rustmotion-components/src/chart/mod.rs | 7 +- .../rustmotion-components/src/chart/radar.rs | 4 +- .../rustmotion-components/src/comparison.rs | 6 +- crates/rustmotion-components/src/countdown.rs | 6 +- crates/rustmotion-components/src/counter.rs | 8 +- crates/rustmotion-components/src/dot_map.rs | 12 +- crates/rustmotion-components/src/gauge.rs | 12 +- .../src/gradient_text.rs | 34 +++-- crates/rustmotion-components/src/intrinsic.rs | 10 +- crates/rustmotion-components/src/kbd.rs | 6 +- crates/rustmotion-components/src/list.rs | 9 +- crates/rustmotion-components/src/marquee.rs | 6 +- .../rustmotion-components/src/number_wheel.rs | 7 +- crates/rustmotion-components/src/pill_nav.rs | 11 +- crates/rustmotion-components/src/progress.rs | 7 +- crates/rustmotion-components/src/rich_text.rs | 14 +- crates/rustmotion-components/src/shape.rs | 8 +- crates/rustmotion-components/src/slider.rs | 13 +- crates/rustmotion-components/src/stat.rs | 18 ++- crates/rustmotion-components/src/stepper.rs | 17 +-- crates/rustmotion-components/src/switch.rs | 11 +- crates/rustmotion-components/src/table.rs | 10 +- crates/rustmotion-components/src/tag_cloud.rs | 12 +- crates/rustmotion-components/src/text.rs | 8 +- crates/rustmotion-components/src/timeline.rs | 10 +- crates/rustmotion-components/src/tooltip.rs | 6 +- crates/rustmotion-components/src/treemap.rs | 7 +- .../src/engine/renderer/fonts.rs | 10 +- .../src/engine/renderer/text.rs | 16 ++- crates/rustmotion/CLAUDE.md | 7 + crates/rustmotion/src/cli/commands/sheet.rs | 3 +- .../rustmotion/tests/text_subpixel_motion.rs | 125 ++++++++++++++++++ 40 files changed, 310 insertions(+), 190 deletions(-) create mode 100644 crates/rustmotion/tests/text_subpixel_motion.rs diff --git a/crates/rustmotion-components/src/avatar_group.rs b/crates/rustmotion-components/src/avatar_group.rs index ef86b98..1851f99 100644 --- a/crates/rustmotion-components/src/avatar_group.rs +++ b/crates/rustmotion-components/src/avatar_group.rs @@ -8,7 +8,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ asset_cache, draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, - paint_from_hex, typeface_with_fallback, + paint_from_hex, subpixel_font, typeface_with_fallback, }; use rustmotion_core::error::RustmotionError; use rustmotion_core::schema::TimelineStep; @@ -167,9 +167,8 @@ impl AvatarGroup { let Ok(typeface) = typeface_with_fallback("Inter", font_style) else { return Ok(()); }; - let font = skia_safe::Font::from_typeface(typeface, font_size); - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let mut text_paint = paint_from_hex("#D1D5DB"); text_paint.set_anti_alias(true); diff --git a/crates/rustmotion-components/src/badge.rs b/crates/rustmotion-components/src/badge.rs index bcc52eb..f0c8823 100644 --- a/crates/rustmotion-components/src/badge.rs +++ b/crates/rustmotion-components/src/badge.rs @@ -8,7 +8,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ asset_cache, draw_text_with_fallback, emoji_typeface, fetch_icon_svg, - measure_text_with_fallback, paint_from_hex, typeface_with_fallback, + measure_text_with_fallback, paint_from_hex, subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -134,10 +134,7 @@ impl Badge { let font_style = skia_safe::FontStyle::normal(); let family = self.style.font_family.as_deref().unwrap_or("Inter"); let typeface = typeface_with_fallback(family, font_style).ok()?; - Some(skia_safe::Font::from_typeface( - typeface, - self.resolved_font_size(ctx), - )) + Some(subpixel_font(typeface, self.resolved_font_size(ctx))) } } @@ -239,7 +236,7 @@ impl Badge { return; }; let font_size = self.resolved_font_size(ctx); - let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let mut text_paint = paint_from_hex(text_color); text_paint.set_anti_alias(true); @@ -298,9 +295,8 @@ impl Badge { else { return; }; - let count_font = skia_safe::Font::from_typeface(count_typeface, count_fs); - let count_emoji = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, count_fs)); + let count_font = subpixel_font(count_typeface, count_fs); + let count_emoji = emoji_typeface().map(|tf| subpixel_font(tf, count_fs)); let count_w = measure_text_with_fallback(&count_text, &count_font, &count_emoji, 0.0); let badge_pad = count_fs * 0.4; diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index 408d1c6..d698635 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -1289,7 +1289,7 @@ fn apply_default_display(component: &Component, css: &mut CssStyle) { fn measure_text_line_width(text: &str, font_size: f32, family: &str, bold: bool) -> f32 { use rustmotion_core::engine::renderer::{ - emoji_typeface, measure_text_with_fallback, typeface_with_fallback, + emoji_typeface, measure_text_with_fallback, subpixel_font, typeface_with_fallback, }; let style = if bold { skia_safe::FontStyle::bold() @@ -1299,8 +1299,8 @@ fn measure_text_line_width(text: &str, font_size: f32, family: &str, bold: bool) let Ok(typeface) = typeface_with_fallback(family, style) else { return 0.0; }; - let font = skia_safe::Font::from_typeface(typeface, font_size); - let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); measure_text_with_fallback(text, &font, &emoji_font, 0.0) } diff --git a/crates/rustmotion-components/src/callout.rs b/crates/rustmotion-components/src/callout.rs index 3b75686..6f480d3 100644 --- a/crates/rustmotion-components/src/callout.rs +++ b/crates/rustmotion-components/src/callout.rs @@ -6,7 +6,9 @@ use skia_safe::{Canvas, PaintStyle, Path, PathBuilder, RRect, Rect}; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::engine::renderer::{paint_from_hex, typeface_with_fallback, wrap_text}; +use rustmotion_core::engine::renderer::{ + paint_from_hex, subpixel_font, typeface_with_fallback, wrap_text, +}; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -157,7 +159,7 @@ impl Callout { let family = self.style.font_family.as_deref().unwrap_or("Inter"); let typeface = typeface_with_fallback(family, font_style)?; - let font = skia_safe::Font::from_typeface(typeface, font_size); + let font = subpixel_font(typeface, font_size); let (_, metrics) = font.metrics(); let ascent = -metrics.ascent; let line_height = font_size * 1.4; diff --git a/crates/rustmotion-components/src/caption.rs b/crates/rustmotion-components/src/caption.rs index 23f2976..be4764e 100644 --- a/crates/rustmotion-components/src/caption.rs +++ b/crates/rustmotion-components/src/caption.rs @@ -1,6 +1,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use skia_safe::{Canvas, Font, FontStyle, Rect}; +use skia_safe::{Canvas, FontStyle, Rect}; use rustmotion_core::css::style::{ FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw, @@ -12,7 +12,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - typeface_with_fallback, + subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::{CaptionStyle, CaptionWord, TimelineStep}; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -77,8 +77,8 @@ impl Caption { return; }; - let font = Font::from_typeface(typeface, font_size); - let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let top_offset = font_size * 1.2; canvas.save(); diff --git a/crates/rustmotion-components/src/chart/axes.rs b/crates/rustmotion-components/src/chart/axes.rs index cc1855d..f11c576 100644 --- a/crates/rustmotion-components/src/chart/axes.rs +++ b/crates/rustmotion-components/src/chart/axes.rs @@ -2,7 +2,7 @@ use skia_safe::{Canvas, PaintStyle}; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - parse_hex_color, + parse_hex_color, subpixel_font, }; use super::Chart; @@ -45,8 +45,7 @@ impl Chart { let Some(font) = self.make_label_font() else { return; }; - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, self.label_font_size)); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, self.label_font_size)); let (_, metrics) = font.metrics(); let ascent = -metrics.ascent; @@ -133,8 +132,7 @@ impl Chart { let Some(font) = self.make_label_font() else { return; }; - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, self.label_font_size)); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, self.label_font_size)); let (_, metrics) = font.metrics(); let ascent = -metrics.ascent; diff --git a/crates/rustmotion-components/src/chart/bar.rs b/crates/rustmotion-components/src/chart/bar.rs index 76d17cc..ba07b2c 100644 --- a/crates/rustmotion-components/src/chart/bar.rs +++ b/crates/rustmotion-components/src/chart/bar.rs @@ -3,6 +3,7 @@ use skia_safe::{Canvas, PaintStyle, Rect}; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, + subpixel_font, }; use super::axes::{contrast_text_color, format_number}; @@ -84,8 +85,7 @@ impl Chart { let Some(font) = self.make_label_font() else { return Ok(()); }; - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, self.label_font_size)); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, self.label_font_size)); let (_, metrics) = font.metrics(); let ascent = -metrics.ascent; let measure = |s: &str| measure_text_with_fallback(s, &font, &emoji_font, 0.0); diff --git a/crates/rustmotion-components/src/chart/funnel.rs b/crates/rustmotion-components/src/chart/funnel.rs index a86b76e..ee704f0 100644 --- a/crates/rustmotion-components/src/chart/funnel.rs +++ b/crates/rustmotion-components/src/chart/funnel.rs @@ -3,6 +3,7 @@ use skia_safe::{Canvas, PaintStyle, PathBuilder}; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, + subpixel_font, }; use super::axes::contrast_text_color; @@ -41,8 +42,7 @@ impl Chart { let Some(font) = self.make_label_font() else { return Ok(()); }; - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, self.label_font_size)); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, self.label_font_size)); let (_, metrics) = font.metrics(); let ascent = -metrics.ascent; @@ -121,8 +121,7 @@ impl Chart { let Some(font) = self.make_label_font() else { return Ok(()); }; - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, self.label_font_size)); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, self.label_font_size)); let (_, metrics) = font.metrics(); let ascent = -metrics.ascent; diff --git a/crates/rustmotion-components/src/chart/mod.rs b/crates/rustmotion-components/src/chart/mod.rs index eb09797..51766c2 100644 --- a/crates/rustmotion-components/src/chart/mod.rs +++ b/crates/rustmotion-components/src/chart/mod.rs @@ -6,7 +6,7 @@ use skia_safe::Canvas; use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::engine::renderer::typeface_with_fallback; +use rustmotion_core::engine::renderer::{subpixel_font, typeface_with_fallback}; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -298,10 +298,7 @@ impl Chart { pub(super) fn make_label_font(&self) -> Option { let font_style = skia_safe::FontStyle::normal(); let typeface = typeface_with_fallback("Inter", font_style).ok()?; - Some(skia_safe::Font::from_typeface( - typeface, - self.label_font_size, - )) + Some(subpixel_font(typeface, self.label_font_size)) } } diff --git a/crates/rustmotion-components/src/chart/radar.rs b/crates/rustmotion-components/src/chart/radar.rs index bce8f5f..acd37f4 100644 --- a/crates/rustmotion-components/src/chart/radar.rs +++ b/crates/rustmotion-components/src/chart/radar.rs @@ -3,6 +3,7 @@ use skia_safe::{Canvas, PaintStyle, PathBuilder}; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, + subpixel_font, }; use super::Chart; @@ -66,8 +67,7 @@ impl Chart { let Some(font) = self.make_label_font() else { return Ok(()); }; - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, self.label_font_size)); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, self.label_font_size)); let (_, metrics) = font.metrics(); let ascent = -metrics.ascent; diff --git a/crates/rustmotion-components/src/comparison.rs b/crates/rustmotion-components/src/comparison.rs index 7868ae7..06e0e20 100644 --- a/crates/rustmotion-components/src/comparison.rs +++ b/crates/rustmotion-components/src/comparison.rs @@ -7,7 +7,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - parse_hex_color, typeface_with_fallback, + parse_hex_color, subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -140,8 +140,8 @@ impl Comparison { canvas.restore(); return; }; - let font = skia_safe::Font::from_typeface(typeface, font_size); - let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let mut label_paint = paint_from_hex("#FFFFFF"); label_paint.set_anti_alias(true); diff --git a/crates/rustmotion-components/src/countdown.rs b/crates/rustmotion-components/src/countdown.rs index 8247844..482ea22 100644 --- a/crates/rustmotion-components/src/countdown.rs +++ b/crates/rustmotion-components/src/countdown.rs @@ -8,7 +8,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - parse_hex_color, typeface_with_fallback, + parse_hex_color, subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -288,8 +288,8 @@ impl Countdown { let Ok(typeface) = typeface_with_fallback("Inter", font_style) else { return; }; - let font = skia_safe::Font::from_typeface(typeface, font_size); - let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let (box_w, _box_h) = self.digit_box_size(); diff --git a/crates/rustmotion-components/src/counter.rs b/crates/rustmotion-components/src/counter.rs index 4d14e40..91f1088 100644 --- a/crates/rustmotion-components/src/counter.rs +++ b/crates/rustmotion-components/src/counter.rs @@ -1,7 +1,7 @@ use rustmotion_core::error::Result; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use skia_safe::{Canvas, Font, FontStyle, PaintStyle}; +use skia_safe::{Canvas, FontStyle, PaintStyle}; use rustmotion_core::css::style::{ FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw, TextAlign as CssTextAlign, @@ -11,7 +11,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, format_counter_value, measure_text_with_fallback, - paint_from_hex, typeface_with_fallback, + paint_from_hex, subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::{ EasingType, FontStyleType, FontWeight, Stroke, TextAlign, TextShadow, TimelineStep, @@ -147,8 +147,8 @@ impl Counter { let typeface = typeface_with_fallback(font_family, skia_font_style)?; - let font = Font::from_typeface(typeface, font_size); - let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let mut paint = paint_from_hex(color); paint.set_alpha_f(1.0); diff --git a/crates/rustmotion-components/src/dot_map.rs b/crates/rustmotion-components/src/dot_map.rs index 5d2a1ad..e679e9b 100644 --- a/crates/rustmotion-components/src/dot_map.rs +++ b/crates/rustmotion-components/src/dot_map.rs @@ -7,7 +7,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - typeface_with_fallback, + subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -435,9 +435,8 @@ impl DotMap { let Ok(typeface) = typeface_with_fallback("Inter", font_style) else { return; }; - let label_font = skia_safe::Font::from_typeface(typeface, label_font_size); - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, label_font_size)); + let label_font = subpixel_font(typeface, label_font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, label_font_size)); for (i, point) in self.points.iter().enumerate() { let dot_alpha = if self.animated { @@ -626,9 +625,8 @@ impl DotMap { let Ok(typeface) = typeface_with_fallback("Inter", font_style) else { return; }; - let label_font = skia_safe::Font::from_typeface(typeface, label_font_size); - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, label_font_size)); + let label_font = subpixel_font(typeface, label_font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, label_font_size)); for (i, point) in self.points.iter().enumerate() { let dot_alpha = if self.animated { diff --git a/crates/rustmotion-components/src/gauge.rs b/crates/rustmotion-components/src/gauge.rs index 835039b..bbeb932 100644 --- a/crates/rustmotion-components/src/gauge.rs +++ b/crates/rustmotion-components/src/gauge.rs @@ -7,7 +7,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - typeface_with_fallback, + subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -150,9 +150,8 @@ impl Gauge { let Ok(typeface) = typeface_with_fallback("Inter", font_style) else { return; }; - let font = skia_safe::Font::from_typeface(typeface, font_size); - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let mut text_paint = paint_from_hex(&self.fill_color); text_paint.set_anti_alias(true); @@ -180,9 +179,8 @@ impl Gauge { let Ok(typeface) = typeface_with_fallback("Inter", font_style) else { return; }; - let font = skia_safe::Font::from_typeface(typeface, font_size); - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let mut label_paint = paint_from_hex("#888888"); label_paint.set_anti_alias(true); diff --git a/crates/rustmotion-components/src/gradient_text.rs b/crates/rustmotion-components/src/gradient_text.rs index b37d885..1f404e2 100644 --- a/crates/rustmotion-components/src/gradient_text.rs +++ b/crates/rustmotion-components/src/gradient_text.rs @@ -1,7 +1,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use skia_safe::gradient::{self, Colors, Gradient}; -use skia_safe::{Canvas, Color4f, Font, FontStyle, Point}; +use skia_safe::{Canvas, Color4f, FontStyle, Point}; use rustmotion_core::css::style::{ FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw, @@ -12,7 +12,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - parse_hex_color, typeface_with_fallback, wrap_text_with_tracking, + parse_hex_color, subpixel_font, typeface_with_fallback, wrap_text_with_tracking, }; use rustmotion_core::schema::{TextAlign, TimelineStep}; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -148,8 +148,8 @@ impl GradientText { line_height_val = lh; } - let font = Font::from_typeface(typeface, font_size); - let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let lines = wrap_text_with_tracking(&self.content, &font, &emoji_font, wrap_at, letter_spacing); @@ -613,16 +613,22 @@ mod tests { )); let alpha: Vec = (0..(W * H) as usize).map(|i| buf[i * 4 + 3]).collect(); let (_, hi) = ink_x_span(&alpha, W, H).expect("must paint"); - let (mut best_y, mut best_a) = (0i32, 0u8); - for y in 0..H { - let a = alpha[(y * W + hi) as usize]; - if a > best_a { - best_a = a; - best_y = y; - } - } - let i = ((best_y * W + hi) * 4) as usize; - (buf[i], buf[i + 1], buf[i + 2]) + let column_peak = |x: i32| { + (0..H) + .map(|y| (alpha[(y * W + x) as usize], y)) + .max() + .expect("H > 0") + }; + let (peak_a, peak_y, peak_x) = (0..=hi) + .rev() + .find_map(|x| { + let (a, y) = column_peak(x); + (a >= 200).then_some((a, y, x)) + }) + .expect("the last glyph must have a covered column to read a colour from"); + let i = ((peak_y * W + peak_x) * 4) as usize; + let straight = |c: u8| ((c as u32 * 255 + peak_a as u32 / 2) / peak_a as u32) as u8; + (straight(buf[i]), straight(buf[i + 1]), straight(buf[i + 2])) } let left = last_glyph_rgb(None); diff --git a/crates/rustmotion-components/src/intrinsic.rs b/crates/rustmotion-components/src/intrinsic.rs index d6624fd..f77e41a 100644 --- a/crates/rustmotion-components/src/intrinsic.rs +++ b/crates/rustmotion-components/src/intrinsic.rs @@ -8,7 +8,7 @@ use rustmotion_core::engine::box_tree::{AvailableSpace, IntrinsicMeasure}; use rustmotion_core::engine::deps::{TextMetrics, TextMetricsProvider}; use rustmotion_core::engine::renderer::{ compute_glyph_metrics, emoji_typeface, format_counter_value, measure_text_with_fallback, - typeface_with_fallback, wrap_text_with_tracking, GlyphMetric, + subpixel_font, typeface_with_fallback, wrap_text_with_tracking, GlyphMetric, }; use crate::badge::{Badge, BadgeSize}; @@ -195,8 +195,8 @@ impl TextIntrinsic { pub fn text_metrics(&self, content_box_width: f32) -> Option { let typeface = self.typeface()?; - let font = Font::from_typeface(typeface, self.font_size); - let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, self.font_size)); + let font = subpixel_font(typeface, self.font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, self.font_size)); let wrap_at = if self.wrap { Some( @@ -275,8 +275,8 @@ fn wrap_and_measure( letter_spacing: f32, line_height: f32, ) -> (f32, f32) { - let font = Font::from_typeface(typeface.clone(), font_size); - let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface.clone(), font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let lines = wrap_text_with_tracking(content, &font, &emoji_font, wrap_at, letter_spacing); let mut max_w = 0.0f32; for line in &lines { diff --git a/crates/rustmotion-components/src/kbd.rs b/crates/rustmotion-components/src/kbd.rs index 652033f..1179b0c 100644 --- a/crates/rustmotion-components/src/kbd.rs +++ b/crates/rustmotion-components/src/kbd.rs @@ -8,7 +8,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - typeface_with_fallback, + subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -103,7 +103,7 @@ impl Kbd { let font_style = skia_safe::FontStyle::normal(); let family = self.style.font_family.as_deref().unwrap_or("SF Mono"); let typeface = typeface_with_fallback(family, font_style).ok()?; - Some(skia_safe::Font::from_typeface(typeface, fs)) + Some(subpixel_font(typeface, fs)) } } @@ -144,7 +144,7 @@ impl Kbd { return; }; let fs = self.resolved_font_size(ctx); - let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, fs)); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, fs)); let text_color = self.style.color_str().unwrap_or(&self.text_color); let mut text_paint = paint_from_hex(text_color); diff --git a/crates/rustmotion-components/src/list.rs b/crates/rustmotion-components/src/list.rs index 783f0e6..c6a1ecf 100644 --- a/crates/rustmotion-components/src/list.rs +++ b/crates/rustmotion-components/src/list.rs @@ -8,7 +8,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ asset_cache, draw_text_with_fallback, emoji_typeface, fetch_icon_svg, paint_from_hex, - typeface_with_fallback, + subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -98,10 +98,7 @@ impl List { let font_style = skia_safe::FontStyle::normal(); let family = self.style.font_family.as_deref().unwrap_or("Inter"); let typeface = typeface_with_fallback(family, font_style).ok()?; - Some(skia_safe::Font::from_typeface( - typeface, - self.resolved_font_size(ctx), - )) + Some(subpixel_font(typeface, self.resolved_font_size(ctx))) } fn render_icon_svg( @@ -168,7 +165,7 @@ impl List { return Ok(()); }; let font_size = self.resolved_font_size(ctx); - let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let text_color = self.style.color_str_or("#FFFFFF"); let mut text_paint = paint_from_hex(text_color); text_paint.set_anti_alias(true); diff --git a/crates/rustmotion-components/src/marquee.rs b/crates/rustmotion-components/src/marquee.rs index 8b1fe67..fbdbdce 100644 --- a/crates/rustmotion-components/src/marquee.rs +++ b/crates/rustmotion-components/src/marquee.rs @@ -8,7 +8,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - typeface_with_fallback, + subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -90,8 +90,8 @@ impl Marquee { let font_style = skia_safe::FontStyle::normal(); let family = self.style.font_family_or("Inter"); let typeface = typeface_with_fallback(family, font_style)?; - let font = skia_safe::Font::from_typeface(typeface, fs); - let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, fs)); + let font = subpixel_font(typeface, fs); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, fs)); let separator = self.separator.as_deref().unwrap_or(" "); let full_text = format!("{}{}", self.content, separator); diff --git a/crates/rustmotion-components/src/number_wheel.rs b/crates/rustmotion-components/src/number_wheel.rs index 0e37a30..9ab3567 100644 --- a/crates/rustmotion-components/src/number_wheel.rs +++ b/crates/rustmotion-components/src/number_wheel.rs @@ -9,7 +9,8 @@ use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::{ease, AnimatedProperties}; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ - draw_text_with_fallback, measure_text_with_fallback, paint_from_hex, typeface_with_fallback, + draw_text_with_fallback, measure_text_with_fallback, paint_from_hex, subpixel_font, + typeface_with_fallback, }; use rustmotion_core::schema::{EasingType, FontStyleType, FontWeight, TimelineStep}; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -188,7 +189,7 @@ impl NumberWheel { FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant), ) .ok()?; - Some(Font::from_typeface(typeface, font_size)) + Some(subpixel_font(typeface, font_size)) } } @@ -210,7 +211,7 @@ impl Painter for NumberWheel { return; }; let emoji_font = rustmotion_core::engine::renderer::emoji_typeface() - .map(|tf| Font::from_typeface(tf, font_size)); + .map(|tf| subpixel_font(tf, font_size)); let own_ctx = rustmotion_core::css::units::LengthContext { font_size, ..base_ctx diff --git a/crates/rustmotion-components/src/pill_nav.rs b/crates/rustmotion-components/src/pill_nav.rs index 0cb4c35..b6bc6ab 100644 --- a/crates/rustmotion-components/src/pill_nav.rs +++ b/crates/rustmotion-components/src/pill_nav.rs @@ -7,7 +7,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - typeface_with_fallback, + subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -106,16 +106,13 @@ impl PillNav { }; let family = self.style.font_family.as_deref().unwrap_or("Inter"); let typeface = typeface_with_fallback(family, font_style).ok()?; - Some(skia_safe::Font::from_typeface( - typeface, - self.resolved_font_size(ctx), - )) + Some(subpixel_font(typeface, self.resolved_font_size(ctx))) } fn compute_tab_layout(&self, ctx: &PaintCtx) -> Option<(f32, Vec, Vec)> { let font = self.make_font(false, ctx)?; let font_size = self.resolved_font_size(ctx); - let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let h_pad = font_size * 1.2; let mut tab_widths: Vec = Vec::new(); @@ -217,7 +214,7 @@ impl PillNav { return; }; let font_size = self.resolved_font_size(ctx); - let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let (_, metrics) = font.metrics(); let text_y = (h + (-metrics.ascent)) / 2.0; diff --git a/crates/rustmotion-components/src/progress.rs b/crates/rustmotion-components/src/progress.rs index a78775d..1f73616 100644 --- a/crates/rustmotion-components/src/progress.rs +++ b/crates/rustmotion-components/src/progress.rs @@ -8,7 +8,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ color4f_from_hex, draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, - paint_from_hex, typeface_with_fallback, + paint_from_hex, subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -167,9 +167,8 @@ impl Progress { let Ok(typeface) = typeface_with_fallback("Inter", font_style) else { return Ok(()); }; - let font = skia_safe::Font::from_typeface(typeface, font_size); - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let mut text_paint = paint_from_hex(&self.fill_color); text_paint.set_anti_alias(true); diff --git a/crates/rustmotion-components/src/rich_text.rs b/crates/rustmotion-components/src/rich_text.rs index dc57cfa..760d2ea 100644 --- a/crates/rustmotion-components/src/rich_text.rs +++ b/crates/rustmotion-components/src/rich_text.rs @@ -11,7 +11,7 @@ use rustmotion_core::engine::animator::{AnimatedProperties, ResolvedCharAnimatio use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - typeface_with_fallback, + subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::{ FontStyleType, FontWeight, TextAlign, TextAnimGranularity, TimelineStep, @@ -118,7 +118,7 @@ fn make_font( }; let skia_style = FontStyle::new(weight_val, skia_safe::font_style::Width::NORMAL, slant); let typeface = typeface_with_fallback(family, skia_style).ok()?; - Some(Font::from_typeface(typeface, size)) + Some(subpixel_font(typeface, size)) } struct SpanFontInfo { @@ -291,7 +291,7 @@ impl RichText { .expect("font presence checked during tokenization"); let emoji_font = emoji_tf .as_ref() - .map(|tf| Font::from_typeface(tf.clone(), sf.font.size())); + .map(|tf| subpixel_font(tf.clone(), sf.font.size())); let tok_width = measure_text_with_fallback(&tok.text, &sf.font, &emoji_font, sf.letter_spacing); let space_width = if tok.space_before { @@ -495,7 +495,7 @@ impl RichText { let paint = paint_from_hex(&sf.color); let emoji_font = emoji_tf .as_ref() - .map(|tf| Font::from_typeface(tf.clone(), sf.font.size())); + .map(|tf| subpixel_font(tf.clone(), sf.font.size())); draw_text_with_fallback( canvas, @@ -652,7 +652,7 @@ fn render_rich_text_char_animation( let paint = paint_from_hex(&sf.color); let emoji_font = emoji_tf .as_ref() - .map(|tf| Font::from_typeface(tf.clone(), sf.font.size())); + .map(|tf| subpixel_font(tf.clone(), sf.font.size())); if is_word_mode { let t = crate::intrinsic::unit_progress(char_anim, global_unit_idx, time); @@ -991,7 +991,7 @@ mod tests { let typeface = typeface_with_fallback("Inter", FontStyle::default()).expect("typeface resolves"); - let font = Font::from_typeface(typeface, font_px); + let font = subpixel_font(typeface, font_px); let (_, m) = font.metrics(); let ascent = -m.ascent; let descent = m.descent; @@ -1371,7 +1371,7 @@ mod tests { fn inter_font_px(px: f32) -> Font { let typeface = typeface_with_fallback("Inter", FontStyle::default()).expect("resolves"); - Font::from_typeface(typeface, px) + subpixel_font(typeface, px) } #[test] diff --git a/crates/rustmotion-components/src/shape.rs b/crates/rustmotion-components/src/shape.rs index a733284..2385f2d 100644 --- a/crates/rustmotion-components/src/shape.rs +++ b/crates/rustmotion-components/src/shape.rs @@ -8,8 +8,8 @@ use rustmotion_core::engine::animator::{ease, AnimatedProperties}; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ build_shape_path, color4f_from_hex, draw_shape_path, draw_text_with_fallback, emoji_typeface, - interpolate_path_data, measure_text_with_fallback, paint_from_hex, trim_path_between, - typeface_with_fallback, wrap_text_with_tracking, + interpolate_path_data, measure_text_with_fallback, paint_from_hex, subpixel_font, + trim_path_between, typeface_with_fallback, wrap_text_with_tracking, }; use rustmotion_core::expr::{Computed, Expr, Scope}; use rustmotion_core::schema::{ @@ -423,8 +423,8 @@ fn render_shape_text( let typeface = typeface_with_fallback(&text.font_family, font_style)?; - let font = skia_safe::Font::from_typeface(typeface, text.font_size); - let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, text.font_size)); + let font = subpixel_font(typeface, text.font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, text.font_size)); let (_strike_width, metrics) = font.metrics(); let ascent = -metrics.ascent; let line_height = match text.line_height { diff --git a/crates/rustmotion-components/src/slider.rs b/crates/rustmotion-components/src/slider.rs index 929c742..bfcb917 100644 --- a/crates/rustmotion-components/src/slider.rs +++ b/crates/rustmotion-components/src/slider.rs @@ -8,7 +8,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - typeface_with_fallback, + subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -170,8 +170,8 @@ impl From for Slider { fn slider_value_label_half_width(thumb_size: f32) -> Option { let font_size = (thumb_size * 0.7).max(12.0); let typeface = typeface_with_fallback("Inter", skia_safe::FontStyle::normal()).ok()?; - let font = skia_safe::Font::from_typeface(typeface, font_size); - let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let w = measure_text_with_fallback("100%", &font, &emoji_font, 0.0); Some(w / 2.0) } @@ -179,7 +179,7 @@ fn slider_value_label_half_width(thumb_size: f32) -> Option { fn slider_value_label_line_height(thumb_size: f32) -> Option { let font_size = (thumb_size * 0.7).max(12.0); let typeface = typeface_with_fallback("Inter", skia_safe::FontStyle::normal()).ok()?; - let font = skia_safe::Font::from_typeface(typeface, font_size); + let font = subpixel_font(typeface, font_size); let (_, metrics) = font.metrics(); Some(-metrics.ascent + metrics.descent + 4.0) } @@ -275,9 +275,8 @@ impl Slider { canvas.restore(); return; }; - let font = skia_safe::Font::from_typeface(typeface, font_size); - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let mut text_paint = paint_from_hex(&self.fill_color); text_paint.set_anti_alias(true); diff --git a/crates/rustmotion-components/src/stat.rs b/crates/rustmotion-components/src/stat.rs index 747b37e..f9d6344 100644 --- a/crates/rustmotion-components/src/stat.rs +++ b/crates/rustmotion-components/src/stat.rs @@ -10,7 +10,8 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ asset_cache, draw_text_with_fallback, emoji_typeface, fetch_icon_svg, - measure_text_with_fallback, paint_from_hex, parse_hex_color, typeface_with_fallback, + measure_text_with_fallback, paint_from_hex, parse_hex_color, subpixel_font, + typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -145,9 +146,8 @@ impl Stat { canvas.restore(); return; }; - let font = skia_safe::Font::from_typeface(typeface, eff_label_fs); - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, eff_label_fs)); + let font = subpixel_font(typeface, eff_label_fs); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, eff_label_fs)); let (_, metrics) = font.metrics(); let mut label_paint = paint_from_hex(&self.label_color); @@ -173,9 +173,8 @@ impl Stat { canvas.restore(); return; }; - let font = skia_safe::Font::from_typeface(typeface, eff_value_fs); - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, eff_value_fs)); + let font = subpixel_font(typeface, eff_value_fs); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, eff_value_fs)); let (_, metrics) = font.metrics(); let mut val_paint = paint_from_hex(&self.value_color); @@ -201,9 +200,8 @@ impl Stat { canvas.restore(); return; }; - let trend_font = skia_safe::Font::from_typeface(trend_typeface, trend_fs); - let trend_emoji = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, trend_fs)); + let trend_font = subpixel_font(trend_typeface, trend_fs); + let trend_emoji = emoji_typeface().map(|tf| subpixel_font(tf, trend_fs)); let trend_color = trend.color.as_deref().unwrap_or(match trend.direction { TrendDirection::Up => "#22C55E", diff --git a/crates/rustmotion-components/src/stepper.rs b/crates/rustmotion-components/src/stepper.rs index db206b8..3e9aaba 100644 --- a/crates/rustmotion-components/src/stepper.rs +++ b/crates/rustmotion-components/src/stepper.rs @@ -7,7 +7,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - parse_hex_color, typeface_with_fallback, + parse_hex_color, subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -149,19 +149,16 @@ impl Stepper { }; let number_font_size = r * 0.9; - let number_font = skia_safe::Font::from_typeface(&bold_typeface, number_font_size); - let emoji_number_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, number_font_size)); + let number_font = subpixel_font(&bold_typeface, number_font_size); + let emoji_number_font = emoji_typeface().map(|tf| subpixel_font(tf, number_font_size)); let label_font_size = 14.0; - let label_font = skia_safe::Font::from_typeface(&typeface, label_font_size); - let emoji_label_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, label_font_size)); + let label_font = subpixel_font(&typeface, label_font_size); + let emoji_label_font = emoji_typeface().map(|tf| subpixel_font(tf, label_font_size)); let desc_font_size = 11.0; - let desc_font = skia_safe::Font::from_typeface(&typeface, desc_font_size); - let emoji_desc_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, desc_font_size)); + let desc_font = subpixel_font(&typeface, desc_font_size); + let emoji_desc_font = emoji_typeface().map(|tf| subpixel_font(tf, desc_font_size)); let is_horizontal = self.orientation == StepperOrientation::Horizontal; diff --git a/crates/rustmotion-components/src/switch.rs b/crates/rustmotion-components/src/switch.rs index 7a3b55e..c5af3bf 100644 --- a/crates/rustmotion-components/src/switch.rs +++ b/crates/rustmotion-components/src/switch.rs @@ -8,7 +8,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - typeface_with_fallback, + subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -144,8 +144,8 @@ impl From for Switch { fn switch_label_extra_width(label: &str, height: f32) -> Option { let font_size = (height * 0.5).max(12.0); let typeface = typeface_with_fallback("Inter", skia_safe::FontStyle::normal()).ok()?; - let font = skia_safe::Font::from_typeface(typeface, font_size); - let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let label_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0); Some(8.0 + label_w) } @@ -220,9 +220,8 @@ impl Switch { let Ok(typeface) = typeface_with_fallback("Inter", font_style) else { return; }; - let font = skia_safe::Font::from_typeface(typeface, font_size); - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let mut text_paint = paint_from_hex("#FFFFFF"); text_paint.set_anti_alias(true); diff --git a/crates/rustmotion-components/src/table.rs b/crates/rustmotion-components/src/table.rs index 5faa9f0..a99617a 100644 --- a/crates/rustmotion-components/src/table.rs +++ b/crates/rustmotion-components/src/table.rs @@ -7,7 +7,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - typeface_with_fallback, + subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -96,7 +96,7 @@ impl Table { let family = self.style.font_family.as_deref().unwrap_or("Inter"); let typeface = typeface_with_fallback(family, font_style).ok()?; - Some(skia_safe::Font::from_typeface(typeface, font_size)) + Some(subpixel_font(typeface, font_size)) } fn resolve_column_widths(&self, total_w: f32, font_size: f32) -> Vec { @@ -128,8 +128,8 @@ impl Table { let min_col_w = DEFAULT_FONT_SIZE * 8.0 + DEFAULT_CELL_PADDING * 2.0; return vec![min_col_w; col_count]; }; - let font = skia_safe::Font::from_typeface(typeface, font_size); - let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let cell_padding = self.cell_padding; let mut col_widths: Vec = vec![0.0; col_count]; @@ -208,7 +208,7 @@ impl Table { canvas.clip_rrect(rrect, skia_safe::ClipOp::Intersect, true); } - let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let (_, header_metrics) = header_font.metrics(); let header_ascent = -header_metrics.ascent; diff --git a/crates/rustmotion-components/src/tag_cloud.rs b/crates/rustmotion-components/src/tag_cloud.rs index aad10bd..8d15e29 100644 --- a/crates/rustmotion-components/src/tag_cloud.rs +++ b/crates/rustmotion-components/src/tag_cloud.rs @@ -7,7 +7,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - parse_hex_color, typeface_with_fallback, + parse_hex_color, subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -144,9 +144,8 @@ impl TagCloud { let Ok(typeface) = typeface_with_fallback("Inter", font_style) else { continue; }; - let font = skia_safe::Font::from_typeface(typeface, font_size); - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let text_width = measure_text_with_fallback(&tag.text, &font, &emoji_font, 0.0); let (_, fm_metrics) = font.metrics(); @@ -245,9 +244,8 @@ impl TagCloud { let Ok(typeface) = typeface_with_fallback("Inter", font_style) else { continue; }; - let font = skia_safe::Font::from_typeface(typeface, pt.font_size); - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, pt.font_size)); + let font = subpixel_font(typeface, pt.font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, pt.font_size)); let text_x = pt.x; let text_y = y_offset + pt.y + pt.ascent; diff --git a/crates/rustmotion-components/src/text.rs b/crates/rustmotion-components/src/text.rs index 1aa2e36..88f7817 100644 --- a/crates/rustmotion-components/src/text.rs +++ b/crates/rustmotion-components/src/text.rs @@ -12,7 +12,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - typeface_with_fallback, wrap_text_with_tracking, + subpixel_font, typeface_with_fallback, wrap_text_with_tracking, }; use rustmotion_core::schema::{ CaretConfig, CaretShape, FontStyleType, FontWeight, Stroke, TextAlign, TextBackground, @@ -266,8 +266,8 @@ impl Text { ..base_ctx }; - let font = Font::from_typeface(typeface, font_size); - let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size)); + let font = subpixel_font(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let paint = paint_from_hex(color); let wrap_width = if nowrap { None } else { box_width }; @@ -1085,7 +1085,7 @@ mod tests { skia_safe::font_style::Slant::Upright, ); let typeface = typeface_with_fallback("Inter", style).expect("typeface resolves"); - Font::from_typeface(typeface, px) + subpixel_font(typeface, px) } #[test] diff --git a/crates/rustmotion-components/src/timeline.rs b/crates/rustmotion-components/src/timeline.rs index b2258f7..ceeeed3 100644 --- a/crates/rustmotion-components/src/timeline.rs +++ b/crates/rustmotion-components/src/timeline.rs @@ -7,7 +7,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - typeface_with_fallback, + subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep as AnimTimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -135,10 +135,10 @@ impl Timeline { let Ok(typeface) = typeface_with_fallback("Inter", FontStyle::normal()) else { return; }; - let font = Font::from_typeface(&typeface, self.font_size); - let icon_font = Font::from_typeface(&typeface, self.node_radius * 0.8); - let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, self.node_radius * 0.8)); - let sublabel_font = Font::from_typeface(&typeface, self.font_size * 0.8); + let font = subpixel_font(&typeface, self.font_size); + let icon_font = subpixel_font(&typeface, self.node_radius * 0.8); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, self.node_radius * 0.8)); + let sublabel_font = subpixel_font(&typeface, self.font_size * 0.8); let (_, metrics) = font.metrics(); let ascent = -metrics.ascent; diff --git a/crates/rustmotion-components/src/tooltip.rs b/crates/rustmotion-components/src/tooltip.rs index b73d124..2695724 100644 --- a/crates/rustmotion-components/src/tooltip.rs +++ b/crates/rustmotion-components/src/tooltip.rs @@ -7,7 +7,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - typeface_with_fallback, + subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -92,7 +92,7 @@ impl Tooltip { let font_style = skia_safe::FontStyle::normal(); let family = self.style.font_family_or("Inter"); let typeface = typeface_with_fallback(family, font_style).ok()?; - Some(skia_safe::Font::from_typeface(typeface, fs)) + Some(subpixel_font(typeface, fs)) } } @@ -175,7 +175,7 @@ impl Tooltip { return; }; let fs = self.resolved_font_size(ctx); - let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, fs)); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, fs)); let text_color = self.style.color_str().unwrap_or(&self.text_color); let mut text_paint = paint_from_hex(text_color); diff --git a/crates/rustmotion-components/src/treemap.rs b/crates/rustmotion-components/src/treemap.rs index c3c0c12..46e27c1 100644 --- a/crates/rustmotion-components/src/treemap.rs +++ b/crates/rustmotion-components/src/treemap.rs @@ -7,7 +7,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - typeface_with_fallback, + subpixel_font, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -201,9 +201,8 @@ impl Treemap { continue; } - let font = skia_safe::Font::from_typeface(&typeface, font_size); - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); + let font = subpixel_font(&typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size)); let mut text_paint = paint_from_hex("#FFFFFF"); text_paint.set_anti_alias(true); diff --git a/crates/rustmotion-core/src/engine/renderer/fonts.rs b/crates/rustmotion-core/src/engine/renderer/fonts.rs index cad9714..fafbefa 100644 --- a/crates/rustmotion-core/src/engine/renderer/fonts.rs +++ b/crates/rustmotion-core/src/engine/renderer/fonts.rs @@ -2,7 +2,7 @@ use std::cell::RefCell; use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; -use skia_safe::{FontMgr, FontStyle, Typeface}; +use skia_safe::{Font, FontHinting, FontMgr, FontStyle, Typeface}; use crate::error::{Result, RustmotionError}; use crate::schema::FontEntry; @@ -237,6 +237,14 @@ pub fn typeface_with_fallback(family: &str, style: FontStyle) -> Result, size: impl Into>) -> Font { + let mut font = Font::from_typeface(typeface, size); + font.set_subpixel(true); + font.set_baseline_snap(false); + font.set_hinting(FontHinting::None); + font +} + pub fn emoji_typeface() -> Option { thread_local! { static EMOJI_TF: Option = { diff --git a/crates/rustmotion-core/src/engine/renderer/text.rs b/crates/rustmotion-core/src/engine/renderer/text.rs index 01195ac..b287b19 100644 --- a/crates/rustmotion-core/src/engine/renderer/text.rs +++ b/crates/rustmotion-core/src/engine/renderer/text.rs @@ -1,5 +1,7 @@ use skia_safe::{Canvas, Font, Paint, Point, TextBlob, Typeface}; +use super::subpixel_font; + pub fn format_counter_value( value: f64, decimals: u8, @@ -323,7 +325,7 @@ fn resolve_run_font<'a>( _ => primary, }, RunKind::Fallback(tf) => { - *owned = Some(Font::from_typeface(tf.clone(), primary.size())); + *owned = Some(subpixel_font(tf.clone(), primary.size())); owned.as_ref().unwrap() } } @@ -473,7 +475,7 @@ mod tracking_tests { fn bold_font(size: f32) -> Font { let typeface = typeface_with_fallback("Helvetica", SkFontStyle::bold()) .expect("host must have a fallback typeface"); - Font::from_typeface(typeface, size) + subpixel_font(typeface, size) } #[test] @@ -741,7 +743,7 @@ mod emoji_presentation_tests { fn helvetica_font(size: f32) -> Font { let typeface = typeface_with_fallback("Helvetica", skia_safe::FontStyle::normal()) .expect("host must have a fallback typeface"); - Font::from_typeface(typeface, size) + subpixel_font(typeface, size) } fn render_and_sample(text: &str, size: f32) -> (usize, f64, f64, f64) { @@ -749,7 +751,7 @@ mod emoji_presentation_tests { const W: i32 = 200; const H: i32 = 200; let font = helvetica_font(size); - let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, size)); + let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, size)); let mut surface = surfaces::raster_n32_premul((W, H)).unwrap(); let canvas = surface.canvas(); canvas.clear(Color::BLACK); @@ -813,7 +815,7 @@ mod glyph_fallback_tests { fn helvetica_font(size: f32) -> Font { let typeface = typeface_with_fallback("Helvetica", skia_safe::FontStyle::normal()) .expect("host must have a fallback typeface"); - Font::from_typeface(typeface, size) + subpixel_font(typeface, size) } #[test] @@ -866,7 +868,7 @@ mod glyph_fallback_tests { the host has a capable font" ); }; - let fallback_font = Font::from_typeface(fallback, 32.0); + let fallback_font = subpixel_font(fallback, 32.0); assert!( font_covers(&fallback_font, "\u{4F60}"), "resolved fallback typeface must actually cover the code point that triggered it" @@ -954,7 +956,7 @@ mod glyph_metrics_tests { fn test_font(size: f32) -> Font { let typeface = typeface_with_fallback("Helvetica", SkFontStyle::default()) .expect("host must have a fallback typeface"); - Font::from_typeface(typeface, size) + subpixel_font(typeface, size) } #[test] diff --git a/crates/rustmotion/CLAUDE.md b/crates/rustmotion/CLAUDE.md index 5b6e222..1c78ced 100644 --- a/crates/rustmotion/CLAUDE.md +++ b/crates/rustmotion/CLAUDE.md @@ -215,6 +215,13 @@ pub trait Painter { `PaintCtx` contient : `time`, `scene_duration`, `fps`, `frame_index`, `video_width`, `video_height`, `stagger_offset`. +> Every `skia_safe::Font` comes from `renderer::subpixel_font`, never from +> `Font::from_typeface`. Skia defaults to whole-pixel glyph placement (subpixel off, +> baseline snapping on), so a text node under a slow translation stepped by 1px while +> the shape beside it moved by hundredths — the label shivering inside a still card. +> The constructor is the only place those three settings live; calling Skia directly +> puts the snapping back for that one component. + ### Structure des crates ``` diff --git a/crates/rustmotion/src/cli/commands/sheet.rs b/crates/rustmotion/src/cli/commands/sheet.rs index 1fd4b6a..4179ac4 100644 --- a/crates/rustmotion/src/cli/commands/sheet.rs +++ b/crates/rustmotion/src/cli/commands/sheet.rs @@ -1,6 +1,7 @@ use crate::cli::OutputFormat; use rustmotion::encode; use rustmotion::engine; +use rustmotion::engine::renderer::subpixel_font; use rustmotion::error::{Result, RustmotionError}; use rustmotion::schema::ResolvedScenario; use skia_safe::{ @@ -115,7 +116,7 @@ fn compute_layout( fn label_font(cell_width: u32) -> Result { let typeface = engine::typeface_with_fallback("", FontStyle::bold())?; let size = (cell_width as f32 * 0.06).clamp(14.0, 26.0); - Ok(Font::from_typeface(typeface, size)) + Ok(subpixel_font(typeface, size)) } fn draw_timestamp_stamp( diff --git a/crates/rustmotion/tests/text_subpixel_motion.rs b/crates/rustmotion/tests/text_subpixel_motion.rs new file mode 100644 index 0000000..7345b32 --- /dev/null +++ b/crates/rustmotion/tests/text_subpixel_motion.rs @@ -0,0 +1,125 @@ +use rustmotion::encode::{build_frame_tasks, render_frame_task_scaled}; +use rustmotion::schema::ResolvedScenario; + +const WIDTH: u32 = 640; +const HEIGHT: u32 = 360; +const FPS: u32 = 30; +const TRAVEL_PX: f64 = 3.0; + +fn scenario() -> ResolvedScenario { + let json = format!( + r##"{{ + "version": "1.0", + "video": {{ "width": {WIDTH}, "height": {HEIGHT}, "fps": {FPS}, "background": "#FFFFFF" }}, + "scenes": [{{ + "duration": 1.0, + "layout": {{ "align_items": "center", "justify_content": "center" }}, + "children": [{{ + "type": "div", + "style": {{ + "flex-direction": "column", "gap": 20, + "animation": [{{ "name": "keyframes", "keyframes": [{{ "property": "translate_y", "easing": "linear", + "keyframes": [{{ "time": 0.0, "value": 0.0 }}, {{ "time": 1.0, "value": {TRAVEL_PX} }}] }}] }}] + }}, + "children": [ + {{ "type": "text", "content": "Chef de projet", "style": {{ "font-size": 40, "font-weight": "bold", "color": "#000000" }} }}, + {{ "type": "shape", "shape": "rect", "fill": "#000000", "style": {{ "width": 300, "height": 4 }} }} + ] + }}] + }}] + }}"## + ); + rustmotion::loader::load_scenario_from_source(None, Some(&json)).expect("scenario loads") +} + +fn row_ink(rgba: &[u8]) -> Vec { + (0..HEIGHT as usize) + .map(|y| { + (0..WIDTH as usize) + .map(|x| { + let i = (y * WIDTH as usize + x) * 4; + let [r, g, b, a] = [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]; + let luma = (0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64) / 255.0; + (1.0 - luma) * (a as f64 / 255.0) + }) + .sum() + }) + .collect() +} + +fn ink_bands(rows: &[f64]) -> Vec<(usize, usize)> { + let mut bands = Vec::new(); + let mut start = None; + for (y, ink) in rows.iter().enumerate() { + match (start, *ink > 0.01) { + (None, true) => start = Some(y), + (Some(s), false) => { + bands.push((s, y)); + start = None; + } + _ => {} + } + } + if let Some(s) = start { + bands.push((s, rows.len())); + } + bands +} + +fn centroid(rows: &[f64], (from, to): (usize, usize)) -> f64 { + let weight: f64 = rows[from..to].iter().sum(); + let moment: f64 = rows[from..to] + .iter() + .enumerate() + .map(|(i, ink)| (from + i) as f64 * ink) + .sum(); + moment / weight +} + +fn centroids_per_frame() -> Vec<(f64, f64)> { + let scenario = scenario(); + rustmotion::engine::preload::preload_scenario_assets(&scenario).expect("preload"); + let tasks = build_frame_tasks(&scenario); + assert_eq!(tasks.len(), FPS as usize, "a 1s scene at {FPS}fps"); + + tasks + .iter() + .map(|task| { + let rgba = + render_frame_task_scaled(&scenario.video, &scenario, task, 1.0).expect("render"); + let rows = row_ink(&rgba); + let bands = ink_bands(&rows); + assert_eq!( + bands.len(), + 2, + "expected the text band and the bar band, got {bands:?}" + ); + (centroid(&rows, bands[0]), centroid(&rows, bands[1])) + }) + .collect() +} + +#[test] +fn text_tracks_a_slow_translation_as_closely_as_the_shape_beside_it() { + let per_frame = centroids_per_frame(); + let (text0, bar0) = per_frame[0]; + + let mut worst_text = 0.0_f64; + let mut worst_gap = 0.0_f64; + for (i, (text, bar)) in per_frame.iter().enumerate() { + let expected = TRAVEL_PX * i as f64 / (FPS - 1) as f64; + worst_text = worst_text.max((text - text0 - expected).abs()); + worst_gap = worst_gap.max(((text - text0) - (bar - bar0)).abs()); + } + + assert!( + worst_text < 0.25, + "glyphs snapped to whole pixels: the text centroid strays {worst_text:.3}px from a \ + {TRAVEL_PX}px linear travel, against 0.690px measured with snapping on\n{per_frame:#?}" + ); + assert!( + worst_gap < 0.25, + "the text drifts {worst_gap:.3}px against the bar it shares a translated box with\n\ + {per_frame:#?}" + ); +}