use oxideav_scribe::{Composer, Face, RgbaBitmap, Shaper};
use crate::object::TextRun;
#[derive(Debug)]
pub struct TextRenderer {
face: Face,
composer: Composer,
}
impl TextRenderer {
pub fn new(face: Face) -> Self {
Self {
face,
composer: Composer::new(),
}
}
pub fn face(&self) -> &Face {
&self.face
}
pub fn line_height_px(&self, size_px: f32) -> f32 {
self.face.line_height_px(size_px)
}
pub fn render_run(&mut self, run: &TextRun) -> Result<RgbaBitmap, oxideav_scribe::Error> {
let size = sane_size(run.font_size);
let color = decode_rgba(run.color);
let bm = oxideav_scribe::render_text(&self.face, &run.text, size, color)?;
if run.italic && !bm.is_empty() {
return Ok(apply_fake_italic(&bm, size));
}
Ok(bm)
}
#[allow(clippy::too_many_arguments)]
pub fn render_run_into(
&mut self,
run: &TextRun,
dst: &mut [u8],
dst_w: u32,
dst_h: u32,
pen_x: i32,
pen_y: i32,
) -> Result<(), oxideav_scribe::Error> {
let bm = self.render_run(run)?;
if bm.is_empty() {
return Ok(());
}
blit_rgba_straight(
dst, dst_w, dst_h, pen_x, pen_y, &bm.data, bm.width, bm.height,
);
let _ = run.underline;
let _ = run.advances;
Ok(())
}
pub fn render_run_wrapped(
&mut self,
run: &TextRun,
max_width_px: f32,
) -> Result<Vec<RgbaBitmap>, oxideav_scribe::Error> {
let size = sane_size(run.font_size);
let color = decode_rgba(run.color);
let bms =
oxideav_scribe::render_text_wrapped(&self.face, &run.text, size, color, max_width_px)?;
if run.italic {
return Ok(bms
.into_iter()
.map(|bm| {
if bm.is_empty() {
bm
} else {
apply_fake_italic(&bm, size)
}
})
.collect());
}
Ok(bms)
}
#[allow(clippy::too_many_arguments)]
pub fn render_run_wrapped_into(
&mut self,
run: &TextRun,
dst: &mut [u8],
dst_w: u32,
dst_h: u32,
pen_x: i32,
pen_y: i32,
max_width_px: f32,
line_height_override: Option<f32>,
) -> Result<(), oxideav_scribe::Error> {
let lines = self.render_run_wrapped(run, max_width_px)?;
if lines.is_empty() {
return Ok(());
}
let lh = line_height_override
.unwrap_or_else(|| self.line_height_px(sane_size(run.font_size)))
.max(1.0)
.ceil() as i32;
let mut y = pen_y;
for line in &lines {
if !line.is_empty() {
blit_rgba_straight(
dst,
dst_w,
dst_h,
pen_x,
y,
&line.data,
line.width,
line.height,
);
}
y += lh;
}
Ok(())
}
pub fn compose_run_at(
&mut self,
run: &TextRun,
dst: &mut RgbaBitmap,
pen_x: f32,
pen_y: f32,
) -> Result<(), oxideav_scribe::Error> {
let size = sane_size(run.font_size);
let color = decode_rgba(run.color);
let glyphs = Shaper::shape(&self.face, &run.text, size)?;
if glyphs.is_empty() || dst.is_empty() {
return Ok(());
}
self.composer
.compose_run(&glyphs, &self.face, size, color, dst, pen_x, pen_y)
}
}
fn decode_rgba(packed: u32) -> [u8; 4] {
[
((packed >> 24) & 0xff) as u8,
((packed >> 16) & 0xff) as u8,
((packed >> 8) & 0xff) as u8,
(packed & 0xff) as u8,
]
}
fn sane_size(s: f32) -> f32 {
if s.is_finite() && s > 0.0 {
s
} else {
1.0
}
}
fn apply_fake_italic(src: &RgbaBitmap, size_px: f32) -> RgbaBitmap {
let shear_px = (size_px / 4.0).round().max(0.0) as u32;
if shear_px == 0 || src.is_empty() {
return src.clone();
}
let new_w = src.width.saturating_add(shear_px);
let mut out = RgbaBitmap::new(new_w, src.height);
let h = src.height;
let src_w = src.width as usize;
let dst_w = new_w as usize;
let denom = (h as f32).max(1.0);
for y in 0..h {
let frac = 1.0 - (y as f32 / denom);
let dx = (frac * shear_px as f32).round() as usize;
let src_off = (y as usize) * src_w * 4;
let dst_off = (y as usize) * dst_w * 4;
for x in 0..src_w {
let so = src_off + x * 4;
let dst_x = x + dx;
if dst_x >= dst_w {
break;
}
let dop = dst_off + dst_x * 4;
out.data[dop] = src.data[so];
out.data[dop + 1] = src.data[so + 1];
out.data[dop + 2] = src.data[so + 2];
out.data[dop + 3] = src.data[so + 3];
}
}
out
}
#[allow(clippy::too_many_arguments)]
fn blit_rgba_straight(
dst: &mut [u8],
dst_w: u32,
dst_h: u32,
x: i32,
y: i32,
src: &[u8],
src_w: u32,
src_h: u32,
) {
if dst_w == 0 || dst_h == 0 || src_w == 0 || src_h == 0 {
return;
}
let dx0 = x.max(0);
let dy0 = y.max(0);
let dx1 = (x + src_w as i32).min(dst_w as i32);
let dy1 = (y + src_h as i32).min(dst_h as i32);
if dx0 >= dx1 || dy0 >= dy1 {
return;
}
let sx0 = (dx0 - x) as usize;
let sy0 = (dy0 - y) as usize;
let blit_w = (dx1 - dx0) as usize;
let blit_h = (dy1 - dy0) as usize;
let dst_stride = dst_w as usize * 4;
let src_stride = src_w as usize * 4;
for row in 0..blit_h {
let dst_row_off = (dy0 as usize + row) * dst_stride + (dx0 as usize) * 4;
let src_row_off = (sy0 + row) * src_stride + sx0 * 4;
for col in 0..blit_w {
let so = src_row_off + col * 4;
let s = [src[so], src[so + 1], src[so + 2], src[so + 3]];
if s[3] == 0 {
continue;
}
let dop = dst_row_off + col * 4;
let d = [dst[dop], dst[dop + 1], dst[dop + 2], dst[dop + 3]];
let out = oxideav_pixfmt::over_straight(s, d);
dst[dop] = out[0];
dst[dop + 1] = out[1];
dst[dop + 2] = out[2];
dst[dop + 3] = out[3];
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn load_dejavu() -> Option<Face> {
let candidates = [
"../oxideav-ttf/tests/fixtures/DejaVuSans.ttf",
"tests/fixtures/DejaVuSans.ttf",
];
for path in candidates {
if let Ok(bytes) = std::fs::read(path) {
return Face::from_ttf_bytes(bytes).ok();
}
}
None
}
fn make_run(text: &str) -> TextRun {
TextRun {
text: text.to_string(),
font_family: "DejaVu Sans".to_string(),
font_weight: 400,
font_size: 24.0,
color: 0xFFFFFFFF,
advances: None,
italic: false,
underline: false,
}
}
#[test]
fn decode_rgba_orders_channels_msb_first() {
let c = decode_rgba(0xFF8040C0);
assert_eq!(c, [0xFF, 0x80, 0x40, 0xC0]);
}
#[test]
fn sane_size_clamps_zero_and_nan() {
assert_eq!(sane_size(12.0), 12.0);
assert_eq!(sane_size(0.0), 1.0);
assert_eq!(sane_size(-3.0), 1.0);
assert!(sane_size(f32::NAN) > 0.0);
}
#[test]
fn render_run_lights_pixels_at_pen_position() {
let face = match load_dejavu() {
Some(f) => f,
None => return, };
let mut tr = TextRenderer::new(face);
let run = make_run("Hello, world!");
let dst_w: u32 = 200;
let dst_h: u32 = 40;
let mut dst = vec![0u8; (dst_w as usize) * (dst_h as usize) * 4];
tr.render_run_into(&run, &mut dst, dst_w, dst_h, 5, 5)
.unwrap();
let lit = dst.chunks_exact(4).filter(|p| p[3] > 0).count();
assert!(
lit > 50,
"expected glyph coverage; got only {lit} lit pixels"
);
let mut hit_near_pen = false;
'outer: for y in 5..30u32 {
for x in 5..120u32 {
let off = (y as usize * dst_w as usize + x as usize) * 4;
if dst[off + 3] > 0 {
hit_near_pen = true;
break 'outer;
}
}
}
assert!(
hit_near_pen,
"no lit pixel found near pen position (5,5) — text not landing where requested"
);
}
#[test]
fn render_run_honours_run_color() {
let face = match load_dejavu() {
Some(f) => f,
None => return,
};
let mut tr = TextRenderer::new(face);
let mut run = make_run("X");
run.color = 0xFF0000FF;
let bm = tr.render_run(&run).unwrap();
if bm.is_empty() {
return;
}
let mut found_red = false;
for px in bm.data.chunks_exact(4) {
if px[3] > 200 && px[0] > 200 && px[1] < 60 && px[2] < 60 {
found_red = true;
break;
}
}
assert!(found_red, "no red-dominant lit pixel — colour not applied");
}
#[test]
fn empty_run_produces_empty_bitmap() {
let face = match load_dejavu() {
Some(f) => f,
None => return,
};
let mut tr = TextRenderer::new(face);
let run = make_run("");
let bm = tr.render_run(&run).unwrap();
assert!(bm.is_empty());
}
#[test]
fn whitespace_only_run_produces_empty_bitmap() {
let face = match load_dejavu() {
Some(f) => f,
None => return,
};
let mut tr = TextRenderer::new(face);
let run = make_run(" ");
let bm = tr.render_run(&run).unwrap();
assert!(bm.is_empty());
}
#[test]
fn invalid_font_size_does_not_panic() {
let face = match load_dejavu() {
Some(f) => f,
None => return,
};
let mut tr = TextRenderer::new(face);
let mut run = make_run("Hi");
run.font_size = 0.0;
let _ = tr.render_run(&run).unwrap();
run.font_size = f32::NAN;
let _ = tr.render_run(&run).unwrap();
}
#[test]
fn wrapped_run_returns_multiple_lines() {
let face = match load_dejavu() {
Some(f) => f,
None => return,
};
let mut tr = TextRenderer::new(face);
let run = make_run("Hello\nworld");
let lines = tr.render_run_wrapped(&run, 1000.0).unwrap();
assert_eq!(lines.len(), 2);
}
#[test]
fn italic_widens_bitmap() {
let face = match load_dejavu() {
Some(f) => f,
None => return,
};
let mut tr = TextRenderer::new(face);
let mut upright = make_run("Hi");
upright.italic = false;
let mut italic = make_run("Hi");
italic.italic = true;
let a = tr.render_run(&upright).unwrap();
let b = tr.render_run(&italic).unwrap();
if a.is_empty() || b.is_empty() {
return;
}
assert!(
b.width >= a.width,
"italic shear should not narrow the bitmap (upright {}, italic {})",
a.width,
b.width
);
}
}