Skip to main content

rustmotion_components/
caption.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, Font, FontStyle, Rect};
4
5use rustmotion_core::css::style::{
6    FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw,
7    WhiteSpace as CssWhiteSpace,
8};
9use rustmotion_core::css::units::LengthContext;
10use rustmotion_core::css::CssStyle;
11use rustmotion_core::engine::animator::AnimatedProperties;
12use rustmotion_core::engine::layout_pass::BoxLayout;
13use rustmotion_core::engine::renderer::{
14    draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
15    typeface_with_fallback,
16};
17use rustmotion_core::schema::{CaptionStyle, CaptionWord, TimelineStep};
18use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
19
20#[derive(Debug, Serialize, Deserialize, JsonSchema)]
21pub struct Caption {
22    pub words: Vec<CaptionWord>,
23    #[serde(default = "default_active_color")]
24    pub active_color: String,
25    #[serde(default)]
26    pub mode: CaptionStyle,
27    #[serde(default)]
28    pub max_width: Option<f32>,
29    /// Pill background color behind the active word (`word_pop` /
30    /// `karaoke_pop` modes). Defaults to black at 70% opacity.
31    #[serde(default)]
32    pub pill_color: Option<String>,
33    #[serde(default)]
34    pub style: CssStyle,
35    #[serde(flatten)]
36    pub timing: TimingConfig,
37    #[serde(default)]
38    pub timeline: Vec<TimelineStep>,
39    #[serde(default)]
40    pub stagger: Option<f32>,
41}
42
43rustmotion_core::impl_traits!(Caption {
44    Animatable => animation,
45    Timed => timing,
46    Styled => style,
47});
48
49impl Caption {
50    fn paint(&self, canvas: &Canvas, layout_width: f32, layout_height: f32, ctx: &PaintCtx) {
51        let time = ctx.time;
52        // #9 / lot B (wave S): `font-size` itself now resolves through the
53        // same context-aware machinery as `letter-spacing`/`line-height`
54        // below — it used to stay on the context-free `font_size_px_or`,
55        // silently dropping `rem`/`vw`/`vh` font-size to 0px. `em`/`%` on
56        // `font-size` itself remain approximate (see
57        // `crate::intrinsic::font_size_ctx`'s doc comment) — cascade.rs
58        // doesn't track the real parent font-size.
59        let base_ctx = crate::intrinsic::font_size_ctx(
60            ctx.video_width as f32,
61            ctx.video_height as f32,
62            layout_width.max(0.0),
63        );
64        let font_size = self.style.font_size_px_ctx(&base_ctx, 48.0);
65        let color = self.style.color_str_or("#FFFFFF");
66        let font_family = self.style.font_family_or("Inter");
67
68        // #9: `letter-spacing`/`line-height` `em`/`%` resolve against this
69        // element's own font-size (just above); `vw`/`vh` resolve against
70        // the real viewport, available here via `ctx` (mirrors
71        // `text.rs::paint`'s `type_ctx`).
72        let type_ctx = LengthContext {
73            font_size,
74            ..base_ctx
75        };
76
77        // #9: derive weight/slant from `style.font-weight`/`font-style`
78        // instead of always painting bold. `CaptionIntrinsic` (via
79        // `TextIntrinsic`) measures at whatever weight the style declares
80        // (400/normal when unset) — painting an unconditional bold made the
81        // glyphs wider than the box that was centred/measured for them.
82        let font_style = Self::resolve_font_style(&self.style);
83
84        let Ok(typeface) = typeface_with_fallback(font_family, font_style) else {
85            return;
86        };
87
88        let font = Font::from_typeface(typeface, font_size);
89        let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));
90
91        // Every branch below draws text with its *baseline* at local y=0 and
92        // (for the pill-background presets) a highlight box extending up to
93        // `font_size + padding/2` above that baseline. Treated as the box's
94        // own coordinate space (y=0 = box top, as every other painter
95        // assumes), that put glyphs — and pills further still — above the
96        // assigned box: `CaptionIntrinsic` sizes the box for one line's
97        // ascent+descent starting at y=0, not for a baseline sitting at 0
98        // with ascenders going negative. Shifting the whole paint down by a
99        // margin that covers the tallest pill (WordPop's `font_size*0.35`
100        // padding, ~1.175×font_size) puts the topmost ink at/after y=0
101        // without touching any of the per-preset layout math below.
102        let top_offset = font_size * 1.2;
103        canvas.save();
104        canvas.translate((0.0, top_offset));
105        // Safety net: even with the offset above, an unusually large pill
106        // padding combined with a short assigned box could still spill past
107        // the bottom edge. Clip vertically only (not horizontally) — a
108        // caption in `white-space: nowrap` mode is *meant* to bleed past
109        // its own width when `max_width` doesn't fit the line (see
110        // `box_builder.rs`'s nowrap comment and the geometry validator's
111        // `unwrappable_text_overflow`), so clipping width here would hide a
112        // condition the validator is supposed to catch instead.
113        if layout_height > 0.0 {
114            const HALF_PLANE: f32 = 1_000_000.0;
115            canvas.clip_rect(
116                Rect::from_xywh(-HALF_PLANE, -top_offset, HALF_PLANE * 2.0, layout_height),
117                skia_safe::ClipOp::Intersect,
118                true,
119            );
120        }
121
122        match self.mode {
123            CaptionStyle::WordByWord => {
124                for word in &self.words {
125                    if time >= word.start && time < word.end {
126                        let paint = paint_from_hex(&self.active_color);
127                        let text_width =
128                            measure_text_with_fallback(&word.text, &font, &emoji_font, 0.0);
129
130                        let cx = layout_width / 2.0;
131
132                        if let Some(bg_color) = self.style.background_color_str() {
133                            let padding = font_size * 0.3;
134                            let bg_rect = Rect::from_xywh(
135                                cx - text_width / 2.0 - padding,
136                                -font_size - padding / 2.0,
137                                text_width + padding * 2.0,
138                                font_size * 1.4 + padding,
139                            );
140                            let bg_paint = paint_from_hex(bg_color);
141                            let rrect = skia_safe::RRect::new_rect_xy(bg_rect, padding, padding);
142                            canvas.draw_rrect(rrect, &bg_paint);
143                        }
144
145                        let x = cx - text_width / 2.0;
146                        draw_text_with_fallback(
147                            canvas,
148                            &word.text,
149                            &font,
150                            &emoji_font,
151                            0.0,
152                            x,
153                            0.0,
154                            &paint,
155                        );
156                        break;
157                    }
158                }
159            }
160            CaptionStyle::WordPop => {
161                for word in &self.words {
162                    if time >= word.start && time < word.end {
163                        let text_width =
164                            measure_text_with_fallback(&word.text, &font, &emoji_font, 0.0);
165                        let cx = layout_width / 2.0;
166
167                        // Spring-like pop: ease-out-back over the first 180ms
168                        // of the word window (overshoots ~1.1 then settles).
169                        let t = (((time - word.start) / POP_DURATION).clamp(0.0, 1.0)) as f32;
170                        let scale = ease_out_back(t).max(0.01);
171
172                        // Scale around the visual center of the word (the
173                        // baseline sits at y=0, glyphs extend upward).
174                        let cy = -font_size * 0.35;
175                        canvas.save();
176                        canvas.translate((cx, cy));
177                        canvas.scale((scale, scale));
178                        canvas.translate((-cx, -cy));
179
180                        let padding = font_size * 0.35;
181                        self.draw_pill(
182                            canvas,
183                            Rect::from_xywh(
184                                cx - text_width / 2.0 - padding,
185                                -font_size - padding / 2.0,
186                                text_width + padding * 2.0,
187                                font_size * 1.4 + padding,
188                            ),
189                        );
190
191                        let paint = paint_from_hex(&self.active_color);
192                        draw_text_with_fallback(
193                            canvas,
194                            &word.text,
195                            &font,
196                            &emoji_font,
197                            0.0,
198                            cx - text_width / 2.0,
199                            0.0,
200                            &paint,
201                        );
202                        canvas.restore();
203                        break;
204                    }
205                }
206            }
207            CaptionStyle::Highlight | CaptionStyle::Karaoke | CaptionStyle::KaraokePop => {
208                // M1: `white-space: nowrap|pre` keeps every word on one
209                // line — ignore `max_width` entirely so the line can bleed
210                // past it, same rule `text.rs` uses. (`WordByWord`/`WordPop`
211                // above show a single word at a time; wrapping is moot
212                // there, same as the existing kbd/counter/badge "atomic"
213                // components, so they don't need this branch.)
214                let nowrap = matches!(
215                    self.style.white_space,
216                    Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre)
217                );
218                // #1: when `max_width` is unset, wrap at the box `layout`
219                // actually gave this caption (matches `text.rs:442-451`)
220                // instead of never wrapping — `CaptionIntrinsic` measures
221                // (and taffy reserves a box) against that same width, so
222                // painting at `f32::MAX` here painted a single line far
223                // wider than the reserved box, bleeding past it and past
224                // the viewport with `validate` never seeing the mismatch
225                // (it re-measures via the same intrinsic, not this paint
226                // path).
227                let max_width = if nowrap {
228                    f32::MAX
229                } else if layout_width.is_finite() && layout_width > 0.0 {
230                    self.max_width
231                        .map_or(layout_width, |mw| mw.min(layout_width))
232                } else {
233                    self.max_width.unwrap_or(f32::MAX)
234                };
235                let space_width = measure_text_with_fallback(" ", &font, &emoji_font, 0.0);
236
237                let mut lines: Vec<Vec<(usize, f32)>> = vec![vec![]];
238                let mut current_x = 0.0f32;
239
240                for (i, word) in self.words.iter().enumerate() {
241                    let word_width =
242                        measure_text_with_fallback(&word.text, &font, &emoji_font, 0.0);
243                    if current_x + word_width > max_width && !lines.last().unwrap().is_empty() {
244                        lines.push(vec![]);
245                        current_x = 0.0;
246                    }
247                    lines.last_mut().unwrap().push((i, word_width));
248                    current_x += word_width + space_width;
249                }
250
251                // #9: honour `style.line-height` like `CaptionIntrinsic`
252                // does (via `TextIntrinsic::from_parts` ->
253                // `line_height_for_ctx`) instead of a hardcoded 1.4 — the
254                // box taffy reserves is sized from the former, so painting
255                // with the latter drifted the line spacing away from what
256                // was measured (7.7% at the unset default, arbitrarily more
257                // with an explicit `line-height`), and the caption's own
258                // vertical clip (below in the outer `paint`) silently crops
259                // whatever spills past the mismatch.
260                let line_height = self.style.line_height_for_ctx(font_size, &type_ctx);
261                let cx = layout_width / 2.0;
262
263                if let Some(bg_color) = self.style.background_color_str() {
264                    let padding = font_size * 0.3;
265                    let total_height = lines.len() as f32 * line_height;
266                    let max_line_width = lines
267                        .iter()
268                        .map(|line| {
269                            line.iter().map(|(_, w)| w).sum::<f32>()
270                                + (line.len().saturating_sub(1)) as f32 * space_width
271                        })
272                        .fold(0.0f32, f32::max);
273                    let bg_rect = Rect::from_xywh(
274                        cx - max_line_width / 2.0 - padding,
275                        -font_size - padding / 2.0,
276                        max_line_width + padding * 2.0,
277                        total_height + padding,
278                    );
279                    let bg_paint = paint_from_hex(bg_color);
280                    let rrect = skia_safe::RRect::new_rect_xy(bg_rect, padding, padding);
281                    canvas.draw_rrect(rrect, &bg_paint);
282                }
283
284                for (line_idx, line) in lines.iter().enumerate() {
285                    let line_width: f32 = line.iter().map(|(_, w)| w).sum::<f32>()
286                        + (line.len().saturating_sub(1)) as f32 * space_width;
287                    let mut x = cx - line_width / 2.0;
288                    let y = line_idx as f32 * line_height;
289
290                    for (word_idx, word_width) in line {
291                        let word = &self.words[*word_idx];
292                        let is_active = time >= word.start && time < word.end;
293                        let pop = is_active && matches!(self.mode, CaptionStyle::KaraokePop);
294                        let word_color = if is_active { &self.active_color } else { color };
295                        let paint = paint_from_hex(word_color);
296
297                        if pop {
298                            // Active word scales up ~1.15x around its visual
299                            // center, on top of a pill background.
300                            let wcx = x + word_width / 2.0;
301                            let wcy = y - font_size * 0.35;
302                            canvas.save();
303                            canvas.translate((wcx, wcy));
304                            canvas.scale((KARAOKE_POP_SCALE, KARAOKE_POP_SCALE));
305                            canvas.translate((-wcx, -wcy));
306
307                            let padding = font_size * 0.18;
308                            self.draw_pill(
309                                canvas,
310                                Rect::from_xywh(
311                                    x - padding,
312                                    y - font_size - padding / 2.0,
313                                    word_width + padding * 2.0,
314                                    font_size * 1.4 + padding,
315                                ),
316                            );
317                        }
318
319                        draw_text_with_fallback(
320                            canvas,
321                            &word.text,
322                            &font,
323                            &emoji_font,
324                            0.0,
325                            x,
326                            y,
327                            &paint,
328                        );
329                        if pop {
330                            canvas.restore();
331                        }
332                        x += word_width + space_width;
333                    }
334                }
335            }
336        }
337        canvas.restore();
338    }
339}
340
341impl Caption {
342    /// Draws the rounded pill background used by the pop presets.
343    fn draw_pill(&self, canvas: &Canvas, rect: Rect) {
344        let radius = rect.height() / 2.0;
345        let paint = paint_from_hex(self.pill_color.as_deref().unwrap_or(DEFAULT_PILL_COLOR));
346        canvas.draw_rrect(skia_safe::RRect::new_rect_xy(rect, radius, radius), &paint);
347    }
348
349    /// #9: the Skia `FontStyle` to paint with, derived from `style.font-
350    /// weight`/`font-style` — mirrors `text.rs`'s weight/slant mapping and
351    /// `intrinsic.rs`'s `weight_to_u16` (used to measure the box), so the
352    /// weight the box was measured at and the weight painted into it always
353    /// agree. Pulled out as its own function so it's directly unit-testable
354    /// without needing to render anything.
355    fn resolve_font_style(style: &CssStyle) -> FontStyle {
356        let weight = match &style.font_weight {
357            Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => {
358                skia_safe::font_style::Weight::BOLD
359            }
360            Some(CssFontWeight::Number(n)) if *n >= 600 => skia_safe::font_style::Weight::BOLD,
361            Some(CssFontWeight::Number(n)) => skia_safe::font_style::Weight::from(*n as i32),
362            _ => skia_safe::font_style::Weight::NORMAL,
363        };
364        let slant = match style.font_style {
365            Some(CssFontStyle::Italic) => skia_safe::font_style::Slant::Italic,
366            Some(CssFontStyle::Oblique) => skia_safe::font_style::Slant::Oblique,
367            _ => skia_safe::font_style::Slant::Upright,
368        };
369        FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant)
370    }
371}
372
373impl Painter for Caption {
374    fn paint_content(
375        &self,
376        canvas: &Canvas,
377        layout: &BoxLayout,
378        _props: &AnimatedProperties,
379        ctx: &PaintCtx,
380    ) {
381        self.paint(canvas, layout.width, layout.height, ctx);
382    }
383}
384
385fn default_active_color() -> String {
386    "#FFFF00".to_string()
387}
388
389/// Black at 70% opacity — default pill background for the pop presets.
390const DEFAULT_PILL_COLOR: &str = "#000000B3";
391
392/// Duration (seconds) of the word_pop scale-in.
393const POP_DURATION: f64 = 0.18;
394
395/// Scale factor applied to the active word in karaoke_pop.
396const KARAOKE_POP_SCALE: f32 = 1.15;
397
398/// Ease-out-back easing: starts at 0, overshoots ~1.1, settles at 1.
399fn ease_out_back(t: f32) -> f32 {
400    const C1: f32 = 1.70158;
401    const C3: f32 = C1 + 1.0;
402    let p = t - 1.0;
403    1.0 + C3 * p * p * p + C1 * p * p
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use rustmotion_core::css::style::CssStyle;
410    use rustmotion_core::css::Length;
411    use rustmotion_core::schema::CaptionWord;
412
413    /// A `PaintCtx` for tests that don't care about frame/fps bookkeeping —
414    /// only `time` and, since #9, the viewport dims threaded into the
415    /// `LengthContext` used to resolve `vw`/`vh` typography units.
416    fn test_ctx(time: f64) -> PaintCtx {
417        PaintCtx {
418            time,
419            scenario_time: time,
420            scene_duration: 2.0,
421            frame_index: (time * 30.0) as u32,
422            fps: 30,
423            video_width: 1920,
424            video_height: 1080,
425            stagger_offset: 0.0,
426        }
427    }
428
429    fn make_caption(text: &str, white_space: Option<CssWhiteSpace>) -> Caption {
430        make_caption_with_max_width(text, white_space, Some(80.0))
431    }
432
433    fn make_caption_with_max_width(
434        text: &str,
435        white_space: Option<CssWhiteSpace>,
436        max_width: Option<f32>,
437    ) -> Caption {
438        let words = text
439            .split_whitespace()
440            .map(|w| CaptionWord {
441                text: w.to_string(),
442                start: 0.0,
443                end: 1000.0,
444            })
445            .collect();
446        Caption {
447            words,
448            active_color: default_active_color(),
449            mode: CaptionStyle::Highlight,
450            max_width,
451            pill_color: None,
452            style: CssStyle {
453                font_size: Some(Length::Px(28.0)),
454                white_space,
455                ..Default::default()
456            },
457            timing: Default::default(),
458            timeline: Vec::new(),
459            stagger: None,
460        }
461    }
462
463    /// Bounding box (min_x, max_x, min_y, max_y) of every non-transparent
464    /// pixel on the surface, or `None` if nothing was painted.
465    fn ink_bounds(
466        surface: &mut skia_safe::Surface,
467        w: i32,
468        h: i32,
469    ) -> Option<(i32, i32, i32, i32)> {
470        let snapshot = surface.image_snapshot();
471        let info = skia_safe::ImageInfo::new(
472            (w, h),
473            skia_safe::ColorType::RGBA8888,
474            skia_safe::AlphaType::Premul,
475            None,
476        );
477        let mut buf = vec![0u8; (w * h * 4) as usize];
478        let ok = snapshot.read_pixels(
479            &info,
480            &mut buf,
481            (w * 4) as usize,
482            skia_safe::IPoint::new(0, 0),
483            skia_safe::image::CachingHint::Disallow,
484        );
485        assert!(ok, "pixel read should succeed");
486        let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
487        for y in 0..h {
488            for x in 0..w {
489                if buf[((y * w + x) * 4 + 3) as usize] > 0 {
490                    minx = minx.min(x);
491                    maxx = maxx.max(x);
492                    miny = miny.min(y);
493                    maxy = maxy.max(y);
494                }
495            }
496        }
497        (minx <= maxx).then_some((minx, maxx, miny, maxy))
498    }
499
500    #[test]
501    fn ink_never_starts_above_the_box_top() {
502        // #127: every branch drew its baseline at local y=0 (the box's own
503        // top edge) — ascenders, and pill backgrounds further still,
504        // painted *above* y=0, bleeding out of whatever box the layout
505        // gave this caption (measured: assigned box top at y=124 in a
506        // card starting at y=100, but ink started at y≈85 — above the
507        // card itself, not just the box).
508        let caption = make_caption("Hello world", None);
509        const W: i32 = 400;
510        const H: i32 = 200;
511        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
512        {
513            let canvas = surface.canvas();
514            caption.paint(canvas, W as f32, H as f32, &test_ctx(0.5));
515        }
516        let (_minx, _maxx, miny, _maxy) =
517            ink_bounds(&mut surface, W, H).expect("caption must paint something");
518        assert!(miny >= 0, "ink starts above the box top at y={miny}");
519    }
520
521    #[test]
522    fn word_pop_pill_never_starts_above_the_box_top() {
523        // The pill-background presets pad further above the baseline than
524        // plain text (up to `font_size * 0.35` extra) — the worst case
525        // among the four rendering modes.
526        let mut caption = make_caption("Hello", None);
527        caption.mode = CaptionStyle::WordPop;
528        caption.words[0].start = 0.0;
529        caption.words[0].end = 10.0;
530        const W: i32 = 400;
531        const H: i32 = 200;
532        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
533        {
534            let canvas = surface.canvas();
535            caption.paint(canvas, W as f32, H as f32, &test_ctx(0.1));
536        }
537        let (_minx, _maxx, miny, _maxy) =
538            ink_bounds(&mut surface, W, H).expect("word_pop caption must paint something");
539        assert!(miny >= 0, "pill starts above the box top at y={miny}");
540    }
541
542    #[test]
543    fn nowrap_paints_one_wide_line_instead_of_wrapping_at_max_width() {
544        // M1 render-level proof, caption (Highlight mode): `white-space:
545        // nowrap` keeps every word on one line — much wider than
546        // `max_width: 80`, and only one line tall — instead of wrapping.
547        let caption = make_caption(
548            "the quick brown fox jumps over the lazy dog",
549            Some(CssWhiteSpace::Nowrap),
550        );
551        const W: i32 = 1600;
552        const H: i32 = 400;
553        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
554        {
555            let canvas = surface.canvas();
556            canvas.translate((800.0, 250.0));
557            caption.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
558        }
559        let (minx, maxx, miny, maxy) =
560            ink_bounds(&mut surface, W, H).expect("nowrap caption must paint something");
561
562        assert!(
563            maxx - minx > 240,
564            "nowrap caption must bleed far past its 80px max_width, got ink width {}",
565            maxx - minx
566        );
567        assert!(
568            maxy - miny < 50,
569            "nowrap caption must stay on one line, got ink height {}",
570            maxy - miny
571        );
572    }
573
574    #[test]
575    fn normal_white_space_wraps_at_max_width() {
576        let caption = make_caption("the quick brown fox jumps over the lazy dog", None);
577        const W: i32 = 1600;
578        const H: i32 = 400;
579        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
580        {
581            let canvas = surface.canvas();
582            canvas.translate((800.0, 250.0));
583            caption.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
584        }
585        let (minx, maxx, miny, maxy) =
586            ink_bounds(&mut surface, W, H).expect("wrapped caption must paint something");
587
588        assert!(
589            maxx - minx < 200,
590            "wrapped caption must pack close to its 80px max_width, got ink width {}",
591            maxx - minx
592        );
593        assert!(
594            maxy - miny > 50,
595            "wrapped caption must spread across multiple lines, got ink height {}",
596            maxy - miny
597        );
598    }
599
600    // ─── #1: wrap at the box's layout_width when max_width is unset ───────
601
602    #[test]
603    fn wraps_at_layout_width_when_max_width_is_unset() {
604        // Reproduction: no `max_width` on the caption (the common case — a
605        // caption's box comes from wherever it's placed, e.g. a card), but
606        // the layout pass still hands `paint` a real, finite `layout_width`
607        // (mirrors `CaptionIntrinsic`, which measures against exactly this
608        // width). Before the fix, `max_width.unwrap_or(f32::MAX)` ignored
609        // `layout_width` entirely and painted one line stretching far past
610        // the box — and past the viewport in the audit's repro.
611        let caption = make_caption_with_max_width(
612            "the quick brown fox jumps over the lazy dog again",
613            None,
614            None, // no explicit max_width
615        );
616        const W: i32 = 1600;
617        const H: i32 = 400;
618        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
619        {
620            let canvas = surface.canvas();
621            canvas.translate((800.0, 200.0));
622            // The box the layout pass assigned: 300px wide, well short of
623            // this sentence's unwrapped width at font-size 28.
624            caption.paint(canvas, 300.0, H as f32, &test_ctx(0.5));
625        }
626        let (minx, maxx, miny, maxy) =
627            ink_bounds(&mut surface, W, H).expect("caption must paint something");
628
629        assert!(
630            maxx - minx < 320,
631            "must wrap within ~layout_width (300px), got ink width {}",
632            maxx - minx
633        );
634        assert!(
635            maxy - miny > 50,
636            "must spread across multiple lines when max_width is unset, got ink height {}",
637            maxy - miny
638        );
639    }
640
641    #[test]
642    fn nowrap_still_ignores_layout_width_when_max_width_is_unset() {
643        // Regression guard: the #1 fix must not touch `white-space:
644        // nowrap`'s existing "always ignore any width constraint" contract.
645        let caption = make_caption_with_max_width(
646            "the quick brown fox jumps over the lazy dog",
647            Some(CssWhiteSpace::Nowrap),
648            None,
649        );
650        const W: i32 = 1600;
651        const H: i32 = 400;
652        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
653        {
654            let canvas = surface.canvas();
655            canvas.translate((800.0, 200.0));
656            caption.paint(canvas, 300.0, H as f32, &test_ctx(0.5));
657        }
658        let (minx, maxx, miny, maxy) =
659            ink_bounds(&mut surface, W, H).expect("caption must paint something");
660
661        assert!(
662            maxx - minx > 400,
663            "nowrap must still bleed past layout_width, got ink width {}",
664            maxx - minx
665        );
666        assert!(
667            maxy - miny < 50,
668            "nowrap must stay on one line, got ink height {}",
669            maxy - miny
670        );
671    }
672
673    // ─── #9: line-height / font-weight measure-vs-paint parity ────────────
674
675    #[test]
676    fn honours_style_line_height_instead_of_hardcoded_1_4() {
677        // Reproduction: `style.line-height: 0.9` must change the vertical
678        // gap between wrapped lines. Before the fix, the painter always
679        // used `font_size * 1.4` regardless of `style.line-height`, while
680        // `CaptionIntrinsic` (the box taffy reserves) honoured it — a
681        // caption author following rules/typography-readability.md's
682        // guidance to set `line-height` got a box sized for their value but
683        // glyphs painted at a fixed 1.4.
684        let mut tight = make_caption_with_max_width(
685            "one two three four five six seven eight",
686            None,
687            Some(80.0),
688        );
689        tight.style.line_height = Some(rustmotion_core::css::style::LineHeight::Number(0.9));
690        let mut loose = make_caption_with_max_width(
691            "one two three four five six seven eight",
692            None,
693            Some(80.0),
694        );
695        loose.style.line_height = Some(rustmotion_core::css::style::LineHeight::Number(2.0));
696
697        const W: i32 = 1600;
698        const H: i32 = 800;
699
700        let mut surf_tight =
701            skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
702        {
703            let canvas = surf_tight.canvas();
704            canvas.translate((800.0, 50.0));
705            tight.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
706        }
707        let (_, _, _, tight_maxy) =
708            ink_bounds(&mut surf_tight, W, H).expect("tight caption must paint something");
709
710        let mut surf_loose =
711            skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
712        {
713            let canvas = surf_loose.canvas();
714            canvas.translate((800.0, 50.0));
715            loose.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
716        }
717        let (_, _, _, loose_maxy) =
718            ink_bounds(&mut surf_loose, W, H).expect("loose caption must paint something");
719
720        assert!(
721            loose_maxy > tight_maxy + 50,
722            "line-height: 2.0 must spread lines much further than 0.9 \
723             (tight bottom={tight_maxy}, loose bottom={loose_maxy})"
724        );
725    }
726
727    // ─── Lot B, wave S: relative `font-size` units ─────────────────────────
728
729    #[test]
730    fn rem_font_size_paints_visible_ink() {
731        // Reproduction: `font-size: "2rem"` used to resolve to 0px on the
732        // context-free `font_size_px_or` path — `CaptionIntrinsic` (via
733        // `TextIntrinsic`) measured a 0-height box and nothing painted.
734        let mut caption = make_caption_with_max_width("hello world", None, Some(300.0));
735        caption.style.font_size = Some(Length::String("2rem".into()));
736        const W: i32 = 400;
737        const H: i32 = 200;
738        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
739        {
740            let canvas = surface.canvas();
741            caption.paint(canvas, 300.0, H as f32, &test_ctx(0.5));
742        }
743        let bounds = ink_bounds(&mut surface, W, H);
744        assert!(
745            bounds.is_some(),
746            "caption at font-size: 2rem must paint visible ink"
747        );
748    }
749
750    // `Caption::resolve_font_style` is the exact weight/slant computation
751    // `paint` uses; testing it directly is deterministic regardless of
752    // whether the system's resolved "bold" and "normal" typefaces happen to
753    // have visually/metrically distinct advance widths on this particular
754    // host (on this machine, Helvetica's bold and normal share identical
755    // glyph metrics — a pixel-width comparison would pass whether or not
756    // `paint` used the right weight, which isn't a real check of the fix).
757
758    #[test]
759    fn resolve_font_style_defaults_to_normal_matching_the_intrinsic_measurement() {
760        // #9 (weight half): `CaptionIntrinsic` (via `TextIntrinsic`'s
761        // `weight_to_u16`) measures at weight 400 when `style.font-weight`
762        // is unset. Before the fix, `paint` ignored `style.font-weight`
763        // entirely and always painted `FontStyle::bold()` (weight 700) — a
764        // silent measure-vs-paint weight mismatch on every caption that
765        // doesn't set an explicit font-weight (the common case).
766        let style = CssStyle::default();
767        let resolved = Caption::resolve_font_style(&style);
768        assert_eq!(
769            *resolved.weight(),
770            400,
771            "unset font-weight must resolve to normal (400), not a hardcoded bold"
772        );
773    }
774
775    #[test]
776    fn resolve_font_style_honours_explicit_bold_and_numeric_weight() {
777        let bold = CssStyle {
778            font_weight: Some(CssFontWeight::Keyword(FontWeightKw::Bold)),
779            ..Default::default()
780        };
781        assert_eq!(*Caption::resolve_font_style(&bold).weight(), 700);
782
783        // Below the >=600 "treat as bold" threshold (same threshold
784        // `text.rs`'s equivalent mapping uses), so the exact numeric value
785        // passes through unchanged.
786        let numeric = CssStyle {
787            font_weight: Some(CssFontWeight::Number(350)),
788            ..Default::default()
789        };
790        assert_eq!(*Caption::resolve_font_style(&numeric).weight(), 350);
791    }
792
793    #[test]
794    fn resolve_font_style_honours_italic() {
795        let italic = CssStyle {
796            font_style: Some(CssFontStyle::Italic),
797            ..Default::default()
798        };
799        assert_eq!(
800            Caption::resolve_font_style(&italic).slant(),
801            skia_safe::font_style::Slant::Italic
802        );
803    }
804}