Skip to main content

oxideav_scene/
text.rs

1//! Text-run rasterisation for [`crate::TextRun`] via the
2//! [`oxideav_scribe`] vector shaper + [`oxideav_raster`] vector→pixel
3//! renderer.
4//!
5//! Round 1 of the scene crate's renderer was a trait-only scaffold —
6//! [`crate::render::StubRenderer`] returns `Error::Unsupported`. This
7//! module is the first concrete piece of *actual* rendering: it takes
8//! a `TextRun` plus a [`oxideav_scribe::Face`] supplied by the caller
9//! (the scene crate does not implement font discovery — see scope notes
10//! below), shapes it into a chain of positioned glyph nodes, builds a
11//! [`oxideav_core::VectorFrame`], and rasterises it via
12//! [`oxideav_raster::Renderer`] into a straight-alpha RGBA framebuffer.
13//!
14//! ## Pipeline
15//!
16//! 1. The caller hands us a [`TextRenderer`] holding a parsed
17//!    [`oxideav_scribe::Face`]. The face is wrapped in a
18//!    [`oxideav_scribe::FaceChain`] internally so the shaper's
19//!    cmap-fallback path is exercised even when only one face is
20//!    registered (chain index 0 == primary). Multiple runs against the
21//!    same renderer share the underlying `oxideav_raster::Renderer`'s
22//!    glyph-bitmap LRU (which keys on `Group::cache_key` populated by
23//!    [`oxideav_scribe::Shaper::shape_to_paths`]).
24//! 2. For each [`TextRun`]:
25//!    * Decode the `0xRRGGBBAA` colour into a `Rgba` quartet.
26//!    * If the run carries `\n` characters, split on newline (or
27//!      `wrap_lines` against `max_width_px`) and shape each line.
28//!    * Otherwise shape the run text directly via
29//!      [`oxideav_scribe::Shaper::shape_to_paths`].
30//!    * Recolour each glyph's [`oxideav_core::PathNode::fill`] from the
31//!      default black to the run colour. Bitmap glyphs (CBDT/sbix
32//!      `Node::Image`) keep their carried palette.
33//!    * Wrap the glyphs in a translating [`oxideav_core::Group`] so the
34//!      run sits at the requested pen position with the baseline below
35//!      the bitmap top by `face.ascent_px(size)`.
36//! 3. Stage the [`oxideav_core::VectorFrame`], rasterise it via
37//!    [`oxideav_raster::Renderer`] (transparent background), and either
38//!    return the resulting [`RgbaBitmap`] (`render_run` /
39//!    `render_run_wrapped`) or composite it onto the destination
40//!    framebuffer (`render_run_into` / `render_run_wrapped_into`).
41//!
42//! ## Scope (round 2)
43//!
44//! * **Font discovery is the caller's job.** A scene-level font
45//!   registry / `font_family → Face` resolver is intentionally out of
46//!   scope; the renderer takes one `Face` and uses it for every
47//!   `TextRun` it sees, ignoring `run.font_family`. Picking the right
48//!   `Face` for the family belongs in the application layer.
49//! * **Per-glyph colour (CPAL/COLR) is forwarded as-is** — the run's
50//!   single foreground colour applies to every outline glyph; bitmap
51//!   colour glyphs (CBDT, sbix) keep their built-in palette since the
52//!   `Node::Image` carries the encoded colour pixels directly.
53//! * **Underline drawing is deferred** to round 3 (see the inline
54//!   `// round-3 note` in [`TextRenderer::render_run_into`]).
55//! * **Italic** — Scribe does not yet expose a synthetic-italic shear
56//!   API on the vector path. When `run.italic` is set we apply the same
57//!   per-row horizontal shear `oxideav_subtitle::compositor` uses for
58//!   bitmap-font italic (cell width / 4) directly on the rasterised
59//!   bitmap, AFTER composition. Once Scribe gains a real italic shear
60//!   on the `Shaper::shape_to_paths` path, the fake-shear branch goes
61//!   away.
62//! * **Per-run advances** (`TextRun::advances`) — recorded but ignored
63//!   in round 2; the shaper computes its own advances. Honouring
64//!   caller-provided advances (PDF-style explicit positioning) is a
65//!   future round.
66
67use oxideav_core::{
68    FillRule, Group, Node, Paint, PathNode, Rgba as CoreRgba, TimeBase, Transform2D, VectorFrame,
69};
70use oxideav_raster::Renderer;
71use oxideav_scribe::{Face, FaceChain, Shaper};
72
73use crate::object::TextRun;
74
75/// A grayscale-irrelevant straight-alpha RGBA8 bitmap. Stride is
76/// `width * 4`. Mirrors the data layout of the now-removed
77/// `oxideav_scribe::RgbaBitmap` so the `TextRenderer` API stays
78/// byte-stable across the round-2 vector-pipeline migration.
79#[derive(Debug, Clone, Default)]
80pub struct RgbaBitmap {
81    /// Bitmap width in pixels.
82    pub width: u32,
83    /// Bitmap height in pixels.
84    pub height: u32,
85    /// Row-major straight-alpha RGBA8 bytes (`width * height * 4`).
86    pub data: Vec<u8>,
87}
88
89impl RgbaBitmap {
90    /// Allocate a fully-transparent (alpha = 0) bitmap.
91    pub fn new(width: u32, height: u32) -> Self {
92        Self {
93            width,
94            height,
95            data: vec![0; (width as usize) * (height as usize) * 4],
96        }
97    }
98
99    /// True if the bitmap holds zero pixels.
100    pub fn is_empty(&self) -> bool {
101        self.width == 0 || self.height == 0
102    }
103}
104
105/// Owns a [`Face`] (wrapped in a single-face [`FaceChain`]) and a long-lived
106/// [`Renderer`] for per-glyph bitmap-cache reuse across runs.
107///
108/// Callers construct one `TextRenderer` per face they want to use, and
109/// call [`TextRenderer::render_run_into`] for every `TextRun` they want
110/// composited.
111#[derive(Debug)]
112pub struct TextRenderer {
113    chain: FaceChain,
114    /// Reusable rasteriser. Resized on every render call (cheap — a
115    /// `Renderer` is just a few configuration fields plus a reusable
116    /// shared bitmap-cache `Arc<Mutex<...>>`). Holding it on the
117    /// renderer lets the cache survive across `render_run` calls so
118    /// the same glyph at the same size isn't re-rasterised.
119    renderer: Renderer,
120}
121
122impl TextRenderer {
123    /// Build a renderer around an already-parsed [`Face`]. The face
124    /// is owned for the lifetime of the renderer; if you need to
125    /// switch fonts mid-scene, build a second `TextRenderer`.
126    pub fn new(face: Face) -> Self {
127        Self {
128            chain: FaceChain::new(face),
129            // Initial canvas size is a placeholder — every render call
130            // resets `width` / `height` to the actual run extent before
131            // calling `Renderer::render`. The cache is what matters
132            // here, and it survives the resize.
133            renderer: Renderer::new(1, 1),
134        }
135    }
136
137    /// Borrow the underlying primary face — useful for caller-side
138    /// metric queries (line height, ascent) without re-parsing.
139    pub fn face(&self) -> &Face {
140        self.chain.primary()
141    }
142
143    /// Pixel line-height for `size_px`. Convenience wrapper around
144    /// [`Face::line_height_px`] so callers laying out multi-line
145    /// `TextRun` content don't need to dig into Scribe directly.
146    pub fn line_height_px(&self, size_px: f32) -> f32 {
147        self.face().line_height_px(size_px)
148    }
149
150    /// Render a `TextRun` into a freshly-allocated straight-alpha
151    /// RGBA bitmap sized to the run's natural glyph bounds.
152    ///
153    /// Returns an empty bitmap if the run shapes to zero glyphs (or
154    /// every glyph is non-rendering — e.g. a string of spaces).
155    pub fn render_run(&mut self, run: &TextRun) -> Result<RgbaBitmap, oxideav_scribe::Error> {
156        let size = sane_size(run.font_size);
157        let bm = self.shape_and_render_line(&run.text, size, run.color);
158        if run.italic && !bm.is_empty() {
159            return Ok(apply_fake_italic(&bm, size));
160        }
161        Ok(bm)
162    }
163
164    /// Render a `TextRun` into a caller-provided RGBA destination at
165    /// pen position `(pen_x, pen_y)`. The destination buffer must be
166    /// `dst_w * dst_h * 4` bytes (straight-alpha RGBA8). Pixels
167    /// outside the destination are clipped.
168    ///
169    /// `pen_x` / `pen_y` is the **top-left** of the run's bitmap
170    /// (NOT the typographic baseline) to match the previous Scribe
171    /// `RgbaBitmap` origin convention.
172    #[allow(clippy::too_many_arguments)]
173    pub fn render_run_into(
174        &mut self,
175        run: &TextRun,
176        dst: &mut [u8],
177        dst_w: u32,
178        dst_h: u32,
179        pen_x: i32,
180        pen_y: i32,
181    ) -> Result<(), oxideav_scribe::Error> {
182        let bm = self.render_run(run)?;
183        if bm.is_empty() {
184            return Ok(());
185        }
186        blit_rgba_straight(
187            dst, dst_w, dst_h, pen_x, pen_y, &bm.data, bm.width, bm.height,
188        );
189        // round-3 note: `run.underline` should drive a 1..2 px filled
190        // rectangle at `pen_y + ascent + 1` spanning `bm.width`. The
191        // Y-position needs the face's underline metric (post.underlinePosition
192        // / underlineThickness) which Scribe doesn't yet plumb through Face;
193        // once it does, draw it here.
194        let _ = run.underline;
195        let _ = run.advances;
196        Ok(())
197    }
198
199    /// Render a `TextRun` whose text may overflow `max_width_px` or
200    /// contain `\n` characters. Output is one bitmap per laid-out
201    /// line; the caller stacks them at the desired line-height.
202    pub fn render_run_wrapped(
203        &mut self,
204        run: &TextRun,
205        max_width_px: f32,
206    ) -> Result<Vec<RgbaBitmap>, oxideav_scribe::Error> {
207        let size = sane_size(run.font_size);
208        let lines =
209            oxideav_scribe::wrap_lines(self.face(), &run.text, size, max_width_px.max(0.0))?;
210        let mut out: Vec<RgbaBitmap> = Vec::with_capacity(lines.len());
211        for line in &lines {
212            let bm = self.shape_and_render_line(line, size, run.color);
213            let bm = if run.italic && !bm.is_empty() {
214                apply_fake_italic(&bm, size)
215            } else {
216                bm
217            };
218            out.push(bm);
219        }
220        Ok(out)
221    }
222
223    /// Render a `TextRun` into the destination, wrapping at
224    /// `max_width_px` (or splitting on `\n`). Lines stack downward
225    /// from `pen_y` at intervals of `self.line_height_px(size)` (or
226    /// `line_height_override` if `Some`).
227    #[allow(clippy::too_many_arguments)]
228    pub fn render_run_wrapped_into(
229        &mut self,
230        run: &TextRun,
231        dst: &mut [u8],
232        dst_w: u32,
233        dst_h: u32,
234        pen_x: i32,
235        pen_y: i32,
236        max_width_px: f32,
237        line_height_override: Option<f32>,
238    ) -> Result<(), oxideav_scribe::Error> {
239        let lines = self.render_run_wrapped(run, max_width_px)?;
240        if lines.is_empty() {
241            return Ok(());
242        }
243        let lh = line_height_override
244            .unwrap_or_else(|| self.line_height_px(sane_size(run.font_size)))
245            .max(1.0)
246            .ceil() as i32;
247        let mut y = pen_y;
248        for line in &lines {
249            if !line.is_empty() {
250                blit_rgba_straight(
251                    dst,
252                    dst_w,
253                    dst_h,
254                    pen_x,
255                    y,
256                    &line.data,
257                    line.width,
258                    line.height,
259                );
260            }
261            y += lh;
262        }
263        Ok(())
264    }
265
266    /// Lower-level path: shape `run.text` and composite directly into
267    /// a caller-provided [`RgbaBitmap`] at pen origin `(pen_x, pen_y)`.
268    /// Reuses the renderer's internal glyph-bitmap LRU. Useful when
269    /// the caller is already tiling several runs into one bitmap and
270    /// doesn't want a fresh allocation per run.
271    ///
272    /// `pen_x` / `pen_y` is the **typographic baseline** for this
273    /// entry point (matching the previous Scribe `compose_run`
274    /// convention), unlike the `render_run_into` family which uses the
275    /// bitmap top-left.
276    pub fn compose_run_at(
277        &mut self,
278        run: &TextRun,
279        dst: &mut RgbaBitmap,
280        pen_x: f32,
281        pen_y: f32,
282    ) -> Result<(), oxideav_scribe::Error> {
283        if dst.is_empty() {
284            return Ok(());
285        }
286        let size = sane_size(run.font_size);
287        let placed = Shaper::shape_to_paths(&self.chain, &run.text, size);
288        if placed.is_empty() {
289            return Ok(());
290        }
291        let fill = Paint::Solid(decode_paint(run.color));
292
293        // Build a vector frame the size of the destination, with the
294        // run translated so glyph (0, 0) lands at the requested
295        // baseline pen position.
296        let frame = build_run_frame(&placed, dst.width, dst.height, pen_x, pen_y, &fill);
297
298        self.renderer.width = dst.width;
299        self.renderer.height = dst.height;
300        self.renderer.background = CoreRgba::new(0, 0, 0, 0);
301        let video_frame = self.renderer.render(&frame);
302
303        if let Some(plane) = video_frame.planes.into_iter().next() {
304            // Composite the rendered frame onto `dst` (straight-alpha
305            // "over"); the rasteriser's output is already at the right
306            // size + position because the frame matches dst dims.
307            blit_rgba_straight(
308                &mut dst.data,
309                dst.width,
310                dst.height,
311                0,
312                0,
313                &plane.data,
314                dst.width,
315                dst.height,
316            );
317        }
318        Ok(())
319    }
320
321    /// Shape one logical line and rasterise it to a freshly-sized
322    /// [`RgbaBitmap`] tightly bounded by the run's natural extent.
323    /// Returns an empty bitmap when the line shapes to zero pixels
324    /// (empty string, whitespace-only, zero-size font).
325    fn shape_and_render_line(&mut self, text: &str, size_px: f32, colour: u32) -> RgbaBitmap {
326        if text.is_empty() {
327            return RgbaBitmap::default();
328        }
329        // Pre-pass through `Shaper::shape` to measure the run's pen
330        // extent so we can size the canvas. `shape_to_paths` re-shapes
331        // internally — round-2 wears the duplicate cost; once Scribe
332        // exposes a "shape + extents" API the second walk goes away.
333        let glyphs = match Shaper::shape(self.face(), text, size_px) {
334            Ok(g) => g,
335            Err(_) => return RgbaBitmap::default(),
336        };
337        if glyphs.is_empty() {
338            return RgbaBitmap::default();
339        }
340        let advance_px: f32 = glyphs.iter().map(|g| g.x_offset + g.x_advance).sum();
341        let ascent_px = self.face().ascent_px(size_px);
342        let descent_px = self.face().descent_px(size_px); // typically negative
343        let glyph_w = advance_px.ceil().max(0.0) as u32;
344        let glyph_h = (ascent_px - descent_px).ceil().max(0.0) as u32;
345        if glyph_w == 0 || glyph_h == 0 {
346            return RgbaBitmap::default();
347        }
348
349        let placed = Shaper::shape_to_paths(&self.chain, text, size_px);
350        if placed.is_empty() {
351            return RgbaBitmap::default();
352        }
353        let fill = Paint::Solid(decode_paint(colour));
354        // Pen Y inside the bitmap: top sits at y=0, baseline at y=ascent_px.
355        let frame = build_run_frame(&placed, glyph_w, glyph_h, 0.0, ascent_px, &fill);
356
357        self.renderer.width = glyph_w;
358        self.renderer.height = glyph_h;
359        self.renderer.background = CoreRgba::new(0, 0, 0, 0);
360        let video_frame = self.renderer.render(&frame);
361        let plane = match video_frame.planes.into_iter().next() {
362            Some(p) => p,
363            None => return RgbaBitmap::default(),
364        };
365        let expected = (glyph_w as usize) * (glyph_h as usize) * 4;
366        if plane.data.len() != expected {
367            return RgbaBitmap::default();
368        }
369        RgbaBitmap {
370            width: glyph_w,
371            height: glyph_h,
372            data: plane.data,
373        }
374    }
375}
376
377// ---------------------------------------------------------------------------
378// Helpers
379// ---------------------------------------------------------------------------
380
381/// Build a `VectorFrame` of `(canvas_w, canvas_h)` containing the
382/// placed glyph chain, translated so the shaper's run-relative
383/// origin (baseline-left) lands at `(pen_x, pen_y)` inside the canvas.
384/// Each glyph is recoloured to `fill` (outline glyphs only — bitmap
385/// `Node::Image` glyphs keep their built-in palette).
386fn build_run_frame(
387    placed: &[(usize, Node, Transform2D)],
388    canvas_w: u32,
389    canvas_h: u32,
390    pen_x: f32,
391    pen_y: f32,
392    fill: &Paint,
393) -> VectorFrame {
394    let mut root = Group {
395        transform: Transform2D::translate(pen_x, pen_y),
396        ..Group::default()
397    };
398    for (_face_idx, glyph_node, transform) in placed {
399        let recoloured = recolour_glyph(glyph_node.clone(), fill);
400        let placement = Group {
401            transform: *transform,
402            children: vec![recoloured],
403            ..Group::default()
404        };
405        root.children.push(Node::Group(placement));
406    }
407    VectorFrame {
408        width: canvas_w as f32,
409        height: canvas_h as f32,
410        view_box: None,
411        root,
412        pts: None,
413        time_base: TimeBase::new(1, 1),
414    }
415}
416
417/// Replace the default-black `PathNode.fill` on outline glyph nodes
418/// with the requested run colour. `shape_to_paths` always wraps each
419/// glyph in `Group { children: [PathNode | Image] }` (round-8 cache
420/// envelope), so we walk into that one child and rewrite the fill in
421/// place. Bitmap glyphs (`Node::Image`) carry their own colour and are
422/// left untouched. Mirrors the helper in
423/// `oxideav_generator::image::label::recolour_glyph`.
424fn recolour_glyph(node: Node, fill: &Paint) -> Node {
425    match node {
426        Node::Group(mut g) => {
427            for child in g.children.iter_mut() {
428                let placeholder = std::mem::replace(child, Node::Group(Group::default()));
429                *child = recolour_glyph(placeholder, fill);
430            }
431            Node::Group(g)
432        }
433        Node::Path(p) => Node::Path(PathNode {
434            path: p.path,
435            fill: Some(fill.clone()),
436            stroke: p.stroke,
437            fill_rule: FillRule::NonZero,
438        }),
439        // Bitmap glyphs (CBDT/sbix → Node::Image) keep their carried
440        // palette; the run `color` parameter is meaningless for them.
441        other => other,
442    }
443}
444
445/// Decode `0xRRGGBBAA` (TextRun convention) into the
446/// [`oxideav_core::Rgba`] the rasteriser consumes.
447fn decode_paint(packed: u32) -> CoreRgba {
448    let [r, g, b, a] = decode_rgba(packed);
449    CoreRgba::new(r, g, b, a)
450}
451
452/// Decode `0xRRGGBBAA` (TextRun convention) into the `[R, G, B, A]`
453/// quartet. Unfinite/zero alpha is preserved — the rasteriser's
454/// composite path treats a 0-alpha colour as "draw nothing," which
455/// matches the expected behaviour.
456fn decode_rgba(packed: u32) -> [u8; 4] {
457    [
458        ((packed >> 24) & 0xff) as u8,
459        ((packed >> 16) & 0xff) as u8,
460        ((packed >> 8) & 0xff) as u8,
461        (packed & 0xff) as u8,
462    ]
463}
464
465/// Clamp font size to a strictly-positive finite value. Scribe
466/// rejects non-positive sizes by returning empty output; rather than
467/// silently swallow that for clearly malformed `TextRun`s, fall back
468/// to a tiny default so the renderer keeps producing output.
469fn sane_size(s: f32) -> f32 {
470    if s.is_finite() && s > 0.0 {
471        s
472    } else {
473        1.0
474    }
475}
476
477/// Apply a "fake italic" by horizontally shearing the rasterised
478/// bitmap. The shear factor is `font_size / 4`, the same magnitude
479/// `oxideav_subtitle::compositor` uses for its bitmap-font italic.
480/// Top rows shift right, bottom rows shift left — the bitmap widens
481/// by `shear_px` to fit both extremes.
482fn apply_fake_italic(src: &RgbaBitmap, size_px: f32) -> RgbaBitmap {
483    let shear_px = (size_px / 4.0).round().max(0.0) as u32;
484    if shear_px == 0 || src.is_empty() {
485        return src.clone();
486    }
487    let new_w = src.width.saturating_add(shear_px);
488    let mut out = RgbaBitmap::new(new_w, src.height);
489    let h = src.height;
490    let src_w = src.width as usize;
491    let dst_w = new_w as usize;
492    let denom = (h as f32).max(1.0);
493    for y in 0..h {
494        // Top of bitmap shifts right by `shear_px`, baseline shifts 0.
495        let frac = 1.0 - (y as f32 / denom);
496        let dx = (frac * shear_px as f32).round() as usize;
497        let src_off = (y as usize) * src_w * 4;
498        let dst_off = (y as usize) * dst_w * 4;
499        for x in 0..src_w {
500            let so = src_off + x * 4;
501            let dst_x = x + dx;
502            if dst_x >= dst_w {
503                break;
504            }
505            let dop = dst_off + dst_x * 4;
506            out.data[dop] = src.data[so];
507            out.data[dop + 1] = src.data[so + 1];
508            out.data[dop + 2] = src.data[so + 2];
509            out.data[dop + 3] = src.data[so + 3];
510        }
511    }
512    out
513}
514
515/// Composite a straight-alpha RGBA8 source bitmap onto a straight-alpha
516/// RGBA8 destination at `(x, y)` (top-left). Pixels outside the
517/// destination rectangle are clipped. Blend is Porter-Duff "over" via
518/// [`oxideav_pixfmt::over_straight`]. Mirrors the helper in
519/// `oxideav_subtitle::compositor::blit_rgba_straight` so the two
520/// renderers behave identically when fed the same source bitmap.
521#[allow(clippy::too_many_arguments)]
522fn blit_rgba_straight(
523    dst: &mut [u8],
524    dst_w: u32,
525    dst_h: u32,
526    x: i32,
527    y: i32,
528    src: &[u8],
529    src_w: u32,
530    src_h: u32,
531) {
532    if dst_w == 0 || dst_h == 0 || src_w == 0 || src_h == 0 {
533        return;
534    }
535    let dx0 = x.max(0);
536    let dy0 = y.max(0);
537    let dx1 = (x + src_w as i32).min(dst_w as i32);
538    let dy1 = (y + src_h as i32).min(dst_h as i32);
539    if dx0 >= dx1 || dy0 >= dy1 {
540        return;
541    }
542    let sx0 = (dx0 - x) as usize;
543    let sy0 = (dy0 - y) as usize;
544    let blit_w = (dx1 - dx0) as usize;
545    let blit_h = (dy1 - dy0) as usize;
546    let dst_stride = dst_w as usize * 4;
547    let src_stride = src_w as usize * 4;
548    for row in 0..blit_h {
549        let dst_row_off = (dy0 as usize + row) * dst_stride + (dx0 as usize) * 4;
550        let src_row_off = (sy0 + row) * src_stride + sx0 * 4;
551        for col in 0..blit_w {
552            let so = src_row_off + col * 4;
553            let s = [src[so], src[so + 1], src[so + 2], src[so + 3]];
554            if s[3] == 0 {
555                continue;
556            }
557            let dop = dst_row_off + col * 4;
558            let d = [dst[dop], dst[dop + 1], dst[dop + 2], dst[dop + 3]];
559            let out = oxideav_pixfmt::over_straight(s, d);
560            dst[dop] = out[0];
561            dst[dop + 1] = out[1];
562            dst[dop + 2] = out[2];
563            dst[dop + 3] = out[3];
564        }
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    fn load_dejavu() -> Option<Face> {
573        // Look for the fixture both relative to this crate (when
574        // tested via `cargo test -p oxideav-scene` from the umbrella)
575        // and inside it (if a copy is dropped here later).
576        let candidates = [
577            "../oxideav-ttf/tests/fixtures/DejaVuSans.ttf",
578            "tests/fixtures/DejaVuSans.ttf",
579        ];
580        for path in candidates {
581            if let Ok(bytes) = std::fs::read(path) {
582                return Face::from_ttf_bytes(bytes).ok();
583            }
584        }
585        None
586    }
587
588    fn make_run(text: &str) -> TextRun {
589        TextRun {
590            text: text.to_string(),
591            font_family: "DejaVu Sans".to_string(),
592            font_weight: 400,
593            font_size: 24.0,
594            color: 0xFFFFFFFF,
595            advances: None,
596            italic: false,
597            underline: false,
598        }
599    }
600
601    #[test]
602    fn decode_rgba_orders_channels_msb_first() {
603        // 0xAARRGGBB? No — TextRun is documented as 0xRRGGBBAA.
604        let c = decode_rgba(0xFF8040C0);
605        assert_eq!(c, [0xFF, 0x80, 0x40, 0xC0]);
606    }
607
608    #[test]
609    fn sane_size_clamps_zero_and_nan() {
610        assert_eq!(sane_size(12.0), 12.0);
611        assert_eq!(sane_size(0.0), 1.0);
612        assert_eq!(sane_size(-3.0), 1.0);
613        assert!(sane_size(f32::NAN) > 0.0);
614    }
615
616    #[test]
617    fn render_run_lights_pixels_at_pen_position() {
618        let face = match load_dejavu() {
619            Some(f) => f,
620            None => return, // fixture missing — skip
621        };
622        let mut tr = TextRenderer::new(face);
623        let run = make_run("Hello, world!");
624
625        let dst_w: u32 = 200;
626        let dst_h: u32 = 40;
627        let mut dst = vec![0u8; (dst_w as usize) * (dst_h as usize) * 4];
628
629        // Pen at (5, 5) — leaves room above/below for descenders.
630        tr.render_run_into(&run, &mut dst, dst_w, dst_h, 5, 5)
631            .unwrap();
632
633        // 1. Total lit pixel count is non-trivial.
634        let lit = dst.chunks_exact(4).filter(|p| p[3] > 0).count();
635        assert!(
636            lit > 50,
637            "expected glyph coverage; got only {lit} lit pixels"
638        );
639
640        // 2. Some lit pixel sits within the run's pen region —
641        //    i.e. roughly in the upper-left quadrant where the
642        //    "Hello" prefix falls.
643        let mut hit_near_pen = false;
644        'outer: for y in 5..30u32 {
645            for x in 5..120u32 {
646                let off = (y as usize * dst_w as usize + x as usize) * 4;
647                if dst[off + 3] > 0 {
648                    hit_near_pen = true;
649                    break 'outer;
650                }
651            }
652        }
653        assert!(
654            hit_near_pen,
655            "no lit pixel found near pen position (5,5) — text not landing where requested"
656        );
657    }
658
659    #[test]
660    fn render_run_honours_run_color() {
661        let face = match load_dejavu() {
662            Some(f) => f,
663            None => return,
664        };
665        let mut tr = TextRenderer::new(face);
666        let mut run = make_run("X");
667        run.color = 0xFF0000FF; // pure red, fully opaque
668
669        let bm = tr.render_run(&run).unwrap();
670        if bm.is_empty() {
671            // Glyph rasterised empty? unexpected for "X" but don't crash.
672            return;
673        }
674        // At least one solidly-lit pixel should be red-dominant.
675        let mut found_red = false;
676        for px in bm.data.chunks_exact(4) {
677            if px[3] > 200 && px[0] > 200 && px[1] < 60 && px[2] < 60 {
678                found_red = true;
679                break;
680            }
681        }
682        assert!(found_red, "no red-dominant lit pixel — colour not applied");
683    }
684
685    #[test]
686    fn empty_run_produces_empty_bitmap() {
687        let face = match load_dejavu() {
688            Some(f) => f,
689            None => return,
690        };
691        let mut tr = TextRenderer::new(face);
692        let run = make_run("");
693        let bm = tr.render_run(&run).unwrap();
694        assert!(bm.is_empty());
695    }
696
697    #[test]
698    fn whitespace_only_run_produces_empty_bitmap() {
699        let face = match load_dejavu() {
700            Some(f) => f,
701            None => return,
702        };
703        let mut tr = TextRenderer::new(face);
704        let run = make_run("    ");
705        let bm = tr.render_run(&run).unwrap();
706        // Whitespace shapes to space glyphs whose `glyph_node` returns
707        // None (no rendering output) — `shape_and_render_line` then
708        // collapses to an empty bitmap because `placed.is_empty()`
709        // even though the pen advance is non-zero. Either an empty
710        // bitmap OR a fully-transparent allocated bitmap is acceptable;
711        // assert "no lit pixels" instead of strict emptiness so the
712        // contract isn't tightened beyond what the previous Scribe
713        // path guaranteed.
714        let lit = bm.data.chunks_exact(4).filter(|p| p[3] > 0).count();
715        assert_eq!(lit, 0);
716    }
717
718    #[test]
719    fn invalid_font_size_does_not_panic() {
720        let face = match load_dejavu() {
721            Some(f) => f,
722            None => return,
723        };
724        let mut tr = TextRenderer::new(face);
725        let mut run = make_run("Hi");
726        run.font_size = 0.0;
727        // Should clamp to a sane size and not error out.
728        let _ = tr.render_run(&run).unwrap();
729        run.font_size = f32::NAN;
730        let _ = tr.render_run(&run).unwrap();
731    }
732
733    #[test]
734    fn wrapped_run_returns_multiple_lines() {
735        let face = match load_dejavu() {
736            Some(f) => f,
737            None => return,
738        };
739        let mut tr = TextRenderer::new(face);
740        let run = make_run("Hello\nworld");
741        let lines = tr.render_run_wrapped(&run, 1000.0).unwrap();
742        assert_eq!(lines.len(), 2);
743    }
744
745    #[test]
746    fn italic_widens_bitmap() {
747        let face = match load_dejavu() {
748            Some(f) => f,
749            None => return,
750        };
751        let mut tr = TextRenderer::new(face);
752        let mut upright = make_run("Hi");
753        upright.italic = false;
754        let mut italic = make_run("Hi");
755        italic.italic = true;
756        let a = tr.render_run(&upright).unwrap();
757        let b = tr.render_run(&italic).unwrap();
758        if a.is_empty() || b.is_empty() {
759            return;
760        }
761        assert!(
762            b.width >= a.width,
763            "italic shear should not narrow the bitmap (upright {}, italic {})",
764            a.width,
765            b.width
766        );
767    }
768}