use oxideav_core::{
FillRule, Group, Node, Paint, PathNode, Rgba as CoreRgba, TimeBase, Transform2D, VectorFrame,
};
use oxideav_raster::Renderer;
use oxideav_scribe::{Face, FaceChain, Shaper};
use crate::object::TextRun;
#[derive(Debug, Clone, Default)]
pub struct RgbaBitmap {
pub width: u32,
pub height: u32,
pub data: Vec<u8>,
}
impl RgbaBitmap {
pub fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
data: vec![0; (width as usize) * (height as usize) * 4],
}
}
pub fn is_empty(&self) -> bool {
self.width == 0 || self.height == 0
}
}
#[derive(Debug)]
pub struct TextRenderer {
chain: FaceChain,
renderer: Renderer,
}
impl TextRenderer {
pub fn new(face: Face) -> Self {
Self {
chain: FaceChain::new(face),
renderer: Renderer::new(1, 1),
}
}
pub fn face(&self) -> &Face {
self.chain.primary()
}
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 bm = self.shape_and_render_line(&run.text, size, run.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 lines =
oxideav_scribe::wrap_lines(self.face(), &run.text, size, max_width_px.max(0.0))?;
let mut out: Vec<RgbaBitmap> = Vec::with_capacity(lines.len());
for line in &lines {
let bm = self.shape_and_render_line(line, size, run.color);
let bm = if run.italic && !bm.is_empty() {
apply_fake_italic(&bm, size)
} else {
bm
};
out.push(bm);
}
Ok(out)
}
#[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> {
if dst.is_empty() {
return Ok(());
}
let size = sane_size(run.font_size);
let placed = Shaper::shape_to_paths(&self.chain, &run.text, size);
if placed.is_empty() {
return Ok(());
}
let fill = Paint::Solid(decode_paint(run.color));
let frame = build_run_frame(&placed, dst.width, dst.height, pen_x, pen_y, &fill);
self.renderer.width = dst.width;
self.renderer.height = dst.height;
self.renderer.background = CoreRgba::new(0, 0, 0, 0);
let video_frame = self.renderer.render(&frame);
if let Some(plane) = video_frame.planes.into_iter().next() {
blit_rgba_straight(
&mut dst.data,
dst.width,
dst.height,
0,
0,
&plane.data,
dst.width,
dst.height,
);
}
Ok(())
}
fn shape_and_render_line(&mut self, text: &str, size_px: f32, colour: u32) -> RgbaBitmap {
if text.is_empty() {
return RgbaBitmap::default();
}
let glyphs = match Shaper::shape(self.face(), text, size_px) {
Ok(g) => g,
Err(_) => return RgbaBitmap::default(),
};
if glyphs.is_empty() {
return RgbaBitmap::default();
}
let advance_px: f32 = glyphs.iter().map(|g| g.x_offset + g.x_advance).sum();
let ascent_px = self.face().ascent_px(size_px);
let descent_px = self.face().descent_px(size_px); let glyph_w = advance_px.ceil().max(0.0) as u32;
let glyph_h = (ascent_px - descent_px).ceil().max(0.0) as u32;
if glyph_w == 0 || glyph_h == 0 {
return RgbaBitmap::default();
}
let placed = Shaper::shape_to_paths(&self.chain, text, size_px);
if placed.is_empty() {
return RgbaBitmap::default();
}
let fill = Paint::Solid(decode_paint(colour));
let frame = build_run_frame(&placed, glyph_w, glyph_h, 0.0, ascent_px, &fill);
self.renderer.width = glyph_w;
self.renderer.height = glyph_h;
self.renderer.background = CoreRgba::new(0, 0, 0, 0);
let video_frame = self.renderer.render(&frame);
let plane = match video_frame.planes.into_iter().next() {
Some(p) => p,
None => return RgbaBitmap::default(),
};
let expected = (glyph_w as usize) * (glyph_h as usize) * 4;
if plane.data.len() != expected {
return RgbaBitmap::default();
}
RgbaBitmap {
width: glyph_w,
height: glyph_h,
data: plane.data,
}
}
}
fn build_run_frame(
placed: &[(usize, Node, Transform2D)],
canvas_w: u32,
canvas_h: u32,
pen_x: f32,
pen_y: f32,
fill: &Paint,
) -> VectorFrame {
let mut root = Group {
transform: Transform2D::translate(pen_x, pen_y),
..Group::default()
};
for (_face_idx, glyph_node, transform) in placed {
let recoloured = recolour_glyph(glyph_node.clone(), fill);
let placement = Group {
transform: *transform,
children: vec![recoloured],
..Group::default()
};
root.children.push(Node::Group(placement));
}
VectorFrame {
width: canvas_w as f32,
height: canvas_h as f32,
view_box: None,
root,
pts: None,
time_base: TimeBase::new(1, 1),
}
}
fn recolour_glyph(node: Node, fill: &Paint) -> Node {
match node {
Node::Group(mut g) => {
for child in g.children.iter_mut() {
let placeholder = std::mem::replace(child, Node::Group(Group::default()));
*child = recolour_glyph(placeholder, fill);
}
Node::Group(g)
}
Node::Path(p) => Node::Path(PathNode {
path: p.path,
fill: Some(fill.clone()),
stroke: p.stroke,
fill_rule: FillRule::NonZero,
}),
other => other,
}
}
fn decode_paint(packed: u32) -> CoreRgba {
let [r, g, b, a] = decode_rgba(packed);
CoreRgba::new(r, g, b, a)
}
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();
let lit = bm.data.chunks_exact(4).filter(|p| p[3] > 0).count();
assert_eq!(lit, 0);
}
#[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
);
}
}