From 8c4113936910c0441acea94068fa8a5b6751a6ab Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 00:00:37 -0500 Subject: [PATCH 1/6] Add shared raster codec facade and versioned JSON headers --- Cargo.lock | 9 +++ Cargo.toml | 1 + crates/raster-codecs/Cargo.toml | 1 + crates/raster-codecs/src/common.rs | 92 ++++++++++++++++++++++++++++++ crates/versioned-json/Cargo.toml | 15 +++++ crates/versioned-json/src/lib.rs | 63 ++++++++++++++++++++ 6 files changed, 181 insertions(+) create mode 100644 crates/raster-codecs/src/common.rs create mode 100644 crates/versioned-json/Cargo.toml create mode 100644 crates/versioned-json/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index cf0e0bd47..81cc73b2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2106,6 +2106,7 @@ dependencies = [ name = "craft-raster-codecs" version = "0.1.0" dependencies = [ + "image", "rayon", "thiserror 2.0.21", ] @@ -2343,6 +2344,14 @@ dependencies = [ "kurbo 0.13.1", ] +[[package]] +name = "craft-versioned-json" +version = "0.1.0" +dependencies = [ + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-video" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 629bab64c..5519f216b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ craft-aep = { path = "crates/aep" } craft-affinity = { path = "crates/affinity" } craft-ai-core = { path = "crates/ai-core" } craft-pdf = { path = "crates/pdf", version = "=0.1.0" } +craft-versioned-json = { path = "crates/versioned-json" } craft-pdn = { path = "crates/pdn" } craft-plate-test = { path = "crates/plate-test" } krilla = { version = "=0.8.2", default-features = false, features = ["raster-images"] } diff --git a/crates/raster-codecs/Cargo.toml b/crates/raster-codecs/Cargo.toml index 6fd3ab858..cadf9cbd0 100644 --- a/crates/raster-codecs/Cargo.toml +++ b/crates/raster-codecs/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] 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..b12fb8b0a --- /dev/null +++ b/crates/raster-codecs/src/common.rs @@ -0,0 +1,92 @@ +//! 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 image::{ImageReader, Limits as ImageLimits, RgbaImage}; + +use crate::CodecError; + +pub use image::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 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()) +} + +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/versioned-json/Cargo.toml b/crates/versioned-json/Cargo.toml new file mode 100644 index 000000000..1b1bbd354 --- /dev/null +++ b/crates/versioned-json/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "craft-versioned-json" +description = "Reusable named and versioned JSON document envelopes" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +serde_json = { workspace = true } +thiserror = { workspace = true } + +[lints] +workspace = true diff --git a/crates/versioned-json/src/lib.rs b/crates/versioned-json/src/lib.rs new file mode 100644 index 000000000..f100920fb --- /dev/null +++ b/crates/versioned-json/src/lib.rs @@ -0,0 +1,63 @@ +//! Helpers for named, versioned JSON documents whose payload is an object. +//! +//! The library checks and writes the common envelope. Each application owns its payload schema, +//! migrations and validation. + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use serde_json::{Map, Value}; + +/// A recognized document header. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Header { + pub name: String, + pub version: u64, +} + +/// Header errors, kept independent of an application's schema and error type. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum Error { + #[error("missing or invalid `{0}` header")] + Missing(String), + #[error("expected format `{expected}`, found `{found}`")] + WrongName { expected: String, found: String }, + #[error("format version {found} is unsupported; this build reads {expected}")] + UnsupportedVersion { found: u64, expected: u64 }, +} + +/// Checks `root[header_key]` for the expected name and exact supported version. +pub fn read_header(root: &Value, header_key: &str, expected_name: &str, expected_version: u64) -> Result { + let header = root.get(header_key).ok_or_else(|| Error::Missing(header_key.to_owned()))?; + let name = header.get("name").and_then(Value::as_str).ok_or_else(|| Error::Missing(header_key.to_owned()))?; + let version = header.get("version").and_then(Value::as_u64).ok_or_else(|| Error::Missing(header_key.to_owned()))?; + if name != expected_name { + return Err(Error::WrongName { expected: expected_name.to_owned(), found: name.to_owned() }); + } + if version != expected_version { + return Err(Error::UnsupportedVersion { found: version, expected: expected_version }); + } + Ok(Header { name: name.to_owned(), version }) +} + +/// Adds a named/versioned header to a JSON object, creating the object if needed. +pub fn write_header(root: &mut Value, header_key: &str, name: &str, version: u64) -> Result<(), Error> { + if !root.is_object() { + *root = Value::Object(Map::new()); + } + let object = root.as_object_mut().ok_or_else(|| Error::Missing(header_key.to_owned()))?; + object.insert(header_key.to_owned(), serde_json::json!({ "name": name, "version": version })); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_and_writes_headers() { + let mut root = serde_json::json!({ "payload": true }); + write_header(&mut root, "format", "example", 3).unwrap(); + assert_eq!(read_header(&root, "format", "example", 3).unwrap().version, 3); + assert_eq!(read_header(&root, "format", "example", 4), Err(Error::UnsupportedVersion { found: 3, expected: 4 })); + } +} From 4ecdcd78dace41e6098512a663cdf62d20e372e1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 00:03:41 -0500 Subject: [PATCH 2/6] Export common raster codec facade --- Cargo.lock | 8 ---- Cargo.toml | 1 - crates/raster-codecs/src/common.rs | 16 +++----- crates/raster-codecs/src/lib.rs | 2 + crates/versioned-json/Cargo.toml | 15 ------- crates/versioned-json/src/lib.rs | 63 ------------------------------ 6 files changed, 7 insertions(+), 98 deletions(-) delete mode 100644 crates/versioned-json/Cargo.toml delete mode 100644 crates/versioned-json/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 81cc73b2c..4b671fb2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2344,14 +2344,6 @@ dependencies = [ "kurbo 0.13.1", ] -[[package]] -name = "craft-versioned-json" -version = "0.1.0" -dependencies = [ - "serde_json", - "thiserror 2.0.21", -] - [[package]] name = "craft-video" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5519f216b..629bab64c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,6 @@ craft-aep = { path = "crates/aep" } craft-affinity = { path = "crates/affinity" } craft-ai-core = { path = "crates/ai-core" } craft-pdf = { path = "crates/pdf", version = "=0.1.0" } -craft-versioned-json = { path = "crates/versioned-json" } craft-pdn = { path = "crates/pdn" } craft-plate-test = { path = "crates/plate-test" } krilla = { version = "=0.8.2", default-features = false, features = ["raster-images"] } diff --git a/crates/raster-codecs/src/common.rs b/crates/raster-codecs/src/common.rs index b12fb8b0a..b653db687 100644 --- a/crates/raster-codecs/src/common.rs +++ b/crates/raster-codecs/src/common.rs @@ -24,8 +24,7 @@ pub struct DecodeLimits { 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 } + 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 } } } @@ -38,8 +37,7 @@ pub fn decode_rgba(bytes: &[u8], limits: DecodeLimits) -> Result Result>, CodecError> { - let mut reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format() - .map_err(|e| CodecError::malformed("image", e))?; + 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")); } @@ -71,22 +69,18 @@ pub fn encode_jpeg(pixels: &RgbaImage, quality: u8) -> Result, CodecErro } 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))?; + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality).encode_image(&rgb).map_err(|e| CodecError::encode("JPEG", 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")) + 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()))?; + 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; diff --git a/crates/versioned-json/Cargo.toml b/crates/versioned-json/Cargo.toml deleted file mode 100644 index 1b1bbd354..000000000 --- a/crates/versioned-json/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "craft-versioned-json" -description = "Reusable named and versioned JSON document envelopes" -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true -repository.workspace = true - -[dependencies] -serde_json = { workspace = true } -thiserror = { workspace = true } - -[lints] -workspace = true diff --git a/crates/versioned-json/src/lib.rs b/crates/versioned-json/src/lib.rs deleted file mode 100644 index f100920fb..000000000 --- a/crates/versioned-json/src/lib.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Helpers for named, versioned JSON documents whose payload is an object. -//! -//! The library checks and writes the common envelope. Each application owns its payload schema, -//! migrations and validation. - -#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] - -use serde_json::{Map, Value}; - -/// A recognized document header. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Header { - pub name: String, - pub version: u64, -} - -/// Header errors, kept independent of an application's schema and error type. -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -pub enum Error { - #[error("missing or invalid `{0}` header")] - Missing(String), - #[error("expected format `{expected}`, found `{found}`")] - WrongName { expected: String, found: String }, - #[error("format version {found} is unsupported; this build reads {expected}")] - UnsupportedVersion { found: u64, expected: u64 }, -} - -/// Checks `root[header_key]` for the expected name and exact supported version. -pub fn read_header(root: &Value, header_key: &str, expected_name: &str, expected_version: u64) -> Result { - let header = root.get(header_key).ok_or_else(|| Error::Missing(header_key.to_owned()))?; - let name = header.get("name").and_then(Value::as_str).ok_or_else(|| Error::Missing(header_key.to_owned()))?; - let version = header.get("version").and_then(Value::as_u64).ok_or_else(|| Error::Missing(header_key.to_owned()))?; - if name != expected_name { - return Err(Error::WrongName { expected: expected_name.to_owned(), found: name.to_owned() }); - } - if version != expected_version { - return Err(Error::UnsupportedVersion { found: version, expected: expected_version }); - } - Ok(Header { name: name.to_owned(), version }) -} - -/// Adds a named/versioned header to a JSON object, creating the object if needed. -pub fn write_header(root: &mut Value, header_key: &str, name: &str, version: u64) -> Result<(), Error> { - if !root.is_object() { - *root = Value::Object(Map::new()); - } - let object = root.as_object_mut().ok_or_else(|| Error::Missing(header_key.to_owned()))?; - object.insert(header_key.to_owned(), serde_json::json!({ "name": name, "version": version })); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reads_and_writes_headers() { - let mut root = serde_json::json!({ "payload": true }); - write_header(&mut root, "format", "example", 3).unwrap(); - assert_eq!(read_header(&root, "format", "example", 3).unwrap().version, 3); - assert_eq!(read_header(&root, "format", "example", 4), Err(Error::UnsupportedVersion { found: 3, expected: 4 })); - } -} From 1dbfad04fd9f010e355b8658e7defb97e756493a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 00:08:05 -0500 Subject: [PATCH 3/6] Support extension-based raster encoding --- crates/raster-codecs/src/common.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/raster-codecs/src/common.rs b/crates/raster-codecs/src/common.rs index b653db687..da3c1c39d 100644 --- a/crates/raster-codecs/src/common.rs +++ b/crates/raster-codecs/src/common.rs @@ -73,6 +73,16 @@ pub fn encode_jpeg(pixels: &RgbaImage, quality: u8) -> Result, CodecErro Ok(out.into_inner()) } +/// Encode pixels according to a destination filename's extension (`png`, `jpg`, or `jpeg`). +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), + other => Err(CodecError::unsupported("image", format!("cannot encode files with the .{other} extension"))), + } +} + 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")) } From dc705aa3647ef658b1367995796d20458d0b4882 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 00:09:00 -0500 Subject: [PATCH 4/6] Re-export common RGBA pixel types --- crates/raster-codecs/src/common.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/raster-codecs/src/common.rs b/crates/raster-codecs/src/common.rs index da3c1c39d..868e375d1 100644 --- a/crates/raster-codecs/src/common.rs +++ b/crates/raster-codecs/src/common.rs @@ -10,7 +10,7 @@ use image::{ImageReader, Limits as ImageLimits, RgbaImage}; use crate::CodecError; -pub use image::RgbaImage as Pixels; +pub use image::{Rgba as Pixel, RgbaImage as Pixels}; /// Limits applied before allocating the decoded image. #[derive(Debug, Clone, Copy, PartialEq, Eq)] From e95c99e7bcba0f70bae630596355c3ebd372fff6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 00:10:01 -0500 Subject: [PATCH 5/6] Support common raster output formats by path --- crates/raster-codecs/src/common.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/raster-codecs/src/common.rs b/crates/raster-codecs/src/common.rs index 868e375d1..fd8c1be60 100644 --- a/crates/raster-codecs/src/common.rs +++ b/crates/raster-codecs/src/common.rs @@ -73,16 +73,26 @@ pub fn encode_jpeg(pixels: &RgbaImage, quality: u8) -> Result, CodecErro Ok(out.into_inner()) } -/// Encode pixels according to a destination filename's extension (`png`, `jpg`, or `jpeg`). +/// 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")) } From eeee3d08b622fe6dd8de898d2e984978ed67cb34 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 02:03:55 -0500 Subject: [PATCH 6/6] Add shared Base64 PNG helpers --- Cargo.lock | 1 + crates/raster-codecs/Cargo.toml | 1 + crates/raster-codecs/src/common.rs | 24 ++++++++++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 4b671fb2e..94076f131 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2106,6 +2106,7 @@ 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 cadf9cbd0..786e9d55c 100644 --- a/crates/raster-codecs/Cargo.toml +++ b/crates/raster-codecs/Cargo.toml @@ -11,6 +11,7 @@ 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"] } diff --git a/crates/raster-codecs/src/common.rs b/crates/raster-codecs/src/common.rs index fd8c1be60..357123e7c 100644 --- a/crates/raster-codecs/src/common.rs +++ b/crates/raster-codecs/src/common.rs @@ -6,6 +6,7 @@ use std::io::Cursor; +use base64::{Engine as _, engine::general_purpose::STANDARD}; use image::{ImageReader, Limits as ImageLimits, RgbaImage}; use crate::CodecError; @@ -62,6 +63,29 @@ pub fn encode_png(pixels: &RgbaImage) -> Result, CodecError> { 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) {