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}