From 99aa37d1b18f0621165a64164212a418bf382a3d Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 11 Aug 2026 10:00:26 +0200 Subject: [PATCH] feat(text): let text declare that it must fit its box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the second Critical gap from the re-scored Remotion differential, and the one the original audit called the most profitable reliability lever here. Until now, a text that overflowed its box was reported and nothing more. `apply_fixes` deliberately refuses `ContentOverflowsBox`, and its comment says why: growing the box, shrinking the font and shortening the copy are all legitimate, and picking one is not the validator's call. That reasoning holds only while the engine has no way to shrink text at all. Given one, the arbitration disappears — the author declares the intent, and a whole class of generation failure stops existing. `style.text-autofit` makes `text` and `gradient_text` reduce their font size until they fit the resolved width and, where taffy defines one, the content box height. `white-space: nowrap` still decides *whether* the text wraps; autofit decides *at what size* — they compose rather than compete. `auto_scroll` never interacts: codeblock and terminal do not read the field, by construction rather than by convention. Three hazards drove the design: - **Measure and paint must agree.** This repository spent a whole chantier repairing divergences where `TextIntrinsic` measured one thing and the painter drew another, blinding the geometry pass to real overflow. One pure `resolve_text_autofit` is called with identical arguments from both sides, so the agreement is structural rather than coincidental — and the tests assert it rather than merely checking the render looks right. - **The size must not drift.** Paint runs per frame. The resolution is fed the complete content, never the typewriter-truncated view, so a reveal cannot make the size oscillate mid-read. Asserted by rendering the same content at two instants and comparing pixels byte for byte. - **Shrinking needs a floor**, or a visible defect is traded for a discreet one. The floor reuses `MIN_LEGIBLE_FONT_RATIO`, already calibrated by visual inspection, relocated into `rustmotion-core` so both sides share the one constant instead of inventing a second. `rustmotion info` now reports each text's natural size, through the same measurer the engine and the validator use. The floor is pinned to a 1080-tall reference, because `IntrinsicMeasure` cannot see the frame height and using the real one on the paint side alone would reintroduce exactly the divergence above. On a taller canvas that floor therefore sits below the legibility threshold, and a declared 120px shrinking to ~13px on a 2160-tall frame would have passed the legibility check in silence — the failure mode this feature exists to remove, not relocate. `check_legibility` now warns in precisely that case, naming both numbers, and stays quiet at 1080 where the two agree. The precise fix is a canvas-relative floor on both sides; that needs the frame height plumbed into `TextIntrinsic` and is tracked separately. --- .../rustmotion-cli/src/commands/geometry.rs | 288 ++++++++- crates/rustmotion-cli/src/commands/info.rs | 125 ++++ .../src/gradient_text.rs | 206 +++++- crates/rustmotion-components/src/intrinsic.rs | 586 +++++++++++++++++- crates/rustmotion-components/src/text.rs | 383 ++++++++++-- .../tests/text_autofit.rs | 162 +++++ crates/rustmotion-core/src/css/style.rs | 96 +++ 7 files changed, 1737 insertions(+), 109 deletions(-) create mode 100644 crates/rustmotion-components/tests/text_autofit.rs 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,