mod context;
pub use context::TinySkiaCpuRenderContext;
#[cfg(test)]
mod text_to_path_tests {
use uzor::render::TextMetrics;
use super::TinySkiaCpuRenderContext;
fn ctx() -> TinySkiaCpuRenderContext {
TinySkiaCpuRenderContext::new(1, 1, 1.0)
}
#[test]
fn hello_returns_path_starting_with_m() {
let d = ctx().text_to_path("HELLO", "bold 48px sans-serif");
assert!(!d.is_empty(), "text_to_path('HELLO') should return non-empty path");
assert!(d.starts_with('M'), "path should start with M, got: {:?}", &d[..d.len().min(20)]);
}
#[test]
fn empty_text_returns_empty_string() {
let d = ctx().text_to_path("", "16px sans-serif");
assert!(d.is_empty(), "empty text should return empty path");
}
#[test]
fn path_ends_with_z() {
let d = ctx().text_to_path("Hi", "16px sans-serif");
assert!(!d.is_empty(), "non-empty input should produce a path");
assert!(d.ends_with('Z'), "path should end with Z, got: {:?}", &d[d.len().saturating_sub(20)..]);
}
#[test]
fn cache_returns_same_string() {
let c = ctx();
let d1 = c.text_to_path("AB", "24px sans-serif");
let d2 = c.text_to_path("AB", "24px sans-serif");
assert_eq!(d1, d2, "cached call should return identical path");
}
#[test]
fn different_text_different_path() {
let c = ctx();
let d_a = c.text_to_path("A", "24px sans-serif");
let d_b = c.text_to_path("B", "24px sans-serif");
assert_ne!(d_a, d_b, "different glyphs should produce different paths");
}
}
#[cfg(test)]
mod fill_text_scale_tests {
use uzor::render::{Painter, TextAlign, TextBaseline, TextRenderer};
use tiny_skia::Pixmap;
use super::TinySkiaCpuRenderContext;
const FONT: &str = "24px sans-serif";
const BASELINE_X: f64 = 10.0;
const BASELINE_Y: f64 = 40.0;
fn render_scaled(text: &str, w: u32, h: u32, sx: f64, sy: f64) -> TinySkiaCpuRenderContext {
let mut ctx = TinySkiaCpuRenderContext::new(w, h, 1.0);
ctx.scale(sx, sy);
ctx.set_fill_color("#000000");
ctx.set_font(FONT);
ctx.set_text_align(TextAlign::Left);
ctx.set_text_baseline(TextBaseline::Alphabetic);
ctx.fill_text(text, BASELINE_X, BASELINE_Y);
ctx
}
fn column_has_ink(pixmap: &Pixmap, x: u32) -> bool {
let w = pixmap.width();
let h = pixmap.height();
let data = pixmap.data();
(0..h).any(|y| data[((y * w + x) * 4 + 3) as usize] > 0)
}
fn ink_bbox_x(pixmap: &Pixmap) -> Option<(u32, u32)> {
let w = pixmap.width();
let mut min_x = None;
let mut max_x = None;
for x in 0..w {
if column_has_ink(pixmap, x) {
min_x.get_or_insert(x);
max_x = Some(x);
}
}
min_x.zip(max_x)
}
fn widest_interior_gap(pixmap: &Pixmap, min_x: u32, max_x: u32) -> u32 {
let mut widest = 0u32;
let mut current = 0u32;
for x in min_x..=max_x {
if column_has_ink(pixmap, x) {
current = 0;
} else {
current += 1;
widest = widest.max(current);
}
}
widest
}
#[test]
fn scale_2x_render_ink_width_is_about_2x_not_4x() {
let text = "Hello World";
let ctx1 = render_scaled(text, 200, 60, 1.0, 1.0);
let (min1, max1) = ink_bbox_x(ctx1.pixmap()).expect("scale(1) render must paint some ink");
let width1 = (max1 - min1 + 1) as f64;
let ctx2 = render_scaled(text, 400, 120, 2.0, 2.0);
let (min2, max2) = ink_bbox_x(ctx2.pixmap()).expect("scale(2) render must paint some ink");
let width2 = (max2 - min2 + 1) as f64;
assert!(
(width2 - 2.0 * width1).abs() < 6.0,
"scale(2) ink width must be ~2x the scale(1) width (a few px tolerance), \
got width1={width1} width2={width2} (ratio={:.2})",
width2 / width1
);
assert!(
width2 < 3.0 * width1,
"scale(2) ink width must not be ~4x the scale(1) width (the double-scaling bug) — \
got width1={width1} width2={width2} (ratio={:.2})",
width2 / width1
);
}
#[test]
fn scale_2x_render_inter_word_gap_scales_about_2x_not_4x() {
let text = "Hello World";
let ctx1 = render_scaled(text, 200, 60, 1.0, 1.0);
let (min1, max1) = ink_bbox_x(ctx1.pixmap()).expect("scale(1) render must paint some ink");
let gap1 = widest_interior_gap(ctx1.pixmap(), min1, max1);
assert!(gap1 > 0, "fixture must have a visible inter-word gap at scale(1) to make this test meaningful");
let ctx2 = render_scaled(text, 400, 120, 2.0, 2.0);
let (min2, max2) = ink_bbox_x(ctx2.pixmap()).expect("scale(2) render must paint some ink");
let gap2 = widest_interior_gap(ctx2.pixmap(), min2, max2);
let gap1 = gap1 as f64;
let gap2 = gap2 as f64;
assert!(
(gap2 - 2.0 * gap1).abs() < 6.0,
"inter-word gap must scale ~2x, not ~4x — got gap1={gap1} gap2={gap2} (ratio={:.2})",
gap2 / gap1
);
}
}
#[cfg(test)]
mod shaper_tests {
use uzor::render::TextMetrics;
use super::TinySkiaCpuRenderContext;
fn ctx() -> TinySkiaCpuRenderContext {
TinySkiaCpuRenderContext::new(1, 1, 1.0)
}
const FONT: &str = "16px sans-serif";
#[test]
fn hello_five_clusters() {
let glyphs = ctx().measure_text_glyphs("Hello", FONT);
assert_eq!(glyphs.len(), 5, "expected 5 clusters for 'Hello', got {:?}", glyphs);
for (i, g) in glyphs.iter().enumerate() {
let expected = ["H", "e", "l", "l", "o"][i];
assert_eq!(g.cluster, expected, "cluster[{i}] mismatch");
}
for w in glyphs.windows(2) {
assert!(
w[1].x_offset >= w[0].x_offset,
"x_offset not increasing: {:?} >= {:?}", w[0].x_offset, w[1].x_offset
);
}
}
#[test]
fn cafe_four_clusters() {
let glyphs = ctx().measure_text_glyphs("caf\u{00e9}", FONT);
assert_eq!(glyphs.len(), 4, "expected 4 clusters for 'café', got {:?}", glyphs);
assert_eq!(glyphs[3].cluster, "\u{00e9}", "last cluster should be é (U+00E9)");
}
#[test]
fn emoji_one_cluster() {
let text = "hi \u{1F44B}"; let glyphs = ctx().measure_text_glyphs(text, FONT);
assert_eq!(glyphs.len(), 4, "expected 4 clusters for 'hi 👋', got {:?}", glyphs);
assert_eq!(glyphs[3].cluster, "\u{1F44B}", "emoji should be one cluster");
}
#[test]
fn empty_string_empty_vec() {
let glyphs = ctx().measure_text_glyphs("", FONT);
assert!(glyphs.is_empty(), "empty string should return empty Vec");
}
}
#[cfg(test)]
mod image_blit_tests {
use tiny_skia::Color;
use uzor::render::{ImagePainter, Painter, RenderContext, ShapeHelpers};
use super::TinySkiaCpuRenderContext;
fn pixel(ctx: &TinySkiaCpuRenderContext, x: u32, y: u32) -> [u8; 4] {
let w = ctx.width();
let data = ctx.pixels();
let idx = (y * w + x) as usize * 4;
[data[idx], data[idx + 1], data[idx + 2], data[idx + 3]]
}
fn assert_close(actual: [u8; 4], expected: [u8; 4], tol: i32, what: &str) {
for i in 0..4 {
let d = (actual[i] as i32 - expected[i] as i32).abs();
assert!(
d <= tol,
"{what}: channel {i} mismatch — got {actual:?}, expected {expected:?} (tol {tol})"
);
}
}
#[test]
fn image_painter_accessor_reaches_the_tiny_skia_backend() {
let mut ctx = TinySkiaCpuRenderContext::new(4, 4, 1.0);
let dyn_ctx: &mut dyn RenderContext = &mut ctx;
assert!(dyn_ctx.image_painter().is_some());
}
#[test]
fn blits_2x2_source_into_8x8_target_reproducing_quadrant_colors() {
#[rustfmt::skip]
let rgba: [u8; 16] = [
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255, ];
let mut ctx = TinySkiaCpuRenderContext::new(8, 8, 1.0);
ctx.clear(Color::WHITE);
ctx.draw_image_rgba(&rgba, 2, 2, 0.0, 0.0, 8.0, 8.0);
assert_close(pixel(&ctx, 1, 1), [255, 0, 0, 255], 4, "top-left quadrant (texel 0,0 = red)");
assert_close(pixel(&ctx, 6, 1), [0, 255, 0, 255], 4, "top-right quadrant (texel 1,0 = green)");
assert_close(pixel(&ctx, 1, 6), [0, 0, 255, 255], 4, "bottom-left quadrant (texel 0,1 = blue)");
assert_close(pixel(&ctx, 6, 6), [255, 255, 0, 255], 4, "bottom-right quadrant (texel 1,1 = yellow)");
}
fn red_row_extent(ctx: &TinySkiaCpuRenderContext, y: u32) -> u32 {
let w = ctx.width();
(0..w)
.filter(|&x| {
let p = pixel(ctx, x, y);
p[0] > 200 && p[1] < 80 && p[2] < 80
})
.count() as u32
}
#[test]
fn scale_2x_doubles_the_painted_extent() {
#[rustfmt::skip]
let rgba: [u8; 16] = [
255, 0, 0, 255, 255, 0, 0, 255,
255, 0, 0, 255, 255, 0, 0, 255,
];
let mut ctx1 = TinySkiaCpuRenderContext::new(20, 20, 1.0);
ctx1.clear(Color::WHITE);
ctx1.draw_image_rgba(&rgba, 2, 2, 2.0, 2.0, 4.0, 4.0);
let width1 = red_row_extent(&ctx1, 5);
assert_eq!(width1, 4, "unscaled 4x4 rect at row 5 must read exactly 4px wide, got {width1}");
let mut ctx2 = TinySkiaCpuRenderContext::new(20, 20, 1.0);
ctx2.clear(Color::WHITE);
ctx2.scale(2.0, 2.0);
ctx2.draw_image_rgba(&rgba, 2, 2, 2.0, 2.0, 4.0, 4.0);
let width2 = red_row_extent(&ctx2, 5);
assert_eq!(
width2,
2 * width1,
"ctx.scale(2, 2) must double the painted extent, got {width1}px -> {width2}px"
);
}
#[test]
fn semi_transparent_source_composites_not_overwrites() {
let mut ctx = TinySkiaCpuRenderContext::new(4, 4, 1.0);
ctx.set_fill_color("#0000ff");
ctx.fill_rect(0.0, 0.0, 4.0, 4.0);
let rgba: [u8; 4] = [255, 0, 0, 128]; ctx.draw_image_rgba(&rgba, 1, 1, 0.0, 0.0, 4.0, 4.0);
let p = pixel(&ctx, 2, 2);
assert_ne!(p, [0, 0, 255, 255], "source alpha must not be dropped (pure background survived)");
assert_ne!(p, [255, 0, 0, 255], "source alpha must be respected (source must not fully overwrite)");
assert_close(p, [128, 0, 127, 255], 10, "semi-transparent red over opaque blue");
}
}