diff --git a/Cargo.lock b/Cargo.lock index 8fb3d4dd7..fd986012c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2114,6 +2114,8 @@ dependencies = [ name = "craft-raster-codecs" version = "0.1.0" dependencies = [ + "base64 0.23.1", + "image", "rayon", "thiserror 2.0.21", ] diff --git a/crates/raster-codecs/Cargo.toml b/crates/raster-codecs/Cargo.toml index 6fd3ab858..786e9d55c 100644 --- a/crates/raster-codecs/Cargo.toml +++ b/crates/raster-codecs/Cargo.toml @@ -11,7 +11,9 @@ repository.workspace = true workspace = true [dependencies] +base64 = "0.23" thiserror = { workspace = true } +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "gif", "tiff", "webp"] } # The DDS encoder compresses rows of blocks in parallel on native; wasm stays single-threaded. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/raster-codecs/src/common.rs b/crates/raster-codecs/src/common.rs new file mode 100644 index 000000000..357123e7c --- /dev/null +++ b/crates/raster-codecs/src/common.rs @@ -0,0 +1,130 @@ +//! Bounded decoding and PNG encoding for common photographic and web image formats. +//! +//! This facade is for applications that need a single RGBA pixel buffer regardless of whether +//! the source was PNG, JPEG, BMP, GIF, TIFF, or WebP. Format sniffing and codec dispatch stay +//! inside this crate. Callers choose their own stricter file and image limits. + +use std::io::Cursor; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use image::{ImageReader, Limits as ImageLimits, RgbaImage}; + +use crate::CodecError; + +pub use image::{Rgba as Pixel, RgbaImage as Pixels}; + +/// Limits applied before allocating the decoded image. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DecodeLimits { + pub max_file_bytes: usize, + pub max_width: u32, + pub max_height: u32, + pub max_pixels: u64, + pub max_alloc_bytes: u64, +} + +impl Default for DecodeLimits { + fn default() -> Self { + Self { max_file_bytes: 64 * 1024 * 1024, max_width: 10_000, max_height: 10_000, max_pixels: 16_000_000, max_alloc_bytes: 256 * 1024 * 1024 } + } +} + +/// Decode one frame into straight-alpha RGBA pixels. Animated images use the first frame. +pub fn decode_rgba(bytes: &[u8], limits: DecodeLimits) -> Result { + if bytes.len() > limits.max_file_bytes { + return Err(CodecError::LimitExceeded(format!("{} encoded bytes exceed max {}", bytes.len(), limits.max_file_bytes))); + } + if is_heif(bytes) { + return Err(CodecError::unsupported("image", "HEIC/HEIF is not supported by the enabled codecs")); + } + let reader = || -> Result>, CodecError> { + let mut reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(|e| CodecError::malformed("image", e))?; + if reader.format().is_none() { + return Err(CodecError::unsupported("image", "unrecognised image format")); + } + let mut image_limits = ImageLimits::default(); + image_limits.max_image_width = Some(limits.max_width); + image_limits.max_image_height = Some(limits.max_height); + image_limits.max_alloc = Some(limits.max_alloc_bytes); + reader.limits(image_limits); + Ok(reader) + }; + let (width, height) = reader()?.into_dimensions().map_err(|e| CodecError::malformed("image", e))?; + check_dimensions(width, height, limits)?; + let decoded = reader()?.decode().map_err(|e| CodecError::malformed("image", e))?; + check_dimensions(decoded.width(), decoded.height(), limits)?; + Ok(decoded.to_rgba8()) +} + +/// Encode an RGBA image as PNG. +pub fn encode_png(pixels: &RgbaImage) -> Result, CodecError> { + let mut out = Cursor::new(Vec::new()); + pixels.write_to(&mut out, image::ImageFormat::Png).map_err(|e| CodecError::encode("PNG", e))?; + Ok(out.into_inner()) +} + +/// Encode an RGBA image as standard Base64 containing PNG bytes, for a document field that stores +/// an inline PNG image. +pub fn encode_png_base64(pixels: &RgbaImage) -> Result { + encode_png(pixels).map(|bytes| STANDARD.encode(bytes)) +} + +/// Decode standard Base64 containing one PNG into straight-alpha RGBA pixels. +/// +/// The encoded length is checked before Base64 allocates the decoded buffer, and the decoded PNG +/// then passes through the same file, dimension, pixel-count, and allocation limits as any other +/// raster input. +pub fn decode_png_base64(text: &str, limits: DecodeLimits) -> Result { + let max_encoded_bytes = limits.max_file_bytes.div_ceil(3).saturating_mul(4); + if text.len() > max_encoded_bytes { + return Err(CodecError::LimitExceeded(format!("{} Base64 characters exceed max {max_encoded_bytes}", text.len()))); + } + let bytes = STANDARD.decode(text).map_err(|e| CodecError::malformed("PNG Base64", e))?; + if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return Err(CodecError::malformed("PNG Base64", "decoded data has no PNG signature")); + } + decode_rgba(&bytes, limits) +} + +/// Encode RGBA pixels as JPEG at the requested quality (1–100). +pub fn encode_jpeg(pixels: &RgbaImage, quality: u8) -> Result, CodecError> { + if !(1..=100).contains(&quality) { + return Err(CodecError::InvalidImage(format!("JPEG quality {quality} is outside 1–100"))); + } + let rgb = image::DynamicImage::ImageRgba8(pixels.clone()).to_rgb8(); + let mut out = Cursor::new(Vec::new()); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality).encode_image(&rgb).map_err(|e| CodecError::encode("JPEG", e))?; + Ok(out.into_inner()) +} + +/// Encode pixels according to a destination filename's extension. +pub fn encode_for_path(path: impl AsRef, pixels: &RgbaImage) -> Result, CodecError> { + let extension = path.as_ref().extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default(); + match extension.to_ascii_lowercase().as_str() { + "png" => encode_png(pixels), + "jpg" | "jpeg" => encode_jpeg(pixels, 90), + "bmp" => encode_dynamic(pixels, image::ImageFormat::Bmp), + "gif" => encode_dynamic(pixels, image::ImageFormat::Gif), + "tif" | "tiff" => encode_dynamic(pixels, image::ImageFormat::Tiff), + "webp" => encode_dynamic(pixels, image::ImageFormat::WebP), + other => Err(CodecError::unsupported("image", format!("cannot encode files with the .{other} extension"))), + } +} + +fn encode_dynamic(pixels: &RgbaImage, format: image::ImageFormat) -> Result, CodecError> { + let mut out = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(pixels.clone()).write_to(&mut out, format).map_err(|e| CodecError::encode("image", e))?; + Ok(out.into_inner()) +} + +fn is_heif(bytes: &[u8]) -> bool { + bytes.get(4..8) == Some(b"ftyp") && matches!(bytes.get(8..12), Some(b"heic" | b"heix" | b"hevc" | b"mif1" | b"msf1" | b"avif")) +} + +fn check_dimensions(width: u32, height: u32, limits: DecodeLimits) -> Result<(), CodecError> { + let pixels = u64::from(width).checked_mul(u64::from(height)).ok_or_else(|| CodecError::LimitExceeded("image dimensions overflow".into()))?; + if width == 0 || height == 0 || width > limits.max_width || height > limits.max_height || pixels > limits.max_pixels { + return Err(CodecError::LimitExceeded(format!("image dimensions {width}x{height} exceed configured limits"))); + } + Ok(()) +} diff --git a/crates/raster-codecs/src/lib.rs b/crates/raster-codecs/src/lib.rs index 880a32c6c..5b335a30c 100644 --- a/crates/raster-codecs/src/lib.rs +++ b/crates/raster-codecs/src/lib.rs @@ -18,6 +18,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +/// Common bounded decoding and PNG/JPEG encoding for common image formats. +pub mod common; pub mod dds; mod dds_bc; pub mod dicom;