use crate::error::{RenderError, RenderResult};
#[derive(Debug, Clone)]
pub struct TextureData {
pub width: u32,
pub height: u32,
pub data: Vec<u8>,
pub format: ImageFormat,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageFormat {
Png,
Jpeg,
WebP,
Unknown,
}
impl ImageFormat {
#[must_use]
pub fn from_extension(ext: &str) -> Self {
match ext.to_lowercase().as_str() {
"png" => Self::Png,
"jpg" | "jpeg" => Self::Jpeg,
"webp" => Self::WebP,
_ => Self::Unknown,
}
}
#[must_use]
pub fn from_mime(mime: &str) -> Self {
match mime.to_lowercase().as_str() {
"image/png" => Self::Png,
"image/jpeg" | "image/jpg" => Self::Jpeg,
"image/webp" => Self::WebP,
_ => Self::Unknown,
}
}
#[must_use]
pub fn from_magic_bytes(data: &[u8]) -> Self {
if data.len() < 4 {
return Self::Unknown;
}
if data.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
return Self::Png;
}
if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
return Self::Jpeg;
}
if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
return Self::WebP;
}
Self::Unknown
}
}
pub fn load_image_from_bytes(data: &[u8]) -> RenderResult<TextureData> {
let format = ImageFormat::from_magic_bytes(data);
let img = image::load_from_memory(data)
.map_err(|e| RenderError::Resource(format!("Failed to decode image: {e}")))?;
let rgba = img.to_rgba8();
let (width, height) = rgba.dimensions();
Ok(TextureData {
width,
height,
data: rgba.into_raw(),
format,
})
}
pub fn load_image_from_data_uri(uri: &str) -> RenderResult<TextureData> {
if !uri.starts_with("data:") {
return Err(RenderError::Resource("Not a data URI".to_string()));
}
let uri_data = &uri[5..];
let comma_pos = uri_data
.find(',')
.ok_or_else(|| RenderError::Resource("Invalid data URI: missing comma".to_string()))?;
let metadata = &uri_data[..comma_pos];
let encoded_data = &uri_data[comma_pos + 1..];
let is_base64 = metadata.contains(";base64");
let bytes = if is_base64 {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(encoded_data)
.map_err(|e| RenderError::Resource(format!("Failed to decode base64: {e}")))?
} else {
urlencoding_decode(encoded_data)?
};
load_image_from_bytes(&bytes)
}
fn urlencoding_decode(input: &str) -> RenderResult<Vec<u8>> {
let mut result = Vec::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' {
let hex: String = chars.by_ref().take(2).collect();
if hex.len() == 2 {
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
result.push(byte);
continue;
}
}
return Err(RenderError::Resource("Invalid URL encoding".to_string()));
}
result.push(c as u8);
}
Ok(result)
}
#[must_use]
pub fn resize_to_fit(
texture: &TextureData,
max_width: u32,
max_height: u32,
) -> Option<TextureData> {
if texture.width <= max_width && texture.height <= max_height {
return None;
}
let scale_x = f64::from(max_width) / f64::from(texture.width);
let scale_y = f64::from(max_height) / f64::from(texture.height);
let scale = scale_x.min(scale_y);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let new_width = (f64::from(texture.width) * scale) as u32;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let new_height = (f64::from(texture.height) * scale) as u32;
let img = image::RgbaImage::from_raw(texture.width, texture.height, texture.data.clone())?;
let resized = image::imageops::resize(
&img,
new_width,
new_height,
image::imageops::FilterType::Lanczos3,
);
Some(TextureData {
width: new_width,
height: new_height,
data: resized.into_raw(),
format: texture.format,
})
}
#[must_use]
pub fn create_solid_color(width: u32, height: u32, r: u8, g: u8, b: u8, a: u8) -> TextureData {
let pixel_count = (width * height) as usize;
let mut data = Vec::with_capacity(pixel_count * 4);
for _ in 0..pixel_count {
data.push(r);
data.push(g);
data.push(b);
data.push(a);
}
TextureData {
width,
height,
data,
format: ImageFormat::Unknown,
}
}
#[must_use]
pub fn create_placeholder(width: u32, height: u32) -> TextureData {
let mut data = Vec::with_capacity((width * height * 4) as usize);
let cell_size = 16u32;
for y in 0..height {
for x in 0..width {
let cell_x = x / cell_size;
let cell_y = y / cell_size;
let is_light = (cell_x + cell_y).is_multiple_of(2);
if is_light {
data.extend_from_slice(&[200, 200, 200, 255]); } else {
data.extend_from_slice(&[150, 150, 150, 255]); }
}
}
TextureData {
width,
height,
data,
format: ImageFormat::Unknown,
}
}
pub fn generate_thumbnail(texture: &TextureData, max_size: u32) -> RenderResult<TextureData> {
let img = image::RgbaImage::from_raw(texture.width, texture.height, texture.data.clone())
.ok_or_else(|| RenderError::Resource("Invalid texture data".to_string()))?;
let thumbnail = image::imageops::thumbnail(&img, max_size, max_size);
let (w, h) = thumbnail.dimensions();
Ok(TextureData {
width: w,
height: h,
data: thumbnail.into_raw(),
format: texture.format,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_detection_from_extension() {
assert_eq!(ImageFormat::from_extension("png"), ImageFormat::Png);
assert_eq!(ImageFormat::from_extension("PNG"), ImageFormat::Png);
assert_eq!(ImageFormat::from_extension("jpg"), ImageFormat::Jpeg);
assert_eq!(ImageFormat::from_extension("jpeg"), ImageFormat::Jpeg);
assert_eq!(ImageFormat::from_extension("webp"), ImageFormat::WebP);
assert_eq!(ImageFormat::from_extension("gif"), ImageFormat::Unknown);
}
#[test]
fn test_format_detection_from_mime() {
assert_eq!(ImageFormat::from_mime("image/png"), ImageFormat::Png);
assert_eq!(ImageFormat::from_mime("image/jpeg"), ImageFormat::Jpeg);
assert_eq!(ImageFormat::from_mime("image/webp"), ImageFormat::WebP);
}
#[test]
fn test_format_detection_from_magic_bytes() {
assert_eq!(
ImageFormat::from_magic_bytes(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
ImageFormat::Png
);
assert_eq!(
ImageFormat::from_magic_bytes(&[0xFF, 0xD8, 0xFF, 0xE0]),
ImageFormat::Jpeg
);
assert_eq!(
ImageFormat::from_magic_bytes(b"RIFF\x00\x00\x00\x00WEBP"),
ImageFormat::WebP
);
}
#[test]
fn test_create_solid_color() {
let texture = create_solid_color(2, 2, 255, 0, 0, 255);
assert_eq!(texture.width, 2);
assert_eq!(texture.height, 2);
assert_eq!(texture.data.len(), 16);
assert_eq!(&texture.data[0..4], &[255, 0, 0, 255]);
}
#[test]
fn test_create_placeholder() {
let texture = create_placeholder(32, 32);
assert_eq!(texture.width, 32);
assert_eq!(texture.height, 32);
assert_eq!(texture.data.len(), 32 * 32 * 4);
}
#[test]
fn test_data_uri_parsing() {
let png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
let data_uri = format!("data:image/png;base64,{png_base64}");
let result = load_image_from_data_uri(&data_uri);
assert!(result.is_ok(), "Should parse valid data URI");
let texture = result.unwrap();
assert_eq!(texture.width, 1);
assert_eq!(texture.height, 1);
assert_eq!(texture.format, ImageFormat::Png);
}
#[test]
fn test_invalid_data_uri() {
let result = load_image_from_data_uri("not a data uri");
assert!(result.is_err());
let result = load_image_from_data_uri("data:image/png");
assert!(result.is_err()); }
}