diff --git a/crates/rustmotion-cli/src/commands/geometry.rs b/crates/rustmotion-cli/src/commands/geometry.rs index a70cc5e..293058c 100644 --- a/crates/rustmotion-cli/src/commands/geometry.rs +++ b/crates/rustmotion-cli/src/commands/geometry.rs @@ -60,7 +60,10 @@ use rustmotion::components::intrinsic::{ TerminalIntrinsic, TextIntrinsic, }; use rustmotion::components::{ChildComponent, Component}; -use rustmotion::core::css::style::{CssStyle, TransformFn, TransformOrigin, WhiteSpace}; +use rustmotion::core::css::style::{ + CssStyle, TransformFn, TransformOrigin, WhiteSpace, MIN_LEGIBLE_FONT_RATIO, + TEXT_AUTOFIT_MIN_FONT_PX, +}; use rustmotion::core::css::taffy_bridge::ConversionContext; use rustmotion::core::css::units::{parse_origin_component, LengthContext, ParsedLength}; use rustmotion::core::engine::box_tree::{AvailableSpace, BoxKind, BoxNode, IntrinsicMeasure}; @@ -757,11 +760,20 @@ fn check_unwrappable_text( if !nowrap { return; } - // Measure at the natural (unbounded) width via the same cosmic-text– - // backed intrinsic the layout engine uses. + // Measure via the same cosmic-text–backed intrinsic the layout engine + // uses. Width is bounded by the node's own resolved `bbox.w` (not + // `MaxContent`) so a `text-autofit: true` node can shrink to fit it — + // see `measurer_and_nowrap`'s `TextIntrinsic`/`GradientTextIntrinsic` + // arms and `CssStyle::text_autofit`'s doc comment. For a non-autofit + // node this changes nothing: `TextIntrinsic::measure` only reads the + // width constraint at all when `text_autofit` is on (see its early + // return), and `nowrap` already forces a single unwrapped line here + // regardless of what width is offered — so `natural_w` below is + // "natural" in the non-autofit case exactly as before, and "shrunk to + // fit, if that's enough" when the author declared it. let (natural_w, _) = intrinsic.measure( (None, None), - (AvailableSpace::MaxContent, AvailableSpace::MaxContent), + (AvailableSpace::Definite(bbox.w), AvailableSpace::MaxContent), ); if natural_w > bbox.w + 0.5 { let kind = component_kind(component); @@ -831,9 +843,18 @@ fn check_content_overflows_box( return; } + // Height is bounded by the node's own resolved content-box height `ch` + // (not `MaxContent`) for the same reason width is bounded by `cw`: a + // `text-autofit: true` node can only try to shrink into a target it's + // actually told about. `TextIntrinsic::measure` only reads this height + // bound at all when `text_autofit` is on (see its early return right + // after the base, non-autofit measurement), so a non-autofit node's + // `measured_h` is unaffected — this is the same "safe to change + // unconditionally" argument as `check_unwrappable_text`'s width bound + // above. let (measured_w, measured_h) = intrinsic.measure( (None, None), - (AvailableSpace::Definite(cw), AvailableSpace::MaxContent), + (AvailableSpace::Definite(cw), AvailableSpace::Definite(ch)), ); let eps = 0.5; @@ -962,19 +983,12 @@ fn check_auto_scroll( // watched (embedded players, mobile feeds, thumbnails) unlike a web page, // which is usually viewed close to 1:1. // -// Threshold justification (rendered evidence, not a guess): a 1920×1080 -// scenario was rendered with the same sample line at 8/10/11/12/13/14/16/18/ -// 20/22/24/28px, then the frame was scaled down 50% (a realistic "not -// full-native" viewing size) to inspect. 8–13px degraded to an illegible -// grey smear at that scale; 14px was the first size that stayed readable. -// 0.012 (1.2% of output height) sits between those two bands — it equals -// ~13px on a 1080p frame — and clears every built-in component default -// already shipped (table/terminal/codeblock/pill_nav = 14px, badge `md` = -// 14px, kbd = 14px, tooltip = 13px), so it does not fire on scenarios that -// already validate clean today. Expressing it as a fraction of output -// height (rather than an absolute px count) makes the same *visual* size -// get flagged on a 4K or vertical-format canvas too. -const MIN_LEGIBLE_FONT_RATIO: f32 = 0.012; +// The calibration and its threshold now live on `MIN_LEGIBLE_FONT_RATIO` +// itself, in `rustmotion_core::css::style` — relocated there (not +// duplicated) so `CssStyle::text_autofit`'s shrink floor can reuse the exact +// same calibrated ratio instead of inventing a second one; `rustmotion-core` +// is a dependency of this crate, never the other way around, so that is the +// only direction the constant can live in for both sides to share it. /// Check every text-bearing component's effective font size against /// [`MIN_LEGIBLE_FONT_RATIO`] of the output height. Always advisory (a @@ -1034,6 +1048,29 @@ fn walk_legibility( } } + // The check above reads the *declared* size, which is the rendered size + // for every component except an autofitting one: `text-autofit` shrinks + // toward `TEXT_AUTOFIT_MIN_FONT_PX`, a constant pinned to a 1080-tall + // reference so that measure and paint cannot disagree about it (see that + // constant's doc comment). `min_px` here is relative to the *real* frame + // height, so on any canvas taller than 1080 the floor sits below the + // legibility threshold — and a declared 120px that shrinks to ~13px on a + // 2160-tall frame would otherwise pass this check in silence, which is + // the exact failure mode autofit exists to remove rather than relocate. + // + // Advisory and conditional: it fires only when the two genuinely diverge + // (taller-than-1080 canvases), and says "may" because resolving the + // actual shrunk size needs layout, which this pass does not run. The + // precise fix is a canvas-relative floor on both sides, which requires + // plumbing the frame height into `TextIntrinsic` — tracked separately. + if declares_text_autofit(component) && TEXT_AUTOFIT_MIN_FONT_PX < min_px - 0.05 { + out.push(format!( + "{path}: text-autofit may shrink this text to ~{TEXT_AUTOFIT_MIN_FONT_PX:.0}px, below \ + the {min_px:.0}px legibility floor for a {video_h:.0}px-tall frame. Give it a wider \ + or taller box so it settles above that, or check the rendered frame.", + )); + } + if let Some(children) = container_children(component) { for (i, child) in children.iter().enumerate() { walk_legibility( @@ -1053,6 +1090,18 @@ fn walk_legibility( /// defaults live in `rustmotion-components`, out of this workstream's /// scope). A component can report more than one size (e.g. a notification's /// title and message use different sizes). +/// Whether this component's painter actually honours `style.text-autofit`. +/// Deliberately the same two variants `TextIntrinsic::with_autofit` is called +/// for — every other component ignores the field, so warning about them would +/// be a false positive about a shrink that cannot happen. +fn declares_text_autofit(component: &Component) -> bool { + match component { + Component::Text(t) => matches!(t.style.text_autofit, Some(true)), + Component::GradientText(t) => matches!(t.style.text_autofit, Some(true)), + _ => false, + } +} + fn text_sizes(component: &Component) -> Vec<(&'static str, f32)> { match component { // text.rs, rich_text.rs, gradient_text.rs, caption.rs, counter.rs: 48.0 @@ -3683,6 +3732,124 @@ mod tests { violations ); } + + // ─── text-autofit: Vérification point 4 ───────────────────────────── + // + // "Un scénario qui déborde aujourd'hui doit valider après, avec + // l'ajustement déclaré — et un scénario sans ajustement doit continuer + // à déborder et à être signalé." These reuse the exact same fixtures as + // the pre-existing `ContentOverflowsBox`/`UnwrappableTextOverflow` + // tests above (`wrapped_text_taller_than_its_fixed_height_card_is_flagged`, + // `unwrappable_text_in_narrow_card_is_flagged`), adding only + // `text-autofit: true`, so the "before"/"after" pair is a controlled + // comparison rather than two unrelated fixtures. + + #[test] + fn text_autofit_resolves_a_content_overflow_that_would_otherwise_fire() { + // Same fixture as `wrapped_text_taller_than_its_fixed_height_card_is_flagged` + // (a paragraph that needs ~343px of height inside an 80px-tall + // card), with `text-autofit: true` added. + let json = r##"{"video":{"width":960,"height":540,"fps":30,"background":"#0A0A12"}, + "scenes":[{"duration":1.0,"children":[ + {"type":"card","position":"absolute","x":330,"y":200, + "style":{"width":300,"height":80,"background":"#1e2233","overflow":"visible"}, + "children":[{"type":"text", + "content":"Ce paragraphe est beaucoup plus grand que la carte de 80px qui le contient.", + "style":{"font-size":44,"color":"#ffffff","text-autofit":true}}]}]}]}"##; + let scenario = parse(json); + let violations = validate_geometry(&scenario); + assert!( + violations + .iter() + .all(|v| v.kind != ViolationKind::ContentOverflowsBox), + "text-autofit: true must resolve the height overflow this exact fixture (minus the \ + flag) triggers: {:?}", + violations + ); + } + + #[test] + fn without_text_autofit_the_same_fixture_still_overflows() { + // Control for the test above: identical fixture, no `text-autofit` + // — must still report `ContentOverflowsBox` exactly like + // `wrapped_text_taller_than_its_fixed_height_card_is_flagged`. + let json = r##"{"video":{"width":960,"height":540,"fps":30,"background":"#0A0A12"}, + "scenes":[{"duration":1.0,"children":[ + {"type":"card","position":"absolute","x":330,"y":200, + "style":{"width":300,"height":80,"background":"#1e2233","overflow":"visible"}, + "children":[{"type":"text", + "content":"Ce paragraphe est beaucoup plus grand que la carte de 80px qui le contient.", + "style":{"font-size":44,"color":"#ffffff"}}]}]}]}"##; + let scenario = parse(json); + let violations = validate_geometry(&scenario); + assert!( + violations + .iter() + .any(|v| v.kind == ViolationKind::ContentOverflowsBox), + "control fixture (no text-autofit) must still report the overflow: {:?}", + violations + ); + } + + #[test] + fn text_autofit_does_not_silence_an_overflow_the_floor_cannot_fix() { + // The floor stops the shrink before it can ever make this fit: the + // same paragraph crammed into an 8px-tall card. `text-autofit` + // narrows the overflow class, it does not eliminate every + // overflow — this must stay reported, per the brief's explicit + // requirement that a still-too-small box remains a signalled + // violation, not a silence. + let json = r##"{"video":{"width":960,"height":540,"fps":30,"background":"#0A0A12"}, + "scenes":[{"duration":1.0,"children":[ + {"type":"card","position":"absolute","x":330,"y":200, + "style":{"width":300,"height":8,"background":"#1e2233","overflow":"visible"}, + "children":[{"type":"text", + "content":"Ce paragraphe est beaucoup plus grand que la carte de 80px qui le contient.", + "style":{"font-size":44,"color":"#ffffff","text-autofit":true}}]}]}]}"##; + let scenario = parse(json); + let violations = validate_geometry(&scenario); + assert!( + violations + .iter() + .any(|v| v.kind == ViolationKind::ContentOverflowsBox), + "text-autofit must not silence an overflow the legibility floor cannot resolve: {:?}", + violations + ); + } + + #[test] + fn text_autofit_resolves_an_unwrappable_nowrap_overflow() { + // Same fixture as `unwrappable_text_in_narrow_card_is_flagged` (a + // 96px nowrap line in a 200px-wide card), with `text-autofit: true` + // added — this is `check_unwrappable_text`'s territory, not + // `check_content_overflows_box`'s. + let json = r##"{ + "video": { "width": 1920, "height": 1080 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "type": "card", + "x": 100, "y": 100, + "style": { "width": "200px", "height": "200px", "background": "#222244" }, + "children": [{ + "type": "text", + "content": "this string is too long to fit", + "style": { "color": "#ffffff", "font-size": "96px", "white-space": "nowrap", "text-autofit": true } + }] + }] + }] + }"##; + let scenario = parse(json); + let violations = validate_geometry(&scenario); + assert!( + violations + .iter() + .all(|v| v.kind != ViolationKind::UnwrappableTextOverflow), + "text-autofit: true must resolve the nowrap overflow this exact fixture (minus the \ + flag) triggers: {:?}", + violations + ); + } } /// M4 (issue #110 / #102): legibility floor tests. @@ -3721,6 +3888,91 @@ mod legibility_tests { ); } + /// `text-autofit`'s shrink floor is pinned to a 1080-tall reference so + /// measure and paint agree on it; this legibility floor is relative to + /// the real frame. On a taller canvas the two diverge, and a declared + /// size well above the floor can still render illegibly. Without this + /// warning that case passes in silence — the exact failure mode autofit + /// is supposed to remove, not relocate. + #[test] + fn autofit_on_a_taller_than_1080_canvas_warns_that_it_may_shrink_below_legibility() { + let json = r##"{ + "video": { "width": 3840, "height": 2160 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "type": "text", + "content": "a long headline that will not fit its narrow box", + "style": { + "width": "320px", "height": "90px", + "color": "#ffffff", "font-size": "120px", + "text-autofit": true + } + }] + }] + }"##; + let warnings = check_legibility(&parse(json)); + assert_eq!(warnings.len(), 1, "expected one warning: {warnings:?}"); + assert!( + warnings[0].contains("text-autofit may shrink"), + "got: {}", + warnings[0] + ); + // Both numbers must be named: what it can shrink to, and the floor + // it would fall under. A warning that says neither is unactionable. + assert!(warnings[0].contains("13px"), "got: {}", warnings[0]); + assert!(warnings[0].contains("26px"), "got: {}", warnings[0]); + } + + /// The mirror case, and the reason the warning is conditional rather + /// than unconditional: at 1080 the pinned floor already sits at the + /// legibility threshold, so there is nothing to warn about and doing so + /// would be noise on every autofitting text in the common canvas. + #[test] + fn autofit_on_a_1080_canvas_does_not_warn() { + let json = r##"{ + "video": { "width": 1920, "height": 1080 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "type": "text", + "content": "a long headline that will not fit its narrow box", + "style": { + "width": "320px", "height": "90px", + "color": "#ffffff", "font-size": "120px", + "text-autofit": true + } + }] + }] + }"##; + assert!( + check_legibility(&parse(json)).is_empty(), + "no divergence at 1080, so no warning" + ); + } + + /// A component whose painter ignores `text-autofit` must never draw the + /// warning: it cannot shrink, so the shrink cannot make it illegible. + #[test] + fn autofit_declared_on_a_component_that_ignores_it_does_not_warn() { + let json = r##"{ + "video": { "width": 3840, "height": 2160 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "type": "caption", + "mode": "highlight", + "words": [{ "text": "hello", "start": 0.0, "end": 1.0 }], + "style": { "font-size": "120px", "color": "#ffffff", "text-autofit": true } + }] + }] + }"##; + assert!( + check_legibility(&parse(json)).is_empty(), + "caption's painter never reads text-autofit" + ); + } + #[test] fn default_sized_text_on_1080p_has_no_legibility_warning() { // No style.font-size override: falls back to text's own 48px diff --git a/crates/rustmotion-cli/src/commands/info.rs b/crates/rustmotion-cli/src/commands/info.rs index 7edfd53..1db4e4b 100644 --- a/crates/rustmotion-cli/src/commands/info.rs +++ b/crates/rustmotion-cli/src/commands/info.rs @@ -1,4 +1,6 @@ +use rustmotion::components::intrinsic::{GradientTextIntrinsic, TextIntrinsic}; use rustmotion::components::{ChildComponent, Component}; +use rustmotion::core::engine::box_tree::{AvailableSpace, IntrinsicMeasure}; use rustmotion::engine::animator::spring_rest_time; use rustmotion::engine::render::deserialize_children; use rustmotion::error::Result; @@ -66,9 +68,132 @@ pub fn cmd_info(input: &PathBuf) -> Result<()> { } } + let text_sizes = collect_text_measurements(&scenario); + if !text_sizes.is_empty() { + println!("Text sizes:"); + for report in &text_sizes { + println!(" {}", report.describe()); + } + } + Ok(()) } +/// "Quelle largeur/hauteur fait ce texte, à cette taille, dans cette +/// police" (text-autofit workstream, lot text-autofit) exposed the same way +/// `rustmotion info` already exposes spring settle times (see +/// `SpringReport` above) rather than as a bespoke, separate command: +/// `rustmotion info` walks the scenario and reports; this adds one more +/// thing it reports. +/// +/// The measurement is the natural (unconstrained) size at the declared +/// `font-size`/family — via `TextIntrinsic`/`GradientTextIntrinsic`, the +/// exact same Skia-backed measurer the layout engine and the geometry +/// validator use, so this is never a second, independently-drifting +/// estimate of what the same text measures elsewhere. +struct TextMeasurement { + label: String, + kind: &'static str, + preview: String, + font_size: f32, + natural_width: f32, + natural_height: f32, + autofit: bool, +} + +impl TextMeasurement { + fn describe(&self) -> String { + let autofit_note = if self.autofit { + " (text-autofit: true — shrinks further if its box is smaller than this)" + } else { + "" + }; + format!( + "{}: {} \"{}\" @ {:.0}px → natural {:.0}×{:.0}px{}", + self.label, + self.kind, + self.preview, + self.font_size, + self.natural_width, + self.natural_height, + autofit_note, + ) + } +} + +fn collect_text_measurements(scenario: &ResolvedScenario) -> Vec { + let mut out = Vec::new(); + for (vi, view) in scenario.views.iter().enumerate() { + for (si, scene) in view.scenes.iter().enumerate() { + let children = deserialize_children(scene); + let path = format!("view {} / scene {}", vi + 1, si + 1); + collect_text_measurements_in_children(&children, &path, &mut out); + } + } + out +} + +fn collect_text_measurements_in_children( + children: &[ChildComponent], + path: &str, + out: &mut Vec, +) { + let natural = (AvailableSpace::MaxContent, AvailableSpace::MaxContent); + for (i, child) in children.iter().enumerate() { + let p = format!("{path} / layer {}", i + 1); + match &child.component { + Component::Text(t) => { + let (w, h) = TextIntrinsic::from_text(t).measure((None, None), natural); + out.push(TextMeasurement { + label: p.clone(), + kind: "text", + preview: preview(&t.content), + font_size: t.style.font_size_px_or(48.0), + natural_width: w, + natural_height: h, + autofit: matches!(t.style.text_autofit, Some(true)), + }); + } + Component::GradientText(t) => { + let (w, h) = + GradientTextIntrinsic::from_gradient_text(t).measure((None, None), natural); + out.push(TextMeasurement { + label: p.clone(), + kind: "gradient_text", + preview: preview(&t.content), + font_size: t.style.font_size_px_or(48.0), + natural_width: w, + natural_height: h, + autofit: matches!(t.style.text_autofit, Some(true)), + }); + } + _ => {} + } + match &child.component { + Component::Card(c) => collect_text_measurements_in_children(&c.children, &p, out), + Component::Flex(c) => collect_text_measurements_in_children(&c.children, &p, out), + Component::Grid(c) => collect_text_measurements_in_children(&c.children, &p, out), + Component::Positioned(c) => collect_text_measurements_in_children(&c.children, &p, out), + Component::Container(c) => collect_text_measurements_in_children(&c.children, &p, out), + _ => {} + } + } +} + +/// Truncate a long content string for a single-line report — the full +/// content is already visible in the source scenario file; this is a label, +/// not a transcript. +fn preview(content: &str) -> String { + const MAX_CHARS: usize = 40; + let char_count = content.chars().count(); + if char_count <= MAX_CHARS { + content.to_string() + } else { + let truncated: String = content.chars().take(MAX_CHARS).collect(); + format!("{truncated}…") + } +} + /// Where a `SpringConfig` was found, and the settle time computed for it — /// the "measure du repos" issue #167 lot E asks `rustmotion info` to /// surface, so an author can size the enclosing animation's `duration` diff --git a/crates/rustmotion-components/src/gradient_text.rs b/crates/rustmotion-components/src/gradient_text.rs index cfb68c0..47aa2d4 100644 --- a/crates/rustmotion-components/src/gradient_text.rs +++ b/crates/rustmotion-components/src/gradient_text.rs @@ -56,13 +56,12 @@ rustmotion_core::impl_traits!(GradientText { }); impl GradientText { - /// `font_size` is resolved once by the caller (`paint`, against a real - /// `LengthContext`) and passed in here — this used to independently - /// recompute it via the context-free `font_size_px_or`, a second site - /// that could silently diverge from `paint`'s own resolution once one of - /// the two learned to handle relative units and the other didn't (lot B, - /// wave S). - fn resolve_font(&self, font_size: f32) -> Option<(Font, Option)> { + /// Family/weight/style resolution only, independent of `font_size` — so + /// `text-autofit` (below) can build a fresh `Font` at each candidate + /// size from the same resolved typeface without re-resolving the + /// family/weight/style lookup per candidate. `paint` builds the actual + /// `Font` itself once the final (possibly autofit-shrunk) size is known. + fn resolve_typeface(&self) -> Option { let font_family = self.style.font_family_or("Inter"); let slant = match self.style.font_style { @@ -79,16 +78,19 @@ impl GradientText { }; let skia_style = FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant); - let typeface = typeface_with_fallback(font_family, skia_style).ok()?; - - let font = Font::from_typeface(typeface, font_size); - let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size)); - Some((font, emoji_font)) + typeface_with_fallback(font_family, skia_style).ok() } } impl GradientText { - fn paint(&self, canvas: &Canvas, layout_width: f32, time: f64, ctx: &PaintCtx) { + fn paint( + &self, + canvas: &Canvas, + layout_width: f32, + content_height: Option, + time: f64, + ctx: &PaintCtx, + ) { if self.content.is_empty() || self.colors.is_empty() { return; } @@ -104,8 +106,8 @@ impl GradientText { ctx.video_height as f32, layout_width.max(0.0), ); - let font_size = self.style.font_size_px_ctx(&base_ctx, 48.0); - let Some((font, emoji_font)) = self.resolve_font(font_size) else { + let mut font_size = self.style.font_size_px_ctx(&base_ctx, 48.0); + let Some(typeface) = self.resolve_typeface() else { return; }; // `letter-spacing`/`line-height`'s `em`/`%` are relative to this @@ -122,23 +124,46 @@ impl GradientText { font_size, ..base_ctx }; - let line_height_val = self.style.line_height_for_ctx(font_size, &type_ctx); - let letter_spacing = self.style.letter_spacing_px_ctx(&type_ctx); + let mut line_height_val = self.style.line_height_for_ctx(font_size, &type_ctx); + let mut letter_spacing = self.style.letter_spacing_px_ctx(&type_ctx); // M1: `white-space: nowrap|pre` keeps the whole content on one line // even past `layout_width` (it bleeds); anything else word-wraps at - // the box width — same rule `text.rs` uses. + // the box width — same rule `text.rs` uses. `gradient_text` has no + // `max_width` field of its own (unlike `text`/`caption`), so its box + // width is simply the resolved layout width, if any. let nowrap = matches!( self.style.white_space, Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre) ); - let wrap_at = if nowrap { - None - } else if layout_width.is_finite() && layout_width > 0.0 { - Some(layout_width) - } else { - None - }; + let box_width = (layout_width.is_finite() && layout_width > 0.0).then_some(layout_width); + let wrap_at = if nowrap { None } else { box_width }; + + // `text-autofit` — see `text.rs::Text::paint`'s identical step and + // `resolve_text_autofit`'s doc comment for the shared-computation + // parity argument with `TextIntrinsic::measure`. Resolved before + // building the real `Font` below so the rest of this function draws + // at the (possibly shrunk) resolved size. + if matches!(self.style.text_autofit, Some(true)) { + let declared_height = content_height.filter(|h| *h > 0.0 && h.is_finite()); + let (fs, ls, lh) = crate::intrinsic::resolve_text_autofit( + &self.content, + &typeface, + font_size, + letter_spacing, + line_height_val, + !nowrap, + box_width, + declared_height, + ); + font_size = fs; + letter_spacing = ls; + 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)); + // Tracking-aware wrap (issue #125 §1), consistent with the // real-tracking measurement/draw below. let lines = @@ -241,7 +266,12 @@ impl Painter for GradientText { _props: &AnimatedProperties, ctx: &PaintCtx, ) { - self.paint(canvas, layout.width, ctx.time, ctx); + // See `Text::paint_content`'s identical step for why `None` here + // means "nothing to fit against" rather than "fit to zero". + let (_, _, _, content_height) = layout.content_box(); + let content_height = + (content_height > 0.0 && content_height.is_finite()).then_some(content_height); + self.paint(canvas, layout.width, content_height, ctx.time, ctx); } } @@ -326,7 +356,7 @@ mod tests { const H: i32 = 200; let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); let canvas = surface.canvas(); - gt.paint(canvas, 80.0, 0.0, &test_ctx()); + gt.paint(canvas, 80.0, None, 0.0, &test_ctx()); let grid = alpha_grid(&mut surface, W, H); assert!( @@ -346,7 +376,7 @@ mod tests { const H: i32 = 200; let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); let canvas = surface.canvas(); - gt.paint(canvas, 80.0, 0.0, &test_ctx()); + gt.paint(canvas, 80.0, None, 0.0, &test_ctx()); let grid = alpha_grid(&mut surface, W, H); assert!( @@ -382,7 +412,7 @@ mod tests { const H: i32 = 200; let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); let canvas = surface.canvas(); - gt.paint(canvas, 300.0, 0.0, &test_ctx()); + gt.paint(canvas, 300.0, None, 0.0, &test_ctx()); let grid = alpha_grid(&mut surface, W, H); assert!( @@ -390,4 +420,122 @@ mod tests { "gradient_text at font-size: 2rem must paint visible ink" ); } + + // ─── text-autofit ─────────────────────────────────────────────────── + + fn autofit_gradient_text( + content: &str, + font_size: f32, + white_space: Option, + ) -> GradientText { + let mut gt = make_gradient_text(content, white_space); + gt.style.font_size = Some(Length::Px(font_size)); + gt.style.text_autofit = Some(true); + gt + } + + fn max_ink_x(grid: &[u8], surface_width: i32, height: i32) -> Option { + let mut max_x: Option = None; + for y in 0..height { + for x in (0..surface_width).rev() { + if grid[(y * surface_width + x) as usize] > 0 { + max_x = Some(max_x.map_or(x, |m| m.max(x))); + break; + } + } + } + max_x + } + + #[test] + fn autofit_shrinks_a_nowrap_line_to_fit_and_paint_agrees_with_measure() { + use crate::intrinsic::GradientTextIntrinsic; + use rustmotion_core::engine::box_tree::{AvailableSpace, IntrinsicMeasure}; + + let gt = autofit_gradient_text( + "the quick brown fox jumps over the lazy dog", + 90.0, + Some(CssWhiteSpace::Nowrap), + ); + const BOX_W: f32 = 300.0; // clears this sentence's floor-fit width + + let (measured_w, _) = GradientTextIntrinsic::from_gradient_text(>).measure( + (None, None), + (AvailableSpace::Definite(BOX_W), AvailableSpace::MaxContent), + ); + assert!( + measured_w <= BOX_W + 0.5, + "GradientTextIntrinsic itself must report a fit once autofit is on, got {measured_w}" + ); + + const W: i32 = 900; + const H: i32 = 300; + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + let canvas = surface.canvas(); + gt.paint(canvas, BOX_W, None, 0.0, &test_ctx()); + let grid = alpha_grid(&mut surface, W, H); + let ink_right = max_ink_x(&grid, W, H).expect("gradient_text must paint some ink"); + + assert!( + (ink_right as f32) <= measured_w + 3.0, + "painted ink (right edge {ink_right}) must not exceed the box the intrinsic reserved \ + ({measured_w})" + ); + assert!( + (ink_right as f32) >= measured_w - 15.0, + "painted ink (right edge {ink_right}) should land close to the measured width \ + ({measured_w}) — a big gap means measure and paint disagree on the resolved size" + ); + } + + #[test] + fn without_text_autofit_nowrap_still_bleeds_past_the_box_exactly_as_before() { + // Backward compatibility twin of `nowrap_paints_a_single_line_past_the_layout_width`, + // now also passing a real `content_height` — proves that alone + // doesn't trigger shrinking without the flag. + let gt = make_gradient_text( + "the quick brown fox jumps over the lazy dog", + Some(CssWhiteSpace::Nowrap), + ); + const W: i32 = 600; + const H: i32 = 200; + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + let canvas = surface.canvas(); + gt.paint(canvas, 80.0, Some(45.0), 0.0, &test_ctx()); + let grid = alpha_grid(&mut surface, W, H); + + assert!( + has_ink_in(&grid, W, 300, W, 0, 45), + "without text-autofit, nowrap gradient_text must still bleed past its box" + ); + } + + #[test] + fn autofit_is_stable_across_frames_for_fixed_content() { + // Trap #2: `angle` can animate over time via `animate_angle`, but + // here it's static — the resolved font size must not depend on + // `time` at all for fixed content in a fixed box. + let gt = autofit_gradient_text( + "the quick brown fox jumps over the lazy dog", + 90.0, + Some(CssWhiteSpace::Nowrap), + ); + const W: i32 = 900; + const H: i32 = 300; + + let render_at = |t: f64| -> Vec { + let mut surface = + skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + let canvas = surface.canvas(); + gt.paint(canvas, 300.0, Some(60.0), t, &test_ctx()); + alpha_grid(&mut surface, W, H) + }; + + let frame_a = render_at(0.0); + let frame_b = render_at(0.9); + assert_eq!( + frame_a, frame_b, + "fixed content in a fixed box must render byte-identically regardless of ctx.time" + ); + } } diff --git a/crates/rustmotion-components/src/intrinsic.rs b/crates/rustmotion-components/src/intrinsic.rs index ac5e7b9..5ca2571 100644 --- a/crates/rustmotion-components/src/intrinsic.rs +++ b/crates/rustmotion-components/src/intrinsic.rs @@ -5,11 +5,11 @@ //! would otherwise cause text to wrap onto an extra line at paint time and //! overflow into the next sibling. -use skia_safe::{Font, FontStyle as SkFontStyle}; +use skia_safe::{Font, FontStyle as SkFontStyle, Typeface}; use rustmotion_core::css::style::{ CssStyle, FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw, LineHeight, - WhiteSpace, + WhiteSpace, TEXT_AUTOFIT_MIN_FONT_PX, }; use rustmotion_core::engine::box_tree::{AvailableSpace, IntrinsicMeasure}; use rustmotion_core::engine::renderer::{ @@ -104,6 +104,13 @@ pub struct TextIntrinsic { letter_spacing: f32, max_width: Option, wrap: bool, + /// `style.text-autofit == Some(true)`, but only ever set by + /// [`Self::from_text`] / [`GradientTextIntrinsic::from_gradient_text`] — + /// see [`Self::with_autofit`]'s doc comment for why `from_parts`/ + /// `from_parts_with_wrap` (shared by `Caption`/`Kbd`/`Badge`/`Counter`, + /// none of whose painters read `text-autofit`) must never set this from + /// `style` directly. + text_autofit: bool, } impl TextIntrinsic { @@ -120,6 +127,24 @@ impl TextIntrinsic { Some(WhiteSpace::Nowrap | WhiteSpace::Pre) ); Self::from_parts_with_wrap(&text.content, &text.style, text.max_width, wrap) + .with_autofit(matches!(text.style.text_autofit, Some(true))) + } + + /// Opt this instance into `text-autofit`. Deliberately a separate, + /// explicit step rather than something `from_parts`/`from_parts_with_wrap` + /// read off `style` themselves: those two constructors are shared by + /// every atomic/synthetic-style caller in this file (`Caption`, `Kbd`, + /// `Badge`, `Counter`) whose *painters* have no idea `text-autofit` + /// exists — if the flag leaked in through the shared style, `measure()` + /// would shrink the reserved box for one of those while the painter + /// went on drawing at the full requested size, which is exactly the + /// measure-vs-paint divergence this feature exists to prevent, not + /// reintroduce elsewhere. Only [`Self::from_text`] and + /// [`GradientTextIntrinsic::from_gradient_text`] call this, matching the + /// two painters (`Text`, `GradientText`) that actually implement it. + pub fn with_autofit(mut self, on: bool) -> Self { + self.text_autofit = on; + self } /// Generic constructor shared by [`GradientText`]/[`Caption`] intrinsics, @@ -163,6 +188,7 @@ impl TextIntrinsic { letter_spacing, max_width, wrap: true, + text_autofit: false, } } @@ -202,49 +228,262 @@ impl IntrinsicMeasure for TextIntrinsic { } }; - let Some(font) = self.skia_font() else { + let Some(typeface) = self.typeface() else { return (0.0, 0.0); }; - let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, self.font_size)); - let wrap_at = if self.wrap { max_width } else { None }; - // Tracking-aware wrap (issue #125 §1): matches the real - // `letter_spacing` used to measure each line's width just below, so - // the box this measurer reserves and what `Text::paint` (also fixed, - // same tracking) actually paints agree on line count. - let lines = wrap_text_with_tracking( + let (base_w, base_h) = wrap_and_measure( &self.content, - &font, - &emoji_font, + &typeface, + self.font_size, wrap_at, self.letter_spacing, + self.line_height_resolved, + ); + + if !self.text_autofit { + return (base_w, base_h); + } + + // Height target: mirrors the `known`/`available` merge above for + // width — taffy hands a leaf its own `known`/`available` height + // already padding/border-subtracted (content-box space) whenever + // the node's own box resolves to a *definite* height, exactly the + // same protocol it uses for width. No separate hand-rolled read of + // `style.height` here: reusing this signal is what guarantees this + // agrees with `Text::paint`'s `content_height` (from the *same* + // taffy-resolved `BoxLayout::content_box()`, post-layout) — see + // `CssStyle::text_autofit`'s doc comment. + let target_height = match known.1 { + Some(h) => Some(h), + None => match available.1 { + AvailableSpace::Definite(h) => Some(h), + AvailableSpace::MaxContent => None, + AvailableSpace::MinContent => Some(0.0), + }, + }; + + let (final_size, final_ls, final_lh) = resolve_text_autofit( + &self.content, + &typeface, + self.font_size, + self.letter_spacing, + self.line_height_resolved, + self.wrap, + max_width, + target_height, ); - let mut max_w = 0.0f32; - for line in &lines { - let w = measure_text_with_fallback(line, &font, &emoji_font, self.letter_spacing); - max_w = max_w.max(w); + if final_size >= self.font_size { + return (base_w, base_h); } - let line_count = lines.len().max(1) as f32; - (max_w, line_count * self.line_height_resolved) + let wrap_at = if self.wrap { max_width } else { None }; + wrap_and_measure( + &self.content, + &typeface, + final_size, + wrap_at, + final_ls, + final_lh, + ) } } impl TextIntrinsic { - fn skia_font(&self) -> Option { + fn sk_font_style(&self) -> SkFontStyle { let slant = if self.italic { skia_safe::font_style::Slant::Italic } else { skia_safe::font_style::Slant::Upright }; let weight = skia_safe::font_style::Weight::from(self.weight as i32); - let sk_style = SkFontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant); + SkFontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant) + } + + fn typeface(&self) -> Option { let family = self.font_family.as_deref().unwrap_or("Inter"); - let typeface = typeface_with_fallback(family, sk_style).ok()?; - Some(Font::from_typeface(typeface, self.font_size)) + typeface_with_fallback(family, self.sk_font_style()).ok() + } +} + +/// Wrap `content` at `font_size` (with `letter_spacing`/`line_height` +/// already resolved for that size) and return its `(max_line_width, +/// total_height)` — the single wrap+measure routine `TextIntrinsic::measure` +/// calls for both its base (requested-size) and, when `text-autofit` shrinks +/// it, its final (resolved-size) measurement, so the two never drift apart +/// from hand-duplicated logic. +fn wrap_and_measure( + content: &str, + typeface: &Typeface, + font_size: f32, + wrap_at: Option, + 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)); + // Tracking-aware wrap (issue #125 §1): matches the real `letter_spacing` + // used to measure each line's width just below, so the box this + // measurer reserves and what the painter (also tracking-aware) actually + // paints agree on line count. + let lines = wrap_text_with_tracking(content, &font, &emoji_font, wrap_at, letter_spacing); + let mut max_w = 0.0f32; + for line in &lines { + max_w = max_w.max(measure_text_with_fallback( + line, + &font, + &emoji_font, + letter_spacing, + )); + } + let line_count = lines.len().max(1) as f32; + (max_w, line_count * line_height) +} + +/// `text-autofit`'s shared shrink resolution — the single computation +/// `TextIntrinsic::measure` and `Text`/`GradientText`'s painters all call +/// with identical inputs, so the resolved size can never disagree between +/// the box taffy reserves and the pixels actually painted into it (see +/// `CssStyle::text_autofit`'s doc comment — this exact class of bug is what +/// this workstream exists to close, not reopen). +/// +/// Pure and stateless: the same `(content, typeface, requested_font_size, +/// requested_letter_spacing, requested_line_height, wrap, box_width, +/// declared_height)` always produces the same `(font_size, letter_spacing, +/// line_height)`. That purity is *why* calling this fresh every paint call +/// (frame) is stable rather than something that needs caching — see the two +/// call sites' comments for what does and does not change frame to frame. +/// The one input this deliberately never sees is the paint-time typewriter +/// reveal (`AnimatedProperties::visible_chars_progress`): both call sites +/// pass the full, untruncated content, so a reveal-in-progress can't make +/// the resolved size drift as more characters become visible. +/// +/// `letter_spacing`/`line_height` are rescaled proportionally with the +/// chosen font size (`requested * chosen/requested`) rather than +/// re-resolved from the original CSS declaration at each candidate size. +/// This matches CSS exactly for the common declarations (a unitless +/// `line-height` number, `%`/`em` line-height, or the engine's `1.3×` +/// default all scale linearly with font-size by definition) and is a +/// deliberate approximation for the rare case of an absolute +/// (`px`/`rem`/`vw`/`vh`) `line-height`/`letter-spacing`, which CSS says +/// should stay fixed regardless of font-size — getting that exactly right +/// needs threading the full `CssStyle` (not just its already-resolved +/// scalars) through both call sites, out of scope here. +/// +/// `box_width`/`declared_height`: `None` means nothing to fit against on +/// that axis (an unconstrained box cannot overflow); returns +/// `requested_font_size` unchanged, without measuring anything, when both +/// are `None`. +#[allow(clippy::too_many_arguments)] +pub fn resolve_text_autofit( + content: &str, + typeface: &Typeface, + requested_font_size: f32, + requested_letter_spacing: f32, + requested_line_height: f32, + wrap: bool, + box_width: Option, + declared_height: Option, +) -> (f32, f32, f32) { + if requested_font_size <= 0.0 || (box_width.is_none() && declared_height.is_none()) { + return ( + requested_font_size, + requested_letter_spacing, + requested_line_height, + ); + } + let wrap_at = if wrap { box_width } else { None }; + let measure_at = |size: f32| -> (f32, f32) { + let ratio = size / requested_font_size; + wrap_and_measure( + content, + typeface, + size, + wrap_at, + requested_letter_spacing * ratio, + requested_line_height * ratio, + ) + }; + let floor = TEXT_AUTOFIT_MIN_FONT_PX.min(requested_font_size); + let final_size = shrink_to_fit( + requested_font_size, + floor, + box_width, + declared_height, + measure_at, + ); + if final_size >= requested_font_size { + ( + requested_font_size, + requested_letter_spacing, + requested_line_height, + ) + } else { + let ratio = final_size / requested_font_size; + ( + final_size, + requested_letter_spacing * ratio, + requested_line_height * ratio, + ) } } +/// Binary-search the largest font size in `[floor_px, requested_font_size]` +/// whose `measure_at(size)` fits within `(target_width, target_height)` +/// (either bound `None` = no constraint on that axis). Assumes `measure_at` +/// is monotonically non-increasing as `size` shrinks — true for real text: +/// smaller glyphs measure narrower, and a fixed pixel wrap width can only +/// need the same or fewer lines as glyphs get smaller. 16 halvings of the +/// search range give sub-0.01px precision for any realistic font size — this +/// is a visual convenience, not a geometry-critical value, so that precision +/// is far more than needed. +/// +/// Never returns below `floor_px`: if the content doesn't fit there either, +/// `floor_px` is returned anyway — illegible-but-smallest beats an even +/// larger overflow — and the caller's own overflow signal (the geometry +/// validator's `ContentOverflowsBox`) is left to fire. This function never +/// silences that; it only tries to make it unnecessary. +fn shrink_to_fit( + requested_font_size: f32, + floor_px: f32, + target_width: Option, + target_height: Option, + mut measure_at: impl FnMut(f32) -> (f32, f32), +) -> f32 { + let eps = 0.5; + let fits = |w: f32, h: f32| { + target_width.is_none_or(|tw| w <= tw + eps) && target_height.is_none_or(|th| h <= th + eps) + }; + + let (w0, h0) = measure_at(requested_font_size); + if fits(w0, h0) { + return requested_font_size; + } + + let floor_px = floor_px.min(requested_font_size).max(0.1); + if floor_px >= requested_font_size { + return requested_font_size; + } + + let (mut lo, mut hi) = (floor_px, requested_font_size); + let (w_floor, h_floor) = measure_at(lo); + if !fits(w_floor, h_floor) { + // Doesn't fit even at the floor — stop there and let the caller's + // own overflow check fire; see this function's doc comment. + return lo; + } + for _ in 0..16 { + let mid = (lo + hi) / 2.0; + let (w, h) = measure_at(mid); + if fits(w, h) { + lo = mid; + } else { + hi = mid; + } + } + lo +} + fn weight_to_u16(w: Option<&CssFontWeight>) -> u16 { match w { Some(CssFontWeight::Keyword(FontWeightKw::Bold)) => 700, @@ -276,9 +515,10 @@ impl GradientTextIntrinsic { t.style.white_space, Some(WhiteSpace::Nowrap | WhiteSpace::Pre) ); - Self(TextIntrinsic::from_parts_with_wrap( - &t.content, &t.style, max_width, wrap, - )) + Self( + TextIntrinsic::from_parts_with_wrap(&t.content, &t.style, max_width, wrap) + .with_autofit(matches!(t.style.text_autofit, Some(true))), + ) } } @@ -1328,4 +1568,300 @@ mod tests { zero={w_zero_tracking}" ); } + + // ─── text-autofit: `shrink_to_fit` (pure binary search, no Skia) ─────── + + #[test] + fn shrink_to_fit_is_a_noop_when_content_already_fits() { + let calls = std::cell::RefCell::new(Vec::new()); + let size = shrink_to_fit(48.0, 12.0, Some(200.0), Some(100.0), |s| { + calls.borrow_mut().push(s); + (150.0, 80.0) + }); + assert_eq!(size, 48.0); + assert_eq!( + *calls.borrow(), + vec![48.0], + "must measure only once (at the requested size) when it already fits" + ); + } + + #[test] + fn shrink_to_fit_is_a_noop_when_nothing_to_fit_against() { + // Both axes unconstrained: no target to shrink for, regardless of + // what `measure_at` reports. + let size = shrink_to_fit(48.0, 12.0, None, None, |_| (99999.0, 99999.0)); + assert_eq!(size, 48.0); + } + + #[test] + fn shrink_to_fit_finds_a_size_that_fits_the_width_target() { + // Fake linear model (width = size * 2), matching how real glyph + // widths scale roughly linearly with font size. + let target = 100.0; + let size = shrink_to_fit(120.0, 5.0, Some(target), None, |s| (s * 2.0, 10.0)); + assert!(size < 120.0, "must have shrunk, got {size}"); + assert!(size * 2.0 <= target + 0.5, "resolved size must fit: {size}"); + // And it's close to the true boundary, not grossly under-shrunk: one + // more px would no longer fit. + assert!( + (size + 1.0) * 2.0 > target + 0.5, + "resolved size should be close to the fitting boundary, got {size}" + ); + } + + #[test] + fn shrink_to_fit_respects_both_axes_jointly() { + // Width alone would allow a much bigger size than height alone — + // the chosen size must satisfy the tighter of the two. + let size = shrink_to_fit(100.0, 5.0, Some(1000.0), Some(20.0), |s| (s, s * 2.0)); + assert!(size * 2.0 <= 20.5, "must respect the height target: {size}"); + assert!( + (size + 0.5) * 2.0 > 20.5, + "should converge close to the height boundary, got {size}" + ); + } + + #[test] + fn shrink_to_fit_never_returns_below_the_floor() { + // Content that never fits even at the floor: must stop exactly + // there, not silence the overflow by continuing to shrink. + let size = shrink_to_fit(120.0, 20.0, Some(10.0), None, |s| (s * 5.0, 10.0)); + assert_eq!(size, 20.0, "must stop exactly at the floor, not lower"); + } + + #[test] + fn shrink_to_fit_is_deterministic_across_repeated_calls() { + // Same inputs, same deterministic binary search → same output every + // time. This is the purity property the temporal-stability argument + // (see `resolve_text_autofit`'s doc comment) rests on: nothing here + // depends on when or how many times it's called. + let run = || shrink_to_fit(90.0, 10.0, Some(137.0), Some(64.0), |s| (s * 1.7, s * 0.9)); + let a = run(); + let b = run(); + assert_eq!(a, b); + } + + // ─── text-autofit: `resolve_text_autofit` (real Skia fonts) ──────────── + + fn inter_typeface() -> Typeface { + typeface_with_fallback("Inter", SkFontStyle::normal()).expect("Inter resolves in tests") + } + + #[test] + fn resolve_text_autofit_shrinks_to_fit_a_width_target() { + let typeface = inter_typeface(); + let content = "A very long headline that will not fit in this box"; + let requested = 80.0; + let box_width = 300.0; + let (fs, ls, lh) = resolve_text_autofit( + content, + &typeface, + requested, + 0.0, + requested * 1.3, + false, // nowrap: single line + Some(box_width), + None, + ); + assert!(fs < requested, "must shrink, got {fs}"); + assert!( + fs >= TEXT_AUTOFIT_MIN_FONT_PX - 0.01, + "must not shrink past the calibrated floor, got {fs}" + ); + // Prove the resolved size actually fits when wrapped/measured the + // same way the caller will — not just that a smaller number came out. + let (w, _) = wrap_and_measure(content, &typeface, fs, None, ls, lh); + assert!( + w <= box_width + 0.5, + "resolved size must actually fit: w={w}, target={box_width}" + ); + } + + #[test] + fn resolve_text_autofit_is_a_noop_when_it_already_fits() { + let typeface = inter_typeface(); + let (fs, ls, lh) = resolve_text_autofit( + "hi", + &typeface, + 24.0, + 1.0, + 30.0, + true, + Some(1000.0), + Some(1000.0), + ); + assert_eq!(fs, 24.0); + assert_eq!(ls, 1.0); + assert_eq!(lh, 30.0); + } + + #[test] + fn resolve_text_autofit_never_goes_below_the_calibrated_floor() { + let typeface = inter_typeface(); + // Absurdly small box: even the floor doesn't fit, but the function + // must still stop exactly at the floor. + let (fs, _, _) = resolve_text_autofit( + "This sentence is far too long for a ten pixel wide box", + &typeface, + 80.0, + 0.0, + 104.0, + true, + Some(10.0), + Some(10.0), + ); + assert!( + (fs - TEXT_AUTOFIT_MIN_FONT_PX).abs() < 0.01, + "expected exactly the floor ({TEXT_AUTOFIT_MIN_FONT_PX}), got {fs}" + ); + } + + #[test] + fn resolve_text_autofit_rescales_letter_spacing_and_line_height_proportionally() { + let typeface = inter_typeface(); + let (fs, ls, lh) = resolve_text_autofit( + "SHRINK ME PLEASE, THIS LINE IS QUITE LONG", + &typeface, + 100.0, + 5.0, + 130.0, + false, + Some(150.0), + None, + ); + assert!(fs < 100.0, "sanity: must have shrunk, got {fs}"); + let ratio = fs / 100.0; + assert!((ls - 5.0 * ratio).abs() < 1e-3); + assert!((lh - 130.0 * ratio).abs() < 1e-3); + } + + // ─── text-autofit: `TextIntrinsic` end to end ────────────────────────── + + fn autofit_text(content: &str, font_size: f32) -> Text { + Text { + content: content.into(), + max_width: None, + timing: Default::default(), + style: CssStyle { + font_size: Some(Length::Px(font_size)), + text_autofit: Some(true), + white_space: Some(WhiteSpace::Nowrap), + ..Default::default() + }, + timeline: Vec::new(), + stagger: None, + text_shadow: None, + stroke: None, + text_background: None, + } + } + + #[test] + fn text_intrinsic_shrinks_when_autofit_is_on_and_the_box_is_too_narrow() { + let text = autofit_text("the quick brown fox jumps over the lazy dog", 60.0); + let m = TextIntrinsic::from_text(&text); + let (w_unconstrained, _) = m.measure( + (None, None), + (AvailableSpace::MaxContent, AvailableSpace::MaxContent), + ); + // A box at half the natural width needs roughly a ~50% size + // reduction — comfortably above the legibility floor for a 60px + // request, so this exercises the "shrinks and fits" path distinctly + // from `..._still_overflows_when_even_the_floor_does_not_fit` below + // (which drives it all the way to the floor on purpose). Derived + // from the actual measured natural width rather than a hardcoded + // px guess, so it isn't sensitive to exactly which glyph widths + // this font ships. + let target = w_unconstrained / 2.0; + let (w_constrained, _) = m.measure( + (None, None), + (AvailableSpace::Definite(target), AvailableSpace::MaxContent), + ); + assert!( + w_constrained <= target + 0.5, + "autofit must shrink the nowrap line to fit {target}px, got {w_constrained}" + ); + assert!( + w_constrained < w_unconstrained, + "must have actually shrunk from the natural width ({w_unconstrained}), got {w_constrained}" + ); + } + + #[test] + fn text_intrinsic_ignores_autofit_target_when_the_flag_is_off() { + let mut text = autofit_text("the quick brown fox jumps over the lazy dog", 60.0); + text.style.text_autofit = None; + let m = TextIntrinsic::from_text(&text); + let (w, _) = m.measure( + (None, None), + (AvailableSpace::Definite(200.0), AvailableSpace::MaxContent), + ); + assert!( + w > 200.0, + "without text-autofit, nowrap must still bleed past the box exactly as before, got {w}" + ); + } + + #[test] + fn text_intrinsic_autofit_still_overflows_when_even_the_floor_does_not_fit() { + let text = autofit_text( + "This is an extremely long sentence that will not fit no matter how much the font shrinks", + 80.0, + ); + let m = TextIntrinsic::from_text(&text); + let (w, _) = m.measure( + (None, None), + (AvailableSpace::Definite(5.0), AvailableSpace::MaxContent), + ); + assert!( + w > 5.0, + "must not silently report a fit that never actually happened, got {w}" + ); + } + + #[test] + fn caption_intrinsic_never_autofits_even_if_style_declares_it() { + // Regression guard for the leak this feature must not reintroduce: + // `Caption`'s painter has no idea `text-autofit` exists (only + // `Text`/`GradientText`'s do), so its intrinsic must never shrink + // because of it, even if the field is present in `style` — see + // `TextIntrinsic::with_autofit`'s doc comment. + let caption = Caption { + words: "the quick brown fox jumps over the lazy dog" + .split_whitespace() + .map(|w| rustmotion_core::schema::CaptionWord { + text: w.to_string(), + start: 0.0, + end: 10.0, + }) + .collect(), + active_color: "#FFFF00".into(), + mode: Default::default(), + max_width: None, + pill_color: None, + style: CssStyle { + font_size: Some(Length::Px(60.0)), + text_autofit: Some(true), + white_space: Some(WhiteSpace::Nowrap), + ..Default::default() + }, + timing: Default::default(), + timeline: Vec::new(), + stagger: None, + }; + let m = CaptionIntrinsic::from_caption(&caption); + let (w_unconstrained, _) = m.measure( + (None, None), + (AvailableSpace::MaxContent, AvailableSpace::MaxContent), + ); + let (w_constrained, _) = m.measure( + (None, None), + (AvailableSpace::Definite(200.0), AvailableSpace::MaxContent), + ); + assert_eq!( + w_constrained, w_unconstrained, + "caption must ignore text-autofit entirely (nowrap bleeds exactly as before)" + ); + } } diff --git a/crates/rustmotion-components/src/text.rs b/crates/rustmotion-components/src/text.rs index 3efc3a9..e1b4763 100644 --- a/crates/rustmotion-components/src/text.rs +++ b/crates/rustmotion-components/src/text.rs @@ -356,6 +356,7 @@ impl Text { &self, canvas: &Canvas, layout_width: f32, + content_height: Option, time: f64, props: &AnimatedProperties, ctx: &PaintCtx, @@ -383,16 +384,8 @@ impl Text { ctx.video_height as f32, layout_width.max(0.0), ); - let (font_size, letter_spacing, line_height_val) = + let (mut font_size, mut letter_spacing, mut line_height_val) = self.style.typography_px_ctx(&base_ctx, 48.0); - // This element's *own* resolved font-size as the `em`/`%` base — - // needed below for `text-shadow` (its blur/offset are relative to - // the shadow owner's own font-size, same rule as letter-spacing/ - // line-height, not the parent-proxy `base_ctx` above). - let type_ctx = rustmotion_core::css::units::LengthContext { - font_size, - ..base_ctx - }; // Animated color (timeline style-state transitions) overrides the // static style color. let color = props @@ -433,33 +426,75 @@ impl Text { 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 mut paint = paint_from_hex(color); - paint.set_alpha_f(1.0); - - // Use layout width as wrapping constraint, combined with max_width. - // M1: `white-space: nowrap|pre` disables wrapping entirely — the - // line may then exceed `layout_width`. That's the point: it makes - // the property mean something, and it's exactly the condition the - // geometry validator's `unwrappable_text_overflow` check (which - // re-measures via `TextIntrinsic::from_text`, now wrap-aware too) - // assumes the renderer can produce. + // The box's own resolved width — computed here (ahead of the + // `white-space: nowrap` wrap decision below) because `text-autofit` + // needs it as its width-fit target *regardless* of nowrap: a nowrap + // line still shrinks to fit this box once `text-autofit` is on (see + // `CssStyle::text_autofit`'s doc comment), it just never breaks + // across lines while doing it. let nowrap = matches!( self.style.white_space, Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre) ); - let wrap_width = if nowrap { - None - } else if layout_width.is_finite() && layout_width > 0.0 { - match self.max_width { - Some(mw) => Some(mw.min(layout_width)), - None => Some(layout_width), - } + let box_width = if layout_width.is_finite() && layout_width > 0.0 { + Some(match self.max_width { + Some(mw) => mw.min(layout_width), + None => layout_width, + }) } else { self.max_width }; + // `text-autofit`: resolve the *actual* font-size/letter-spacing/ + // line-height used for the rest of this function — the identical + // computation `TextIntrinsic::measure` runs for this same node (see + // `resolve_text_autofit`'s doc comment for the parity argument). + // Must happen before `type_ctx`/the final `Font` are built below so + // both reflect the resolved (possibly shrunk) size, not the + // requested one. + if matches!(self.style.text_autofit, Some(true)) { + let declared_height = content_height.filter(|h| *h > 0.0 && h.is_finite()); + let (fs, ls, lh) = crate::intrinsic::resolve_text_autofit( + &self.content, + &typeface, + font_size, + letter_spacing, + line_height_val, + !nowrap, + box_width, + declared_height, + ); + font_size = fs; + letter_spacing = ls; + line_height_val = lh; + } + + // This element's *own* resolved font-size as the `em`/`%` base — + // needed below for `text-shadow` (its blur/offset are relative to + // the shadow owner's own font-size, same rule as letter-spacing/ + // line-height, not the parent-proxy `base_ctx` above). Built from + // the post-autofit `font_size` so a shrunk headline's shadow shrinks + // with it instead of using the pre-shrink em/% base. + let type_ctx = rustmotion_core::css::units::LengthContext { + font_size, + ..base_ctx + }; + + 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); + + // Use the box width as the wrapping constraint (computed above, as + // `box_width`, ahead of the autofit step). M1: `white-space: + // nowrap|pre` disables wrapping entirely — the line may then exceed + // `layout_width`. That's the point: it makes the property mean + // something, and it's exactly the condition the geometry + // validator's `unwrappable_text_overflow` check (which re-measures + // via `TextIntrinsic::from_text`, now wrap-aware too) assumes the + // renderer can produce. + let wrap_width = if nowrap { None } else { box_width }; + // Apply typewriter effect: limit visible characters based on animation progress let content = if props.visible_chars_progress >= 0.0 { let chars: Vec = self.content.chars().collect(); @@ -683,15 +718,25 @@ impl Painter for Text { props: &AnimatedProperties, ctx: &PaintCtx, ) { - let _ = self.paint(canvas, layout.width, ctx.time, props, ctx); + // `text-autofit`'s height-fit target: the box's own content-box + // height, exactly as taffy resolved it for this frame's layout — + // `None` when it isn't a positive, finite number (an intrinsically- + // sized box that grew to fit its content, i.e. nothing to shrink + // for on this axis; see `CssStyle::text_autofit`'s doc comment). + let (_, _, _, content_height) = layout.content_box(); + let content_height = + (content_height > 0.0 && content_height.is_finite()).then_some(content_height); + let _ = self.paint(canvas, layout.width, content_height, ctx.time, props, ctx); } } #[cfg(test)] mod tests { use super::*; + use crate::intrinsic::TextIntrinsic; use rustmotion_core::css::style::CssStyle; use rustmotion_core::css::Length; + use rustmotion_core::engine::box_tree::{AvailableSpace, IntrinsicMeasure}; use rustmotion_core::schema::{CharAnimationTiming, EasingType}; fn make_text(content: &str, white_space: Option) -> Text { @@ -778,7 +823,7 @@ mod tests { let canvas = surface.canvas(); let ctx = test_ctx(); let props = AnimatedProperties::default(); - text.paint(canvas, 80.0, 0.0, &props, &ctx) + text.paint(canvas, 80.0, None, 0.0, &props, &ctx) .expect("paint succeeds"); let grid = alpha_grid(&mut surface, W, H); @@ -809,7 +854,7 @@ mod tests { let canvas = surface.canvas(); let ctx = test_ctx(); let props = AnimatedProperties::default(); - text.paint(canvas, 80.0, 0.0, &props, &ctx) + text.paint(canvas, 80.0, None, 0.0, &props, &ctx) .expect("paint succeeds"); let grid = alpha_grid(&mut surface, W, H); @@ -853,7 +898,7 @@ mod tests { let canvas = surface.canvas(); let ctx = test_ctx(); let props = AnimatedProperties::default(); - text.paint(canvas, 300.0, 0.0, &props, &ctx) + text.paint(canvas, 300.0, None, 0.0, &props, &ctx) .expect("paint succeeds"); let grid = alpha_grid(&mut surface, W, H); @@ -891,7 +936,7 @@ mod tests { let canvas = surface.canvas(); let ctx = test_ctx(); let props = AnimatedProperties::default(); - text.paint(canvas, 300.0, 0.0, &props, &ctx) + text.paint(canvas, 300.0, None, 0.0, &props, &ctx) .expect("paint succeeds"); let grid = alpha_grid(&mut surface, W, H); @@ -977,7 +1022,7 @@ mod tests { skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); { let canvas = surface.canvas(); - text.paint(canvas, W as f32, t, &props, &ctx) + text.paint(canvas, W as f32, None, t, &props, &ctx) .expect("paint succeeds"); } alpha_grid(&mut surface, W, H) @@ -1058,7 +1103,7 @@ mod tests { skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); { let canvas = surface.canvas(); - text.paint(canvas, W as f32, t, &props, &ctx) + text.paint(canvas, W as f32, None, t, &props, &ctx) .expect("paint succeeds"); } let grid = alpha_grid(&mut surface, W, H); @@ -1099,7 +1144,7 @@ mod tests { let mut before = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); { let canvas = before.canvas(); - text.paint(canvas, W as f32, 0.1, &props, &ctx) + text.paint(canvas, W as f32, None, 0.1, &props, &ctx) .expect("paint succeeds"); } let before_grid = alpha_grid(&mut before, W, H); @@ -1113,7 +1158,7 @@ mod tests { let mut mid = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); { let canvas = mid.canvas(); - text.paint(canvas, W as f32, 0.65, &props, &ctx) + text.paint(canvas, W as f32, None, 0.65, &props, &ctx) .expect("paint succeeds"); } let mid_grid = alpha_grid(&mut mid, W, H); @@ -1130,4 +1175,268 @@ mod tests { "word 2 (starts at delay+stagger=1.1s) must still be fully invisible at t=0.65" ); } + + // ─── text-autofit ─────────────────────────────────────────────────── + + fn autofit_text(content: &str, font_size: f32, white_space: Option) -> Text { + let mut t = make_text(content, white_space); + t.style.font_size = Some(Length::Px(font_size)); + t.style.text_autofit = Some(true); + t + } + + /// Rightmost painted column across the whole surface — the horizontal + /// extent of whatever ink was actually drawn. + fn max_ink_x(grid: &[u8], surface_width: i32, height: i32) -> Option { + let mut max_x: Option = None; + for y in 0..height { + for x in (0..surface_width).rev() { + if grid[(y * surface_width + x) as usize] > 0 { + max_x = Some(max_x.map_or(x, |m| m.max(x))); + break; + } + } + } + max_x + } + + /// Bottommost painted row across the whole surface — the vertical + /// extent of whatever ink was actually drawn. + fn max_ink_y(grid: &[u8], surface_width: i32, height: i32) -> Option { + for y in (0..height).rev() { + for x in 0..surface_width { + if grid[(y * surface_width + x) as usize] > 0 { + return Some(y); + } + } + } + None + } + + #[test] + fn measure_and_paint_agree_on_a_shrunk_nowrap_line() { + // Trap #1 — the one the brief calls the only one that can ruin this + // work: `TextIntrinsic::measure` and `Text::paint` must resolve to + // the *same* font size for the same node, or the box the layout + // engine reserves stops matching what actually gets painted. Both + // delegate to `resolve_text_autofit` with identical inputs (see its + // doc comment); this proves that agreement operationally, on the + // real render path, not by re-deriving the expected size by hand + // (which would only test this test's own arithmetic). + let text = autofit_text( + "the quick brown fox jumps over the lazy dog", + 90.0, + Some(CssWhiteSpace::Nowrap), + ); + // 300px comfortably clears this sentence's floor-fit width (~252px + // — this string never reads shorter than the calibrated legibility + // floor allows), so the box is reachable by shrinking alone, + // distinct from the separate floor-behaviour tests in + // `intrinsic.rs`. + const BOX_W: f32 = 300.0; + const BOX_H: f32 = 60.0; + + let (measured_w, _measured_h) = TextIntrinsic::from_text(&text).measure( + (None, None), + ( + AvailableSpace::Definite(BOX_W), + AvailableSpace::Definite(BOX_H), + ), + ); + // Sanity: at 90px this line would never fit a 300px box unshrunk — + // proves the shrink path is actually exercised here. + assert!( + measured_w <= BOX_W + 0.5, + "TextIntrinsic itself must report a fit once autofit is on, got {measured_w}" + ); + + const W: i32 = 900; + const H: i32 = 300; + 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, BOX_W, Some(BOX_H), 0.0, &props, &ctx) + .expect("paint succeeds"); + } + let grid = alpha_grid(&mut surface, W, H); + let ink_right = max_ink_x(&grid, W, H).expect("text must paint some ink"); + + assert!( + (ink_right as f32) <= measured_w + 3.0, + "painted ink (right edge {ink_right}) must not exceed the box TextIntrinsic reserved \ + ({measured_w}) — a wider paint than measure is exactly the class of bug this \ + workstream exists to close" + ); + assert!( + (ink_right as f32) >= measured_w - 15.0, + "painted ink (right edge {ink_right}) should land close to what TextIntrinsic \ + measured ({measured_w}); a big gap would mean the two disagree on the resolved \ + font size in the other direction (paint drawing much smaller than reserved)" + ); + } + + #[test] + fn measure_and_paint_agree_on_a_shrunk_wrapped_paragraph_height() { + // Same agreement proof as above, on the height axis with wrapping + // on: a paragraph whose box has an explicit height too short for + // its natural (unshrunk) line count. + let text = autofit_text( + "the quick brown fox jumps over the lazy dog and then keeps going for quite a while longer", + 60.0, + None, + ); + const BOX_W: f32 = 300.0; + const BOX_H: f32 = 90.0; + + let (_measured_w, measured_h) = TextIntrinsic::from_text(&text).measure( + (None, None), + ( + AvailableSpace::Definite(BOX_W), + AvailableSpace::Definite(BOX_H), + ), + ); + assert!( + measured_h <= BOX_H + 0.5, + "TextIntrinsic itself must report a fit once autofit is on, got {measured_h}" + ); + + const W: i32 = 500; + const H: i32 = 400; + 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, BOX_W, Some(BOX_H), 0.0, &props, &ctx) + .expect("paint succeeds"); + } + let grid = alpha_grid(&mut surface, W, H); + let ink_bottom = max_ink_y(&grid, W, H).expect("text must paint some ink"); + + assert!( + (ink_bottom as f32) <= measured_h + 6.0, + "painted ink (bottom edge {ink_bottom}) must not exceed the box TextIntrinsic \ + reserved ({measured_h})" + ); + } + + #[test] + fn autofit_size_is_stable_across_frames_for_fixed_content() { + // Trap #2: nothing in the resolution may depend on `ctx.time` for + // fixed content — rendering it at two different times, same box, + // must be byte-identical. + let text = autofit_text( + "the quick brown fox jumps over the lazy dog", + 90.0, + Some(CssWhiteSpace::Nowrap), + ); + const W: i32 = 900; + const H: i32 = 300; + let ctx = test_ctx(); + let props = AnimatedProperties::default(); + + let render_at = |t: f64| -> Vec { + let mut surface = + skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + text.paint(canvas, 250.0, Some(60.0), t, &props, &ctx) + .expect("paint succeeds"); + } + alpha_grid(&mut surface, W, H) + }; + + let frame_a = render_at(0.0); + let frame_b = render_at(0.9); + assert_eq!( + frame_a, frame_b, + "fixed content in a fixed box must render byte-identically regardless of ctx.time — \ + a per-frame drift here is exactly what the temporal-stability requirement forbids" + ); + } + + #[test] + fn autofit_size_does_not_drift_during_a_typewriter_reveal() { + // Trap #2's named example: a typewriter reveal + // (`visible_chars_progress`) must not make the resolved font size + // drift as more characters become visible — `resolve_text_autofit` + // is always fed the full, untruncated content, never the + // reveal-in-progress view (see its doc comment). Proof: the line's + // vertical footprint (driven by line-height, hence font size) must + // be identical at 30% and 100% reveal, even though the horizontal + // extent legitimately differs (fewer glyphs are visible yet). + let text = autofit_text( + "the quick brown fox jumps over the lazy dog", + 90.0, + Some(CssWhiteSpace::Nowrap), + ); + const W: i32 = 900; + const H: i32 = 300; + let ctx = test_ctx(); + + let render_at = |progress: f32| -> Vec { + let mut surface = + skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + let props = AnimatedProperties { + visible_chars_progress: progress, + ..Default::default() + }; + { + let canvas = surface.canvas(); + text.paint(canvas, 250.0, Some(60.0), 0.0, &props, &ctx) + .expect("paint succeeds"); + } + alpha_grid(&mut surface, W, H) + }; + + let early = render_at(0.3); + let full = render_at(1.0); + + let early_bottom = max_ink_y(&early, W, H).expect("some ink must paint at 30% reveal"); + let full_bottom = max_ink_y(&full, W, H).expect("some ink must paint at full reveal"); + assert_eq!( + early_bottom, full_bottom, + "the resolved font size (line height, hence vertical ink footprint) must not change \ + as the typewriter reveal progresses: early={early_bottom}, full={full_bottom}" + ); + + // And the visible width at 30% must be meaningfully narrower than + // the full line — otherwise this test would not actually be + // exercising a partial reveal at all. + let early_right = max_ink_x(&early, W, H).expect("some ink at 30% reveal"); + let full_right = max_ink_x(&full, W, H).expect("some ink at full reveal"); + assert!( + early_right < full_right, + "test setup: 30% reveal should show measurably less horizontal ink than the full \ + line (early={early_right}, full={full_right})" + ); + } + + #[test] + fn without_text_autofit_nowrap_still_bleeds_past_the_box_exactly_as_before() { + // Backward compatibility: a scenario that does not declare + // `text-autofit` must render exactly as it did before this feature + // existed, even now that `content_height` is threaded through — + // the render-level twin of + // `intrinsic::tests::text_intrinsic_ignores_autofit_target_when_the_flag_is_off`. + let text = make_text( + "the quick brown fox jumps over the lazy dog", + Some(CssWhiteSpace::Nowrap), + ); + const W: i32 = 900; + const H: i32 = 300; + 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, 250.0, Some(60.0), 0.0, &props, &ctx) + .expect("paint succeeds"); + let grid = alpha_grid(&mut surface, W, H); + assert!( + has_ink_in(&grid, W, 260, W, 0, 45), + "without text-autofit, nowrap must still bleed past its box exactly as before" + ); + } } diff --git a/crates/rustmotion-components/tests/text_autofit.rs b/crates/rustmotion-components/tests/text_autofit.rs new file mode 100644 index 0000000..3cd457e --- /dev/null +++ b/crates/rustmotion-components/tests/text_autofit.rs @@ -0,0 +1,162 @@ +//! End-to-end pixel tests for `text-autofit` through the *real* render +//! pipeline (box_builder → run_layout → paint_tree), not just the +//! `TextIntrinsic`/`Text::paint` unit-level tests in `intrinsic.rs`/ +//! `text.rs`. Routes a `text` component through the same +//! `build_scene_with_anim` → `run_layout` → `paint_tree` sequence +//! `codeblock_auto_scroll.rs` uses — the same sequence +//! `render_with_new_pipeline_iter` runs once per rendered frame in the real +//! encoder. This is the strongest available proof that `TextIntrinsic:: +//! measure` (which determines the box `run_layout` reserves) and +//! `Text::paint` (which draws into whatever box that pass assigned) agree +//! on the real render path, and that nothing about the resolved size drifts +//! frame to frame. + +use rustmotion_components::box_builder::{build_scene_with_anim, BuildAnimationCtx}; +use rustmotion_components::legacy_dispatch::LegacyPaintDispatcher; +use rustmotion_components::{ChildComponent, Component, PositionMode}; +use rustmotion_core::css::taffy_bridge::ConversionContext; +use rustmotion_core::engine::layout_pass::run_layout; +use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame}; + +const W: u32 = 800; +const H: u32 = 300; +const SCENE_DURATION: f64 = 3.0; +const BOX_X: f32 = 50.0; +const BOX_Y: f32 = 50.0; +const BOX_W: f32 = 300.0; +const BOX_H: f32 = 60.0; + +fn text_json(content: &str, autofit: bool) -> serde_json::Value { + serde_json::json!({ + "type": "text", + "content": content, + "style": { + "width": BOX_W, + "height": BOX_H, + "font-size": 90, + "color": "#ffffff", + "white-space": "nowrap", + "text-autofit": autofit, + } + }) +} + +fn render_at(content: &str, autofit: bool, time: f64) -> Vec { + let component: Component = + serde_json::from_value(text_json(content, autofit)).expect("deserialize"); + let child = ChildComponent { + component, + position: Some(PositionMode::Absolute { x: BOX_X, y: BOX_Y }), + x: None, + y: None, + z_index: None, + bleed: false, + }; + let children = vec![child]; + + let mut surface = + skia_safe::surfaces::raster_n32_premul((W as i32, H as i32)).expect("raster surface"); + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0)); + + let built = build_scene_with_anim( + &children, + (W as f32, H as f32), + BuildAnimationCtx { + time, + scene_duration: SCENE_DURATION, + fps: 30, + }, + ); + let layout = run_layout( + &built.root, + (W as f32, H as f32), + &ConversionContext::default(), + ); + let dispatcher = LegacyPaintDispatcher::for_scene(&built); + let frame = PaintFrame { + time, + frame_index: (time * 30.0) as u32, + fps: 30, + video_width: W, + video_height: H, + scene_duration: SCENE_DURATION, + camera: None, + }; + paint_tree(canvas, &built.root, &layout, &frame, &dispatcher); + + let row_bytes = W as usize * 4; + let mut pixels = vec![0u8; row_bytes * H as usize]; + let info = skia_safe::ImageInfo::new( + (W as i32, H as i32), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0)); + pixels +} + +/// Rightmost column (absolute, full-surface coordinates) with any painted +/// (non-zero-alpha) ink. +fn max_ink_x(pixels: &[u8]) -> Option { + let mut max_x: Option = None; + for y in 0..H as i32 { + for x in (0..W as i32).rev() { + let idx = ((y * W as i32 + x) * 4 + 3) as usize; + if pixels[idx] > 0 { + max_x = Some(max_x.map_or(x, |m| m.max(x))); + break; + } + } + } + max_x +} + +#[test] +fn without_autofit_the_nowrap_line_bleeds_past_the_box_through_the_real_pipeline() { + // Control: a 90px `white-space: nowrap` line in a 300px-wide box, no + // `text-autofit` — must bleed well past the box's right edge, exactly + // as it always has (nowrap's existing, unmodified contract). + let pixels = render_at("the quick brown fox jumps over the lazy dog", false, 0.0); + let ink_right = max_ink_x(&pixels).expect("text must paint some ink"); + let box_right = (BOX_X + BOX_W) as i32; + assert!( + ink_right > box_right + 20, + "control: without text-autofit, a 90px nowrap line must bleed well past its 300px box \ + (right edge {box_right}), got ink at x={ink_right}" + ); +} + +#[test] +fn with_autofit_the_same_line_stays_inside_the_box_through_the_real_pipeline() { + // Same fixture as the control above, `text-autofit: true` added. The + // box `run_layout` reserves (via `TextIntrinsic::measure`, invoked by + // taffy inside `run_layout`) and the pixels `paint_tree` actually draws + // (via `Text::paint`, invoked by `LegacyPaintDispatcher`) must agree — + // if they disagreed, the box would still be 300px wide but the ink + // would still bleed past it exactly like the control test above. + let pixels = render_at("the quick brown fox jumps over the lazy dog", true, 0.0); + let ink_right = max_ink_x(&pixels).expect("text must paint some ink"); + let box_right = (BOX_X + BOX_W) as i32; + assert!( + ink_right <= box_right + 3, + "text-autofit: true must keep the painted line inside its 300px box (right edge \ + {box_right}), got ink at x={ink_right}" + ); +} + +#[test] +fn autofit_end_to_end_is_stable_across_frames_for_fixed_content() { + // Temporal stability through the real per-frame pipeline (the box tree + // and layout are rebuilt fresh every frame in the real encoder, exactly + // like this test does via `build_scene_with_anim` at two different + // `time`s): static content, static box → must be pixel-identical. + let frame_a = render_at("the quick brown fox jumps over the lazy dog", true, 0.0); + let frame_b = render_at("the quick brown fox jumps over the lazy dog", true, 2.0); + assert_eq!( + frame_a, frame_b, + "fixed content in a fixed box must render byte-identically across frames/time — a \ + per-frame drift here is exactly what the temporal-stability requirement forbids" + ); +} diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs index a37ef79..160b367 100644 --- a/crates/rustmotion-core/src/css/style.rs +++ b/crates/rustmotion-core/src/css/style.rs @@ -13,6 +13,51 @@ use super::units::{Length, LengthContext, LengthPercentage, ParsedLength}; // shape either way — a css-local mirror would only duplicate the struct. use crate::schema::{deserialize_animation_effects, AnimationEffect, GradientBorder, InnerShadow}; +// ─── Legibility floor (relocated from `rustmotion-cli/src/commands/ +// geometry.rs`'s `check_legibility`, issue #110/#102 — moved here, not +// duplicated, so `text-autofit` below can shrink down to the exact same +// calibrated threshold instead of inventing a second one; `rustmotion-cli` +// depends on `rustmotion-core`, never the other way around, so the shared +// value has to live on this side of that boundary) ───────────────────────── +// +// Threshold justification (rendered evidence, not a guess): a 1920×1080 +// scenario was rendered with the same sample line at 8/10/11/12/13/14/16/18/ +// 20/22/24/28px, then the frame was scaled down 50% (a realistic "not +// full-native" viewing size) to inspect. 8–13px degraded to an illegible +// grey smear at that scale; 14px was the first size that stayed readable. +// 0.012 (1.2% of output height) sits between those two bands — it equals +// ~13px on a 1080p frame — and clears every built-in component default +// already shipped (table/terminal/codeblock/pill_nav = 14px, badge `md` = +// 14px, kbd = 14px, tooltip = 13px), so it does not fire on scenarios that +// already validate clean today. Expressing it as a fraction of output +// height (rather than an absolute px count) makes the same *visual* size +// get flagged on a 4K or vertical-format canvas too. +pub const MIN_LEGIBLE_FONT_RATIO: f32 = 0.012; + +/// [`MIN_LEGIBLE_FONT_RATIO`] evaluated at a fixed 1920×1080 reference +/// canvas (≈12.96px) — `text-autofit`'s shrink floor. +/// +/// This is deliberately **not** `MIN_LEGIBLE_FONT_RATIO * scenario.video. +/// height`, unlike `check_legibility`'s own per-scenario check. Reason: +/// `text-autofit` must resolve to the *identical* px value wherever it's +/// computed (`TextIntrinsic::measure`, which runs pre-layout inside +/// `box_builder.rs`, and `Text`/`GradientText`'s painters, which run +/// post-layout with a real `PaintCtx`) — see the measure/paint parity +/// argument on `CssStyle::text_autofit`. `box_builder.rs` does not thread +/// the real `VideoConfig` down to where `TextIntrinsic` is constructed (out +/// of this workstream's file scope), so the painter side cannot be allowed +/// to use the real, more accurate `ctx.video_height` either — doing so would +/// silently reintroduce exactly the measure-vs-paint divergence this +/// workstream exists to prevent, just relocated from "the box" to "the +/// floor". Pinning both sides to the same fixed reference trades per-canvas +/// precision (a vertical 1080×2256 scenario's *true* 1.2%-of-height floor is +/// larger than this) for the non-negotiable guarantee that they agree. This +/// does not weaken `check_legibility` itself: that check still runs +/// independently, against the real canvas, on whatever `font-size` was +/// authored — it has no visibility into `text-autofit`'s runtime output +/// either way (see the workstream report's "non traité" list). +pub const TEXT_AUTOFIT_MIN_FONT_PX: f32 = MIN_LEGIBLE_FONT_RATIO * 1080.0; + /// Top-level CSS style block. All fields are optional; `None` means "not set" /// and lets the cascade fill in inherited / initial values. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -74,6 +119,57 @@ pub struct CssStyle { pub overflow_wrap: Option, pub text_overflow: Option, pub text_decoration: Option, + /// When `true` on `text`/`gradient_text`, the effective `font-size` is + /// shrunk (never grown) until the content fits the box it was assigned, + /// instead of overflowing it. This is what lets an author declare "this + /// text must fit here" and closes `ContentOverflowsBox` as a possible + /// validator failure for that node — see `apply_fixes` + /// (`rustmotion-cli/src/commands/validate.rs`)'s comment on why it + /// deliberately refuses to auto-fix that violation today: growing the + /// box, shrinking the font, and shortening the copy are all legitimate + /// fixes, and picking one was never this tool's call to make silently. + /// `text-autofit` removes that ambiguity by having the *author* pick + /// "shrink the font" up front. + /// + /// **Which box.** Two independent axes, each opt-in on its own: + /// - *Width*: the box's resolved width — its own `width`/`max-width` if + /// set, else whatever it inherited from its parent (exactly the value + /// `text`/`gradient_text` already wrap against — see + /// `TextIntrinsic`/`Text::paint`). Always present once the node is + /// laid out, so the width axis is always a candidate for shrinking. + /// - *Height*: only when the node's own box resolves to a **definite** + /// height (an explicit `height`, or a parent that hands it one, e.g. a + /// fixed `flex-basis`) — never an implicit/inherited one. A box that + /// grows to fit its content has, by construction, nothing to overflow + /// on the height axis, so there is nothing to shrink for. See + /// `TextIntrinsic::measure` / `Text::paint`'s `content_height` for + /// exactly how this is read (the same taffy-resolved, padding/border- + /// already-subtracted content-box value on both the pre-layout measure + /// path and the post-layout paint path — this is what guarantees the + /// two agree on the target, not just the algorithm). + /// + /// **`white-space: nowrap`.** Does not change: nowrap still means "never + /// break this into multiple lines". `text-autofit` composes with it + /// rather than overriding it — a `nowrap` line combined with + /// `text-autofit: true` shrinks the *one* line until it fits the box's + /// width (this is `text`/`gradient_text`'s answer to Remotion's + /// `fitText()`), it does not start wrapping. + /// + /// **`auto_scroll`** (`codeblock`/`terminal`). Unrelated: `text-autofit` + /// is only read by `text`/`gradient_text`'s own painter/intrinsic — + /// `codeblock`/`terminal` never look at this field, so there is no + /// precedence to resolve between the two; `auto_scroll` keeps scrolling + /// (never shrinking) exactly as documented in `CLAUDE.md`. + /// + /// **The floor.** Never shrinks below [`TEXT_AUTOFIT_MIN_FONT_PX`] — the + /// same calibrated legibility ratio `check_legibility` + /// (`rustmotion-cli/src/commands/geometry.rs`) already enforces, not a + /// new threshold. If the content still doesn't fit at the floor, the + /// floor size is used anyway (illegible-but-smallest beats an even + /// larger overflow) and the geometry validator's `ContentOverflowsBox` + /// still fires — `text-autofit` narrows that failure class, it does not + /// silence it. + pub text_autofit: Option, // ---- Visual ---- pub background: Option,