//! Native implementation of the pinned `SkiaTextureCodec` behavior. use crate::Error; use crate::backend::{SKAlphaType, SKBitmap, SKColorType}; #[cfg(any(feature = "rust-skia", feature = "skia"))] use libremetaverse_imaging::{DEFAULT_MAX_ENCODED_BYTES, DEFAULT_MAX_PIXELS}; use libremetaverse_imaging::{ManagedImage, ManagedImageImageChannels}; use libremetaverse_types::compat::ReadWrite; #[cfg(any(feature = "rust-skia", feature = "skia"))] use std::io::Read; /// Optional Skia-backed decoder and format-neutral bitmap converter. #[derive(Clone, Copy, Debug, Default)] pub struct SkiaTextureCodec; /// Pure-Rust alternative to [`SkiaTextureCodec`] with the same image boundary. /// /// Enable the `rust-skia` feature to decode the common Skia codec formats /// without a native library, build script, system package lookup, or download. #[derive(Clone, Copy, Debug, Default)] pub struct RustSkiaTextureCodec; impl SkiaTextureCodec { /// Creates the stateless codec adapter. /// /// # Errors /// /// This fixed compatibility constructor cannot fail. pub const fn new() -> Result { Ok(Self) } /// Decodes a bounded compressed-image stream through Skia. /// /// # Errors /// /// With the `skia` feature, returns a typed argument, parse, or operation /// error for oversized or malformed input and unsupported native output. /// Without the feature it returns [`Error::InvalidOperation`]. pub fn decode(&self, mut stream: Box) -> Result { #[cfg(feature = "skia")] { let encoded = read_bounded(&mut stream)?; decode_with_skia(&encoded) } #[cfg(not(feature = "skia"))] { let _ = &mut stream; Err(Error::InvalidOperation) } } /// Converts an owned mapped bitmap using the exact reference channel rules. /// /// # Errors /// /// Returns a typed error for invalid dimensions, storage, or an unsupported /// fallback byte width. #[allow(clippy::needless_pass_by_value)] // fixed mapped C# signature pub fn to_managed_image(bitmap: SKBitmap) -> Result { bitmap_to_managed(&bitmap) } } impl RustSkiaTextureCodec { /// Creates the stateless pure-Rust codec adapter. /// /// # Errors /// /// This fixed compatibility constructor cannot fail. pub const fn new() -> Result { Ok(Self) } /// Decodes a bounded stream with Rust-only BMP, GIF, ICO, JPEG, PNG, /// WBMP, and WebP codecs. /// /// # Errors /// /// With `rust-skia`, returns [`Error::Argument`] for an input or decoded /// canvas above the documented limits and [`Error::InvalidOperation`] for /// malformed or unsupported images. Without the feature it returns /// [`Error::InvalidOperation`]. pub fn decode(&self, mut stream: Box) -> Result { #[cfg(feature = "rust-skia")] { let encoded = read_bounded(&mut stream)?; decode_with_rust_skia(&encoded) } #[cfg(not(feature = "rust-skia"))] { let _ = &mut stream; Err(Error::InvalidOperation) } } } #[cfg(any(feature = "rust-skia", feature = "skia"))] fn read_bounded(stream: &mut (dyn ReadWrite + Send)) -> Result, Error> { let mut encoded = Vec::new(); stream .take((DEFAULT_MAX_ENCODED_BYTES + 1) as u64) .read_to_end(&mut encoded) .map_err(|_| Error::InvalidOperation)?; if encoded.is_empty() { return Err(Error::InvalidOperation); } if encoded.len() > DEFAULT_MAX_ENCODED_BYTES { return Err(Error::Argument); } Ok(encoded) } // Keeping the format branches together makes comparison with the pinned C# // color switch auditable and prevents subtly different indexing paths. #[allow(clippy::too_many_lines)] fn bitmap_to_managed(bitmap: &SKBitmap) -> Result { let width = usize::try_from(bitmap.width()).map_err(|_| Error::Argument)?; let height = usize::try_from(bitmap.height()).map_err(|_| Error::Argument)?; let reference_step = bitmap.row_bytes() / width.max(1); let channels = match bitmap.color_type() { SKColorType::Rgb565 => ManagedImageImageChannels::COLOR, SKColorType::Bgra8888 | SKColorType::Rgba8888 | SKColorType::Rgba1010102 | SKColorType::Bgra1010102 => { ManagedImageImageChannels::COLOR | ManagedImageImageChannels::ALPHA } SKColorType::Gray8 => ManagedImageImageChannels::GRAY, SKColorType::Alpha8 => ManagedImageImageChannels::ALPHA, SKColorType::Other { .. } if reference_step == 1 => ManagedImageImageChannels::GRAY, SKColorType::Other { .. } if reference_step == 3 => ManagedImageImageChannels::COLOR, SKColorType::Other { .. } if reference_step == 4 => { ManagedImageImageChannels::COLOR | ManagedImageImageChannels::ALPHA } SKColorType::Other { .. } => return Err(Error::InvalidOperation), }; let mut image = ManagedImage::new(bitmap.width(), bitmap.height(), channels)?; for y in 0..height { let row = y.checked_mul(bitmap.row_bytes()).ok_or(Error::Argument)?; for x in 0..width { let pixel = y .checked_mul(width) .and_then(|offset| offset.checked_add(x)) .ok_or(Error::Argument)?; match bitmap.color_type() { SKColorType::Rgb565 => { let offset = checked_offset(row, x, 2, bitmap.pixels().len())?; let packed = u16::from_le_bytes([bitmap.pixels()[offset], bitmap.pixels()[offset + 1]]); image.red[pixel] = scale_bits(u32::from((packed >> 11) & 0x1f), 31); image.green[pixel] = scale_bits(u32::from((packed >> 5) & 0x3f), 63); image.blue[pixel] = scale_bits(u32::from(packed & 0x1f), 31); } SKColorType::Bgra8888 | SKColorType::Rgba8888 => { let offset = checked_offset(row, x, reference_step, bitmap.pixels().len())?; let source = &bitmap.pixels()[offset..offset + 4]; let (red, green, blue, alpha) = if bitmap.color_type() == SKColorType::Rgba8888 { (source[0], source[1], source[2], source[3]) } else { (source[2], source[1], source[0], source[3]) }; let (red, green, blue) = normalize_8bit_alpha(red, green, blue, alpha, bitmap.alpha_type()); image.red[pixel] = red; image.green[pixel] = green; image.blue[pixel] = blue; image.alpha[pixel] = alpha; } SKColorType::Rgba1010102 | SKColorType::Bgra1010102 => { let offset = checked_offset(row, x, 4, bitmap.pixels().len())?; let packed = u32::from_le_bytes( bitmap.pixels()[offset..offset + 4] .try_into() .map_err(|_| Error::Argument)?, ); let first = packed & 0x3ff; let green = (packed >> 10) & 0x3ff; let third = (packed >> 20) & 0x3ff; let alpha = (packed >> 30) & 0x3; let (red, blue) = if bitmap.color_type() == SKColorType::Rgba1010102 { (first, third) } else { (third, first) }; let (red, green, blue) = normalize_10bit_alpha(red, green, blue, alpha, bitmap.alpha_type()); image.red[pixel] = scale_bits(red, 1023); image.green[pixel] = scale_bits(green, 1023); image.blue[pixel] = scale_bits(blue, 1023); image.alpha[pixel] = u8::try_from(alpha * 85).map_err(|_| Error::Argument)?; } SKColorType::Gray8 => { let offset = checked_offset(row, x, reference_step, bitmap.pixels().len())?; image.red[pixel] = bitmap.pixels()[offset]; } SKColorType::Alpha8 => { let offset = checked_offset(row, x, reference_step, bitmap.pixels().len())?; image.alpha[pixel] = bitmap.pixels()[offset]; } SKColorType::Other { .. } => { let offset = checked_offset(row, x, reference_step, bitmap.pixels().len())?; match reference_step { 4 => { let alpha = bitmap.pixels()[offset + 3]; let (red, green, blue) = normalize_8bit_alpha( bitmap.pixels()[offset + 2], bitmap.pixels()[offset + 1], bitmap.pixels()[offset], alpha, bitmap.alpha_type(), ); image.red[pixel] = red; image.green[pixel] = green; image.blue[pixel] = blue; image.alpha[pixel] = alpha; } 3 => { image.blue[pixel] = bitmap.pixels()[offset]; image.green[pixel] = bitmap.pixels()[offset + 1]; image.red[pixel] = bitmap.pixels()[offset + 2]; } 1 => image.red[pixel] = bitmap.pixels()[offset], _ => return Err(Error::InvalidOperation), } } } } } Ok(image) } fn checked_offset(row: usize, x: usize, step: usize, length: usize) -> Result { let offset = x .checked_mul(step) .and_then(|offset| row.checked_add(offset)) .ok_or(Error::Argument)?; offset .checked_add(step) .filter(|end| *end <= length) .map(|_| offset) .ok_or(Error::Argument) } fn scale_bits(value: u32, maximum: u32) -> u8 { u8::try_from((value * 255 + maximum / 2) / maximum).unwrap_or(u8::MAX) } fn normalize_8bit_alpha( red: u8, green: u8, blue: u8, alpha: u8, alpha_type: SKAlphaType, ) -> (u8, u8, u8) { if alpha_type != SKAlphaType::Premul || alpha == u8::MAX { return (red, green, blue); } if alpha == 0 { return (0, 0, 0); } let normalize = |value: u8| { u8::try_from((u32::from(value) * 255 + u32::from(alpha) / 2) / u32::from(alpha)) .unwrap_or(u8::MAX) }; (normalize(red), normalize(green), normalize(blue)) } fn normalize_10bit_alpha( red: u32, green: u32, blue: u32, alpha: u32, alpha_type: SKAlphaType, ) -> (u32, u32, u32) { if alpha_type != SKAlphaType::Premul || alpha == 3 { return (red, green, blue); } if alpha == 0 { return (0, 0, 0); } let normalize = |value: u32| (value * 3 + alpha / 2) / alpha; (normalize(red), normalize(green), normalize(blue)) } #[cfg(feature = "rust-skia")] fn decode_with_rust_skia(encoded: &[u8]) -> Result { use skia_rs_codec::{ImageDecoder, ImageFormat}; let format = if wbmp_dimensions(encoded).is_ok() { ImageFormat::Wbmp } else { ImageFormat::from_magic(encoded) }; if format == ImageFormat::WebP { return decode_webp_with_rust(encoded); } if !matches!( format, ImageFormat::Bmp | ImageFormat::Gif | ImageFormat::Ico | ImageFormat::Jpeg | ImageFormat::Png | ImageFormat::Wbmp ) { return Err(Error::InvalidOperation); } let (width, height) = match format { ImageFormat::Gif => gif_dimensions(encoded)?, ImageFormat::Ico => ico_dimensions(encoded)?, ImageFormat::Wbmp => wbmp_dimensions(encoded)?, _ => skia_rs_codec::get_image_dimensions(encoded).map_err(|_| Error::InvalidOperation)?, }; validate_dimensions(width, height)?; let decoded = if format == ImageFormat::Wbmp { skia_rs_codec::WbmpDecoder::new().decode_bytes(encoded) } else { skia_rs_codec::decode_image(encoded) } .map_err(|_| Error::InvalidOperation)?; validate_dimensions(decoded.width(), decoded.height())?; if decoded.width() != width || decoded.height() != height { return Err(Error::InvalidOperation); } let width_usize = usize::try_from(width).map_err(|_| Error::Argument)?; if decoded.row_bytes() != width_usize.checked_mul(4).ok_or(Error::Argument)? { return Err(Error::InvalidOperation); } let source_pixels = decoded.peek_pixels().ok_or(Error::InvalidOperation)?; let grayscale = match format { ImageFormat::Jpeg => jpeg_is_grayscale(encoded)?, ImageFormat::Png => encoded.get(25) == Some(&0), ImageFormat::Wbmp => true, _ => false, }; let (row_bytes, color_type, alpha_type, pixels) = if grayscale { let pixel_count = width_usize .checked_mul(usize::try_from(height).map_err(|_| Error::Argument)?) .ok_or(Error::Argument)?; let mut gray = Vec::new(); gray.try_reserve_exact(pixel_count) .map_err(|_| Error::InvalidOperation)?; gray.extend(source_pixels.chunks_exact(4).map(|rgba| rgba[0])); if gray.len() != pixel_count { return Err(Error::InvalidOperation); } (width_usize, SKColorType::Gray8, SKAlphaType::Opaque, gray) } else { let mut rgba = Vec::new(); rgba.try_reserve_exact(source_pixels.len()) .map_err(|_| Error::InvalidOperation)?; rgba.extend_from_slice(source_pixels); ( decoded.row_bytes(), SKColorType::Rgba8888, if decoded.is_opaque() { SKAlphaType::Opaque } else { SKAlphaType::Unpremul }, rgba, ) }; let bitmap = SKBitmap::new(width, height, row_bytes, color_type, alpha_type, pixels)?; bitmap_to_managed(&bitmap) } #[cfg(feature = "rust-skia")] fn jpeg_is_grayscale(encoded: &[u8]) -> Result { let mut offset = 2; while offset + 1 < encoded.len() { if encoded[offset] != 0xff { offset += 1; continue; } let mut marker_offset = offset + 1; while encoded.get(marker_offset) == Some(&0xff) { marker_offset += 1; } let marker = *encoded.get(marker_offset).ok_or(Error::InvalidOperation)?; if marker == 0 || marker == 1 || (0xd0..=0xd9).contains(&marker) { offset = marker_offset + 1; continue; } let length_bytes = encoded .get(marker_offset + 1..marker_offset + 3) .ok_or(Error::InvalidOperation)?; let length = usize::from(u16::from_be_bytes( length_bytes .try_into() .map_err(|_| Error::InvalidOperation)?, )); if matches!( marker, 0xc0..=0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf ) { let components = *encoded .get(marker_offset + 8) .ok_or(Error::InvalidOperation)?; return match components { 1 => Ok(true), 3 => Ok(false), _ => Err(Error::InvalidOperation), }; } if length < 2 { return Err(Error::InvalidOperation); } offset = marker_offset .checked_add(1) .and_then(|offset| offset.checked_add(length)) .ok_or(Error::InvalidOperation)?; } Err(Error::InvalidOperation) } #[cfg(feature = "rust-skia")] fn gif_dimensions(encoded: &[u8]) -> Result<(i32, i32), Error> { if encoded.len() < 13 || !(encoded.starts_with(b"GIF87a") || encoded.starts_with(b"GIF89a")) { return Err(Error::InvalidOperation); } let canvas = ( i32::from(u16::from_le_bytes([encoded[6], encoded[7]])), i32::from(u16::from_le_bytes([encoded[8], encoded[9]])), ); validate_dimensions(canvas.0, canvas.1)?; let global_table_bytes = if encoded[10] & 0x80 == 0 { 0 } else { 3_usize .checked_mul(1_usize << (usize::from(encoded[10] & 0x07) + 1)) .ok_or(Error::InvalidOperation)? }; let mut offset = 13_usize .checked_add(global_table_bytes) .filter(|offset| *offset <= encoded.len()) .ok_or(Error::InvalidOperation)?; loop { let marker = *encoded.get(offset).ok_or(Error::InvalidOperation)?; offset += 1; match marker { 0x2c => { let end = offset.checked_add(9).ok_or(Error::InvalidOperation)?; let descriptor = encoded.get(offset..end).ok_or(Error::InvalidOperation)?; let frame_width = i32::from(u16::from_le_bytes([descriptor[4], descriptor[5]])); let frame_height = i32::from(u16::from_le_bytes([descriptor[6], descriptor[7]])); validate_dimensions(frame_width, frame_height)?; return Ok(canvas); } 0x21 => { offset = offset.checked_add(1).ok_or(Error::InvalidOperation)?; loop { let length = *encoded.get(offset).ok_or(Error::InvalidOperation)?; offset += 1; if length == 0 { break; } offset = offset .checked_add(usize::from(length)) .filter(|offset| *offset <= encoded.len()) .ok_or(Error::InvalidOperation)?; } } _ => return Err(Error::InvalidOperation), } } } #[cfg(feature = "rust-skia")] fn wbmp_dimensions(encoded: &[u8]) -> Result<(i32, i32), Error> { if encoded.get(..2) != Some(&[0, 0]) { return Err(Error::InvalidOperation); } let mut offset = 2; let width = i32::try_from(read_wbmp_integer(encoded, &mut offset)?).map_err(|_| Error::Argument)?; let height = i32::try_from(read_wbmp_integer(encoded, &mut offset)?).map_err(|_| Error::Argument)?; validate_dimensions(width, height)?; Ok((width, height)) } #[cfg(feature = "rust-skia")] fn read_wbmp_integer(encoded: &[u8], offset: &mut usize) -> Result { let mut value = 0_u32; for _ in 0..5 { let byte = *encoded.get(*offset).ok_or(Error::InvalidOperation)?; *offset += 1; value = value .checked_shl(7) .and_then(|value| value.checked_add(u32::from(byte & 0x7f))) .ok_or(Error::InvalidOperation)?; if byte & 0x80 == 0 { return Ok(value); } } Err(Error::InvalidOperation) } #[cfg(feature = "rust-skia")] fn ico_dimensions(encoded: &[u8]) -> Result<(i32, i32), Error> { if encoded.len() < 6 || encoded.get(..4) != Some(&[0, 0, 1, 0]) { return Err(Error::InvalidOperation); } let count = usize::from(u16::from_le_bytes([encoded[4], encoded[5]])); let mut best: Option<(u32, usize, usize)> = None; for index in 0..count { let offset = 6_usize .checked_add(index.checked_mul(16).ok_or(Error::InvalidOperation)?) .ok_or(Error::InvalidOperation)?; let end = offset.checked_add(16).ok_or(Error::InvalidOperation)?; let entry = encoded.get(offset..end).ok_or(Error::InvalidOperation)?; let width = if entry[0] == 0 { 256 } else { u32::from(entry[0]) }; let height = if entry[1] == 0 { 256 } else { u32::from(entry[1]) }; let area = width.checked_mul(height).ok_or(Error::Argument)?; let length = usize::try_from(u32::from_le_bytes( entry[8..12] .try_into() .map_err(|_| Error::InvalidOperation)?, )) .map_err(|_| Error::Argument)?; let image_offset = usize::try_from(u32::from_le_bytes( entry[12..16] .try_into() .map_err(|_| Error::InvalidOperation)?, )) .map_err(|_| Error::Argument)?; if best.is_none_or(|(best_area, _, _)| area > best_area) { best = Some((area, image_offset, length)); } } let (_, offset, length) = best.ok_or(Error::InvalidOperation)?; let end = offset.checked_add(length).ok_or(Error::Argument)?; let image = encoded .get(offset..end) .filter(|image| !image.is_empty()) .ok_or(Error::InvalidOperation)?; if image.starts_with(&[0x89, b'P', b'N', b'G']) { return skia_rs_codec::get_image_dimensions(image).map_err(|_| Error::InvalidOperation); } if image.len() < 40 { return Err(Error::InvalidOperation); } let header_size = u32::from_le_bytes( image[0..4] .try_into() .map_err(|_| Error::InvalidOperation)?, ); if header_size < 40 { return Err(Error::InvalidOperation); } let width = i32::from_le_bytes( image[4..8] .try_into() .map_err(|_| Error::InvalidOperation)?, ); let stored_height = i32::from_le_bytes( image[8..12] .try_into() .map_err(|_| Error::InvalidOperation)?, ); if stored_height <= 0 || stored_height % 2 != 0 { return Err(Error::InvalidOperation); } Ok((width, stored_height / 2)) } #[cfg(feature = "rust-skia")] fn validate_dimensions(width: i32, height: i32) -> Result<(), Error> { let width = usize::try_from(width).map_err(|_| Error::Argument)?; let height = usize::try_from(height).map_err(|_| Error::Argument)?; width .checked_mul(height) .filter(|pixels| *pixels > 0 && *pixels <= DEFAULT_MAX_PIXELS) .map(|_| ()) .ok_or(Error::Argument) } #[cfg(feature = "rust-skia")] fn decode_webp_with_rust(encoded: &[u8]) -> Result { use std::io::{BufReader, Cursor}; let reader = BufReader::new(Cursor::new(encoded)); let mut decoder = image_webp::WebPDecoder::new(reader).map_err(|_| Error::InvalidOperation)?; let (width, height) = decoder.dimensions(); let animated = decoder.is_animated(); let width_i32 = i32::try_from(width).map_err(|_| Error::Argument)?; let height_i32 = i32::try_from(height).map_err(|_| Error::Argument)?; validate_dimensions(width_i32, height_i32)?; decoder.set_memory_limit(DEFAULT_MAX_PIXELS * 16); let output_size = decoder.output_buffer_size().ok_or(Error::Argument)?; if output_size > DEFAULT_MAX_PIXELS * 4 { return Err(Error::Argument); } let mut packed_pixels = Vec::new(); packed_pixels .try_reserve_exact(output_size) .map_err(|_| Error::InvalidOperation)?; packed_pixels.resize(output_size, 0); decoder .read_image(&mut packed_pixels) .map_err(|_| Error::InvalidOperation)?; let has_alpha = decoder.has_alpha(); // image-webp 0.2 uses a divide-by-256 blend fast path for animated // canvases. Its fully opaque nonzero samples are consequently one below // Skia/libwebp's divide-by-255 result. Correct that bounded first-frame // canvas here; partially transparent samples already use the same rounding // as the compatibility backend. if animated { if has_alpha { for rgba in packed_pixels.chunks_exact_mut(4) { if rgba[3] == u8::MAX { for channel in &mut rgba[..3] { if *channel != 0 { *channel = channel.saturating_add(1); } } } } } else { for channel in &mut packed_pixels { if *channel != 0 { *channel = channel.saturating_add(1); } } } } let pixels = if has_alpha { packed_pixels } else { let pixel_count = usize::try_from(width) .ok() .and_then(|width| { usize::try_from(height) .ok() .and_then(|height| width.checked_mul(height)) }) .ok_or(Error::Argument)?; let rgba_size = pixel_count.checked_mul(4).ok_or(Error::Argument)?; let mut rgba = Vec::new(); rgba.try_reserve_exact(rgba_size) .map_err(|_| Error::InvalidOperation)?; for rgb in packed_pixels.chunks_exact(3) { rgba.extend_from_slice(&[rgb[0], rgb[1], rgb[2], u8::MAX]); } if rgba.len() != rgba_size { return Err(Error::InvalidOperation); } rgba }; let row_bytes = usize::try_from(width) .map_err(|_| Error::Argument)? .checked_mul(4) .ok_or(Error::Argument)?; let bitmap = SKBitmap::new( width_i32, height_i32, row_bytes, SKColorType::Rgba8888, if has_alpha { SKAlphaType::Unpremul } else { SKAlphaType::Opaque }, pixels, )?; bitmap_to_managed(&bitmap) } #[cfg(feature = "skia")] fn decode_with_skia(encoded: &[u8]) -> Result { use skia_safe::Data; use skia_safe::codec::{Codec, Options, Result as CodecResult}; let mut codec = Codec::from_data(Data::new_copy(encoded)).ok_or(Error::InvalidOperation)?; let source_info = codec.info(); let width = usize::try_from(source_info.width()).map_err(|_| Error::Argument)?; let height = usize::try_from(source_info.height()).map_err(|_| Error::Argument)?; width .checked_mul(height) .filter(|pixels| *pixels > 0 && *pixels <= DEFAULT_MAX_PIXELS) .ok_or(Error::Argument)?; // Match SKBitmap.Decode: decode into Skia's native alpha representation, // then normalize premultiplied channels at the abstraction boundary. let target_info = source_info.clone(); let row_bytes = target_info.min_row_bytes(); let length = target_info.compute_byte_size(row_bytes); if length == usize::MAX || length > DEFAULT_MAX_PIXELS * 16 { return Err(Error::Argument); } let mut pixels = Vec::new(); pixels .try_reserve_exact(length) .map_err(|_| Error::InvalidOperation)?; pixels.resize(length, 0); let result = codec.get_pixels_with_options( &target_info, &mut pixels, row_bytes, Some(&Options { max_decode_memory: Some(DEFAULT_MAX_PIXELS * 16), ..Options::default() }), ); if result != CodecResult::Success { return Err(Error::InvalidOperation); } let bitmap = SKBitmap::new( source_info.width(), source_info.height(), row_bytes, map_color_type(target_info.color_type(), target_info.bytes_per_pixel()), map_alpha_type(target_info.alpha_type()), pixels, )?; bitmap_to_managed(&bitmap) } #[cfg(feature = "skia")] const fn map_color_type(color: skia_safe::ColorType, bytes_per_pixel: usize) -> SKColorType { use skia_safe::ColorType; match color { ColorType::RGB565 => SKColorType::Rgb565, ColorType::BGRA8888 => SKColorType::Bgra8888, ColorType::RGBA8888 => SKColorType::Rgba8888, ColorType::RGBA1010102 => SKColorType::Rgba1010102, ColorType::BGRA1010102 => SKColorType::Bgra1010102, ColorType::Gray8 => SKColorType::Gray8, ColorType::Alpha8 => SKColorType::Alpha8, _ => SKColorType::Other { bytes_per_pixel }, } } #[cfg(feature = "skia")] const fn map_alpha_type(alpha: skia_safe::AlphaType) -> SKAlphaType { use skia_safe::AlphaType; match alpha { AlphaType::Opaque => SKAlphaType::Opaque, AlphaType::Premul => SKAlphaType::Premul, AlphaType::Unpremul => SKAlphaType::Unpremul, AlphaType::Unknown => SKAlphaType::Unknown, } } #[cfg(test)] mod tests { use super::*; #[test] fn mapped_bitmap_formats_preserve_reference_channel_rules() { let rgba = SKBitmap::new( 2, 1, 8, SKColorType::Rgba8888, SKAlphaType::Unpremul, vec![1, 2, 3, 4, 5, 6, 7, 8], ) .unwrap(); let image = SkiaTextureCodec::to_managed_image(rgba).unwrap(); assert_eq!(image.red, [1, 5]); assert_eq!(image.green, [2, 6]); assert_eq!(image.blue, [3, 7]); assert_eq!(image.alpha, [4, 8]); let rgb565 = SKBitmap::new( 3, 1, 6, SKColorType::Rgb565, SKAlphaType::Opaque, vec![0x00, 0xf8, 0xe0, 0x07, 0x1f, 0x00], ) .unwrap(); let image = SkiaTextureCodec::to_managed_image(rgb565).unwrap(); assert_eq!(image.red, [255, 0, 0]); assert_eq!(image.green, [0, 255, 0]); assert_eq!(image.blue, [0, 0, 255]); // The reference fallback derives its byte width from rowBytes / width, // even when the declared native format has a different packed width. let padded_unknown = SKBitmap::new( 2, 1, 8, SKColorType::Other { bytes_per_pixel: 3 }, SKAlphaType::Unpremul, vec![3, 2, 1, 4, 7, 6, 5, 8], ) .unwrap(); let image = SkiaTextureCodec::to_managed_image(padded_unknown).unwrap(); assert_eq!( image.channels, ManagedImageImageChannels::COLOR | ManagedImageImageChannels::ALPHA ); assert_eq!(image.red, [1, 5]); assert_eq!(image.green, [2, 6]); assert_eq!(image.blue, [3, 7]); assert_eq!(image.alpha, [4, 8]); } #[test] fn grayscale_alpha_ten_bit_and_premul_are_converted() { let gray = SKBitmap::new( 2, 1, 2, SKColorType::Gray8, SKAlphaType::Opaque, vec![11, 239], ) .unwrap(); let gray = SkiaTextureCodec::to_managed_image(gray).unwrap(); assert_eq!(gray.channels, ManagedImageImageChannels::GRAY); assert_eq!(gray.red, [11, 239]); let premul = SKBitmap::new( 1, 1, 4, SKColorType::Bgra8888, SKAlphaType::Premul, vec![25, 50, 100, 128], ) .unwrap(); let premul = SkiaTextureCodec::to_managed_image(premul).unwrap(); assert_eq!(premul.red, [199]); assert_eq!(premul.green, [100]); assert_eq!(premul.blue, [50]); assert_eq!(premul.alpha, [128]); let transparent_premul = SKBitmap::new( 1, 1, 4, SKColorType::Rgba8888, SKAlphaType::Premul, vec![100, 50, 25, 0], ) .unwrap(); let transparent_premul = SkiaTextureCodec::to_managed_image(transparent_premul).unwrap(); assert_eq!(transparent_premul.red, [0]); assert_eq!(transparent_premul.green, [0]); assert_eq!(transparent_premul.blue, [0]); assert_eq!(transparent_premul.alpha, [0]); let packed = 1023_u32 | (512 << 10) | (1 << 20) | (3 << 30); let ten_bit = SKBitmap::new( 1, 1, 4, SKColorType::Rgba1010102, SKAlphaType::Unpremul, packed.to_le_bytes().to_vec(), ) .unwrap(); let ten_bit = SkiaTextureCodec::to_managed_image(ten_bit).unwrap(); assert_eq!(ten_bit.red, [255]); assert_eq!(ten_bit.green, [128]); assert_eq!(ten_bit.blue, [0]); assert_eq!(ten_bit.alpha, [255]); let packed = 1023_u32 | (512 << 10) | (1 << 20) | (2 << 30); let ten_bit = SKBitmap::new( 1, 1, 4, SKColorType::Bgra1010102, SKAlphaType::Unpremul, packed.to_le_bytes().to_vec(), ) .unwrap(); let ten_bit = SkiaTextureCodec::to_managed_image(ten_bit).unwrap(); assert_eq!(ten_bit.red, [0]); assert_eq!(ten_bit.green, [128]); assert_eq!(ten_bit.blue, [255]); assert_eq!(ten_bit.alpha, [170]); } #[test] fn alpha_and_reference_fallback_layouts_are_converted_or_rejected() { let alpha = SKBitmap::new( 2, 1, 2, SKColorType::Alpha8, SKAlphaType::Unpremul, vec![17, 241], ) .unwrap(); let alpha = SkiaTextureCodec::to_managed_image(alpha).unwrap(); assert_eq!(alpha.channels, ManagedImageImageChannels::ALPHA); assert_eq!(alpha.alpha, [17, 241]); let bgr = SKBitmap::new( 2, 1, 6, SKColorType::Other { bytes_per_pixel: 3 }, SKAlphaType::Opaque, vec![3, 2, 1, 6, 5, 4], ) .unwrap(); let bgr = SkiaTextureCodec::to_managed_image(bgr).unwrap(); assert_eq!(bgr.channels, ManagedImageImageChannels::COLOR); assert_eq!(bgr.red, [1, 4]); assert_eq!(bgr.green, [2, 5]); assert_eq!(bgr.blue, [3, 6]); let gray = SKBitmap::new( 2, 1, 2, SKColorType::Other { bytes_per_pixel: 1 }, SKAlphaType::Opaque, vec![23, 229], ) .unwrap(); let gray = SkiaTextureCodec::to_managed_image(gray).unwrap(); assert_eq!(gray.channels, ManagedImageImageChannels::GRAY); assert_eq!(gray.red, [23, 229]); let unsupported = SKBitmap::new( 1, 1, 2, SKColorType::Other { bytes_per_pixel: 2 }, SKAlphaType::Opaque, vec![0, 0], ) .unwrap(); assert_eq!( SkiaTextureCodec::to_managed_image(unsupported), Err(Error::InvalidOperation) ); } #[test] fn bitmap_bounds_and_feature_disabled_decode_fail_typed() { assert_eq!( SKBitmap::new(0, 1, 0, SKColorType::Gray8, SKAlphaType::Opaque, Vec::new(),), Err(Error::Argument) ); assert_eq!( SKBitmap::new( 2, 2, 4, SKColorType::Rgba8888, SKAlphaType::Unpremul, vec![0; 16], ), Err(Error::Argument) ); #[cfg(not(feature = "skia"))] assert_eq!( SkiaTextureCodec.decode(Box::new(std::io::Cursor::new(Vec::new()))), Err(Error::InvalidOperation) ); #[cfg(not(feature = "rust-skia"))] assert_eq!( RustSkiaTextureCodec.decode(Box::new(std::io::Cursor::new(Vec::new()))), Err(Error::InvalidOperation) ); } #[cfg(any(feature = "rust-skia", feature = "skia"))] fn hex_fixture(source: &str) -> Vec { let digits: Vec<_> = source.bytes().filter(u8::is_ascii_hexdigit).collect(); assert_eq!(digits.len() % 2, 0, "fixture contains a partial byte"); digits .chunks_exact(2) .map(|pair| { let high = (pair[0] as char).to_digit(16).expect("hex digit"); let low = (pair[1] as char).to_digit(16).expect("hex digit"); u8::try_from(high * 16 + low).expect("hex byte") }) .collect() } #[cfg(feature = "rust-skia")] fn rust_decode(encoded: Vec) -> Result { RustSkiaTextureCodec.decode(Box::new(std::io::Cursor::new(encoded))) } #[cfg(any(feature = "rust-skia", feature = "skia"))] fn checked_format_matrix() -> [(&'static str, &'static str, (i32, i32)); 19] { [ ( "BMP 16-bit RGB555", include_str!("../tests/fixtures/rgb555.bmp.hex"), (2, 1), ), ( "BMP 24-bit bottom-up", include_str!("../tests/fixtures/bottom-up.bmp.hex"), (8, 8), ), ( "BMP 32-bit BGRX", include_str!("../tests/fixtures/bgrx32.bmp.hex"), (2, 1), ), ( "GIF palette and frame offset", include_str!("../tests/fixtures/palette-offset.gif.hex"), (4, 2), ), ( "ICO largest entry", include_str!("../tests/fixtures/multi.ico.hex"), (2, 2), ), ( "JPEG baseline RGB", include_str!("../tests/fixtures/baseline.jpg.hex"), (8, 8), ), ( "JPEG progressive grayscale", include_str!("../tests/fixtures/progressive.jpg.hex"), (128, 128), ), ( "PNG grayscale", include_str!("../tests/fixtures/gray.png.hex"), (32, 32), ), ( "PNG grayscale alpha", include_str!("../tests/fixtures/gray-alpha.png.hex"), (32, 32), ), ( "PNG RGB", include_str!("../tests/fixtures/rgb.png.hex"), (32, 32), ), ( "PNG RGBA", include_str!("../tests/fixtures/rgba.png.hex"), (32, 32), ), ( "PNG straight alpha", include_str!("../tests/fixtures/straight-alpha.png.hex"), (1, 1), ), ( "PNG interlaced palette", include_str!("../tests/fixtures/interlaced-palette.png.hex"), (32, 32), ), ( "WBMP multibyte width", include_str!("../tests/fixtures/multibyte.wbmp.hex"), (130, 1), ), ( "WBMP minimum-length image", include_str!("../tests/fixtures/small.wbmp.hex"), (2, 2), ), ( "WebP lossless RGB", include_str!("../tests/fixtures/rgb-lossless.webp.hex"), (2, 2), ), ( "WebP lossless alpha", include_str!("../tests/fixtures/alpha.webp.hex"), (1, 1), ), ( "WebP lossy", include_str!("../tests/fixtures/lossy-red.webp.hex"), (8, 8), ), ( "WebP animated first frame", include_str!("../tests/fixtures/animated.webp.hex"), (11, 29), ), ] } #[cfg(any(feature = "rust-skia", feature = "skia"))] fn assert_checked_format_matrix(codec: &dyn libremetaverse_imaging::ITextureCodec) { for (name, fixture, dimensions) in checked_format_matrix() { let image = codec .decode(Box::new(std::io::Cursor::new(hex_fixture(fixture)))) .unwrap_or_else(|error| panic!("{name} failed to decode: {error:?}")); assert_eq!((image.width, image.height), dimensions, "{name}"); assert_eq!( image.channels, if matches!( name, "JPEG progressive grayscale" | "PNG grayscale" | "WBMP multibyte width" | "WBMP minimum-length image" ) { ManagedImageImageChannels::GRAY } else { ManagedImageImageChannels::COLOR | ManagedImageImageChannels::ALPHA }, "{name}" ); } } #[cfg(feature = "rust-skia")] #[test] fn rust_feature_decodes_the_checked_format_matrix() { assert_checked_format_matrix(&RustSkiaTextureCodec); } #[cfg(feature = "skia")] #[test] fn native_feature_decodes_the_checked_format_matrix() { assert_checked_format_matrix(&SkiaTextureCodec); } #[cfg(all(feature = "rust-skia", feature = "skia"))] #[test] fn native_and_rust_backends_match_the_shared_contract_matrix() { fn assert_plane(name: &str, plane: &str, native: &[u8], rust: &[u8], tolerance: u8) { assert_eq!(native.len(), rust.len(), "{name} {plane} length"); for (index, (&native, &rust)) in native.iter().zip(rust).enumerate() { assert!( native.abs_diff(rust) <= tolerance, "{name} {plane}[{index}]: native={native}, rust={rust}, tolerance={tolerance}" ); } } for (name, fixture, _) in checked_format_matrix() { let bytes = hex_fixture(fixture); let native = SkiaTextureCodec .decode(Box::new(std::io::Cursor::new(bytes.clone()))) .unwrap_or_else(|error| panic!("native {name}: {error:?}")); let rust = RustSkiaTextureCodec .decode(Box::new(std::io::Cursor::new(bytes))) .unwrap_or_else(|error| panic!("Rust {name}: {error:?}")); assert_eq!(native.width, rust.width, "{name} width"); assert_eq!(native.height, rust.height, "{name} height"); assert_eq!(native.channels, rust.channels, "{name} channels"); let tolerance = if name.starts_with("JPEG") { 8 } else if name == "WebP lossy" { 10 } else { 0 }; assert_plane(name, "red", &native.red, &rust.red, tolerance); assert_plane(name, "green", &native.green, &rust.green, tolerance); assert_plane(name, "blue", &native.blue, &rust.blue, tolerance); assert_plane(name, "alpha", &native.alpha, &rust.alpha, tolerance); } } #[cfg(feature = "rust-skia")] #[test] fn rust_feature_preserves_canvas_orientation_selection_and_alpha() { for fixture in [ include_str!("../tests/fixtures/two-by-two-bottom-up.bmp.hex"), include_str!("../tests/fixtures/two-by-two-top-down.bmp.hex"), ] { let bitmap = rust_decode(hex_fixture(fixture)).expect("oriented BMP"); assert_eq!(bitmap.red, [255, 0, 0, 255]); assert_eq!(bitmap.green, [0, 255, 0, 255]); assert_eq!(bitmap.blue, [0, 0, 255, 255]); } let gif = rust_decode(hex_fixture(include_str!( "../tests/fixtures/palette-offset.gif.hex" ))) .expect("GIF logical canvas"); assert_eq!((gif.width, gif.height), (4, 2)); let icon = rust_decode(hex_fixture(include_str!("../tests/fixtures/multi.ico.hex"))) .expect("largest ICO entry"); assert_eq!(icon.red, [255, 0, 0, 255]); assert_eq!(icon.green, [0, 255, 0, 255]); assert_eq!(icon.blue, [0, 0, 255, 255]); let alpha = rust_decode(hex_fixture(include_str!( "../tests/fixtures/alpha.webp.hex" ))) .expect("alpha WebP"); assert_eq!( (alpha.red[0], alpha.green[0], alpha.blue[0]), (255, 255, 255) ); assert!((127..=128).contains(&alpha.alpha[0])); let lossy = rust_decode(hex_fixture(include_str!( "../tests/fixtures/lossy-red.webp.hex" ))) .expect("lossy WebP"); assert!(lossy.red.iter().all(|red| *red >= 245)); assert!(lossy.green.iter().all(|green| *green <= 10)); assert!(lossy.blue.iter().all(|blue| *blue <= 10)); assert!(lossy.alpha.iter().all(|alpha| *alpha == u8::MAX)); } #[cfg(feature = "rust-skia")] #[test] fn rust_feature_rejects_invalid_truncated_and_oversized_input_without_panicking() { for malformed in [Vec::new(), b"not an image".to_vec(), b"GIF89a".to_vec()] { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| rust_decode(malformed))); assert!(result.is_ok(), "malformed input panicked"); assert_eq!(result.expect("checked above"), Err(Error::InvalidOperation)); } let mut pixel_bomb = hex_fixture(include_str!("../tests/fixtures/interlaced-palette.png.hex")); pixel_bomb[16..20].copy_from_slice(&4097_u32.to_be_bytes()); pixel_bomb[20..24].copy_from_slice(&4097_u32.to_be_bytes()); assert_eq!(rust_decode(pixel_bomb), Err(Error::Argument)); let mut gif_frame_bomb = hex_fixture(include_str!("../tests/fixtures/palette-offset.gif.hex")); let descriptor = gif_frame_bomb[109..] .iter() .position(|byte| *byte == 0x2c) .map(|offset| offset + 109) .expect("first GIF image descriptor"); gif_frame_bomb[descriptor + 5..descriptor + 7].copy_from_slice(&u16::MAX.to_le_bytes()); gif_frame_bomb[descriptor + 7..descriptor + 9].copy_from_slice(&u16::MAX.to_le_bytes()); assert_eq!(rust_decode(gif_frame_bomb), Err(Error::Argument)); let mut malformed_gif_palette = hex_fixture(include_str!("../tests/fixtures/palette-offset.gif.hex")); // Advertise a 256-entry global palette while retaining the fixture's // much smaller checked table. Preflight must reject the missing table // before the decoder can allocate or index palette storage. malformed_gif_palette[10] = (malformed_gif_palette[10] & 0xf8) | 0x07; assert_eq!( rust_decode(malformed_gif_palette), Err(Error::InvalidOperation) ); let mut icon_embedded_bomb = hex_fixture(include_str!("../tests/fixtures/multi.ico.hex")); icon_embedded_bomb[86..90].copy_from_slice(&4097_i32.to_le_bytes()); icon_embedded_bomb[90..94].copy_from_slice(&8194_i32.to_le_bytes()); assert_eq!(rust_decode(icon_embedded_bomb), Err(Error::Argument)); let over_limit = vec![0_u8; DEFAULT_MAX_ENCODED_BYTES + 1]; assert_eq!(rust_decode(over_limit), Err(Error::Argument)); for fixture in [ include_str!("../tests/fixtures/bottom-up.bmp.hex"), include_str!("../tests/fixtures/palette-offset.gif.hex"), include_str!("../tests/fixtures/multi.ico.hex"), include_str!("../tests/fixtures/interlaced-palette.png.hex"), include_str!("../tests/fixtures/alpha.webp.hex"), ] { let mut bytes = hex_fixture(fixture); bytes.truncate(bytes.len() / 2); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| rust_decode(bytes))); assert!(result.is_ok(), "truncated input panicked"); assert!(result.expect("checked above").is_err()); } } #[cfg(feature = "rust-skia")] struct DropStream { cursor: std::io::Cursor>, drops: std::sync::Arc, } #[cfg(feature = "rust-skia")] impl std::io::Read for DropStream { fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { std::io::Read::read(&mut self.cursor, buffer) } } #[cfg(feature = "rust-skia")] impl std::io::Write for DropStream { fn write(&mut self, buffer: &[u8]) -> std::io::Result { std::io::Write::write(&mut self.cursor, buffer) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } #[cfg(feature = "rust-skia")] impl std::io::Seek for DropStream { fn seek(&mut self, position: std::io::SeekFrom) -> std::io::Result { std::io::Seek::seek(&mut self.cursor, position) } } #[cfg(feature = "rust-skia")] impl Drop for DropStream { fn drop(&mut self) { self.drops.fetch_add(1, std::sync::atomic::Ordering::SeqCst); } } #[cfg(feature = "rust-skia")] #[test] fn rust_feature_is_thread_safe_across_repeated_decode_and_drop() { use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; fn assert_texture_codec() {} assert_texture_codec::(); let codec = Arc::new(RustSkiaTextureCodec); let png = Arc::new(hex_fixture(include_str!( "../tests/fixtures/interlaced-palette.png.hex" ))); let mut workers = Vec::new(); for _ in 0..8 { let codec = Arc::clone(&codec); let png = Arc::clone(&png); workers.push(std::thread::spawn(move || { for _ in 0..16 { let image = codec .decode(Box::new(std::io::Cursor::new((*png).clone()))) .expect("concurrent PNG decode"); assert_eq!((image.width, image.height), (32, 32)); let repeated = codec .decode(Box::new(std::io::Cursor::new((*png).clone()))) .expect("repeated concurrent PNG decode"); assert_eq!(image, repeated); } })); } for worker in workers { worker.join().expect("decoder worker did not panic"); } let drops = Arc::new(AtomicUsize::new(0)); let stream = DropStream { cursor: std::io::Cursor::new((*png).clone()), drops: Arc::clone(&drops), }; codec.decode(Box::new(stream)).expect("drop-probe decode"); assert_eq!(drops.load(Ordering::SeqCst), 1); } #[cfg(feature = "skia")] #[test] fn feature_decodes_known_png_with_straight_alpha() { const PNG: &[u8] = &[ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]; let image = SkiaTextureCodec .decode(Box::new(std::io::Cursor::new(PNG.to_vec()))) .expect("decode PNG"); assert_eq!((image.width, image.height), (1, 1)); assert_eq!(image.red, [255]); assert_eq!(image.green, [0]); assert_eq!(image.blue, [0]); assert_eq!(image.alpha, [255]); assert_eq!( SkiaTextureCodec.decode(Box::new(std::io::Cursor::new(Vec::new()))), Err(Error::InvalidOperation) ); assert_eq!( SkiaTextureCodec.decode(Box::new(std::io::Cursor::new(b"not an image".to_vec()))), Err(Error::InvalidOperation) ); } #[cfg(feature = "skia")] #[test] fn feature_decodes_webp_from_the_cross_platform_cache_configuration() { const WEBP: &[u8] = &[ 0x52, 0x49, 0x46, 0x46, 0x1c, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x4c, 0x0f, 0x00, 0x00, 0x00, 0x2f, 0x01, 0x40, 0x00, 0x00, 0x07, 0x10, 0xf5, 0x8f, 0xfe, 0x07, 0x22, 0xa2, 0xff, 0x01, 0x00, ]; let image = SkiaTextureCodec .decode(Box::new(std::io::Cursor::new(WEBP.to_vec()))) .expect("decode WebP"); assert_eq!((image.width, image.height), (2, 2)); assert_eq!(image.red, [254; 4]); assert_eq!(image.green, [0; 4]); assert_eq!(image.blue, [0; 4]); assert_eq!(image.alpha, [255; 4]); } }