render: normalize high precision texture inputs (#1267)

Adds a bounded normalization path for PNG16 and ICC-bearing JPEG texture inputs, producing float RGBA with recorded color and orientation decisions.

Co-authored-by: 0xlillith <0xlillith@users.noreply.github.com>
This commit is contained in:
seluneand0xlillith authored and GitHub committed 2026-10-15 13:55:35 -04:00
1 parent a47e82ba94
commit 1e984c8e46
13 files changed
+1616 -17

No files matched your search

@@ -0,0 +1,307 @@
//! Explicit high-precision normalization. No sampler, persistent resource or capability.
mod icc;
mod decode;
use super::{MapRole, Result, admitted_format, guarded, invalid};
use image::{ImageFormat, metadata::Orientation};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::sync::Arc;
pub const MAX_WORKING_BYTES: usize = 256 * 1024 * 1024;
pub const MAX_NORMALIZED_BYTES: usize = 64 * 1024 * 1024;
const GAMUT_TOLERANCE: f64 = 3.0 / 65535.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum UnprofiledColor {
Refuse,
/// Declare sRGB primaries; use stated PNG gamma, or sRGB transfer when absent.
DeclareSrgbPrimaries,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OrientationPolicy {
Refuse,
Apply,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GamutPolicy {
/// Refuse values beyond the documented finite-precision edge tolerance.
Refuse,
/// Explicit preview policy, with every clamped RGB channel counted in the receipt.
Clip,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct NormalizationOptions {
pub unprofiled_color: UnprofiledColor,
pub orientation: OrientationPolicy,
pub gamut: GamutPolicy,
/// Conservative buffer/workspace admission estimate, not a process-RSS ceiling.
pub max_working_bytes: usize,
}
impl Default for NormalizationOptions {
fn default() -> Self {
Self {
unprofiled_color: UnprofiledColor::Refuse,
orientation: OrientationPolicy::Refuse,
gamut: GamutPolicy::Refuse,
max_working_bytes: MAX_WORKING_BYTES,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ColorConversion {
RawData,
DeclaredSrgb,
PngSrgb,
PngGammaSrgbPrimaries { encoding_gamma: f64 },
IccMatrixTrc { profile_sha256: String },
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct NormalizationReceipt {
pub recipe_version: u32,
pub source_sha256: String,
pub normalized_sha256: String,
pub format: String,
pub encoded_bytes: usize,
pub retained_encoded_capacity: usize,
pub normalized_bytes: usize,
pub role: MapRole,
pub source_dimensions: [u32; 2],
pub source_channel_bits: u8,
pub output_dimensions: [u32; 2],
pub output_encoding: String,
pub output_precision: String,
pub source_exif_orientation: u8,
pub exif_metadata_present: bool,
pub exif_orientation_tag_present: bool,
pub orientation_applied: bool,
pub conversion: ColorConversion,
pub ignored_png_color_metadata: bool,
pub gamut_policy: GamutPolicy,
pub gamut_tolerance: f64,
pub clamped_rgb_channels: u64,
pub estimated_working_bytes: usize,
pub options: NormalizationOptions,
}
/// Encoded source only. Decoding, metadata admission and normalization happen in `decode`.
/// The caller must schedule whole-image codec/CMS work away from an interactive UI thread.
#[derive(Clone)]
pub struct NormalizationSource {
bytes: Arc<Vec<u8>>,
format: ImageFormat,
role: MapRole,
options: NormalizationOptions,
}
impl NormalizationSource {
pub fn new(bytes: Vec<u8>, role: MapRole, options: NormalizationOptions) -> Result<Self> {
let format = admitted_format(&bytes, bytes.capacity())?;
if options.max_working_bytes == 0 || options.max_working_bytes > MAX_WORKING_BYTES {
return Err(invalid("normalization working budget must be positive and at most 256 MiB"));
}
if bytes.capacity().checked_add(decode::CODEC_BYTES).is_none_or(|minimum| minimum > options.max_working_bytes) {
return Err(invalid("normalization working budget must cover encoded storage and the codec reservation"));
}
Ok(Self { bytes: Arc::new(bytes), format, role, options })
}
pub fn decode(&self, cancelled: impl Fn() -> bool) -> Result<NormalizedTexture> {
check_cancel(&cancelled)?;
let raw = guarded(|| decode::image(&self.bytes, self.format, self.options.max_working_bytes, self.bytes.capacity()))?;
check_cancel(&cancelled)?;
let orientation = raw.orientation;
if orientation != Orientation::NoTransforms {
if self.options.orientation == OrientationPolicy::Refuse {
return Err(invalid("normalization requires explicit orientation consent"));
}
if self.role == MapRole::NormalOpenGl {
return Err(invalid("oriented normal maps need an explicit vector/basis conversion policy"));
}
}
let conversion = raw.conversion(self.role, self.options.unprofiled_color)?;
let cms = match &conversion {
ColorConversion::IccMatrixTrc { .. } => {
let profile = raw.icc.as_ref().ok_or_else(|| invalid("ICC metadata disappeared"))?;
Some(icc::prepare(profile)?)
}
_ => None,
};
check_cancel(&cancelled)?;
let [width, height] = raw.dimensions;
let dimensions = match orientation {
Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH => [height, width],
_ => [width, height],
};
let sample_count = usize::try_from(u64::from(width) * u64::from(height) * 4).map_err(|_| invalid("normalized image size overflow"))?;
if sample_count.checked_mul(4).is_none_or(|n| n > MAX_NORMALIZED_BYTES) {
return Err(invalid("normalized float pixels exceed 64 MiB"));
}
let mut pixels = zeroed::<f32>(sample_count)?;
let row_channels = usize::try_from(u64::from(width) * 3).map_err(|_| invalid("normalization row overflow"))?;
let mut input = zeroed::<f64>(row_channels)?;
let mut converted = zeroed::<f64>(row_channels)?;
let mut clamped_rgb_channels = 0;
for y in 0..height {
check_cancel(&cancelled)?;
for (x, channels) in input.as_chunks_mut::<3>().0.iter_mut().enumerate() {
let [r, g, b, _] = raw.pixel(u32::try_from(x).map_err(|_| invalid("pixel coordinate overflow"))?, y)?;
channels.copy_from_slice(&[r, g, b]);
}
if let Some(cms) = &cms {
guarded(|| cms.transform(&input, &mut converted).map_err(|e| invalid(format!("ICC conversion: {e}"))))?;
} else {
converted.copy_from_slice(&input);
}
for (x, rgb) in converted.as_chunks::<3>().0.iter().enumerate() {
let x = u32::try_from(x).map_err(|_| invalid("pixel coordinate overflow"))?;
let [_, _, _, alpha] = raw.pixel(x, y)?;
let rgb = *rgb;
let mut rgba = [0.0_f32; 4];
for (destination, value) in rgba.iter_mut().take(3).zip(rgb) {
if !value.is_finite() {
return Err(invalid("color conversion produced nonfinite values"));
}
if self.options.gamut == GamutPolicy::Refuse && !(-GAMUT_TOLERANCE..=1.0 + GAMUT_TOLERANCE).contains(&value) {
return Err(invalid("color conversion is outside sRGB gamut; explicit preview clipping is required"));
}
let bounded = value.clamp(0.0, 1.0);
if bounded != value {
clamped_rgb_channels += 1;
}
let linear = match conversion {
ColorConversion::RawData => bounded,
ColorConversion::PngGammaSrgbPrimaries { encoding_gamma } => bounded.powf(1.0 / encoding_gamma),
_ => srgb_to_linear(bounded),
};
if !linear.is_finite() || !(0.0..=1.0).contains(&linear) {
return Err(invalid("normalization produced invalid linear/data channels"));
}
*destination = linear as f32;
}
if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) {
return Err(invalid("normalization produced invalid alpha"));
}
if let Some(destination) = rgba.get_mut(3) {
*destination = alpha as f32;
}
let index = destination_index(raw.dimensions, dimensions, orientation, x, y)?;
let end = index.checked_add(4).ok_or_else(|| invalid("oriented pixel offset overflow"))?;
pixels.get_mut(index..end).ok_or_else(|| invalid("oriented pixel lies outside its output"))?.copy_from_slice(&rgba);
}
}
let source_sha256 = digest_bytes(&self.bytes, &cancelled)?;
let mut pixel_hash = Sha256::new();
for chunk in pixels.chunks(4096) {
check_cancel(&cancelled)?;
for value in chunk {
pixel_hash.update(value.to_le_bytes());
}
}
check_cancel(&cancelled)?;
let receipt = NormalizationReceipt {
recipe_version: 1,
source_sha256,
normalized_sha256: format!("{:x}", pixel_hash.finalize()),
format: if self.format == ImageFormat::Png { "png" } else { "jpeg" }.into(),
encoded_bytes: self.bytes.len(),
retained_encoded_capacity: self.bytes.capacity(),
normalized_bytes: sample_count * 4,
role: self.role,
source_dimensions: raw.dimensions,
source_channel_bits: raw.source_bits,
output_dimensions: dimensions,
output_encoding: if self.role == MapRole::BaseColor { "linear_srgb" } else { "data" }.into(),
output_precision: "rgba_f32".into(),
source_exif_orientation: orientation.to_exif(),
exif_metadata_present: raw.exif_present,
exif_orientation_tag_present: raw.orientation_tag_present,
orientation_applied: orientation != Orientation::NoTransforms,
ignored_png_color_metadata: self.role != MapRole::BaseColor && raw.has_png_color_metadata(),
conversion,
gamut_policy: self.options.gamut,
gamut_tolerance: GAMUT_TOLERANCE,
clamped_rgb_channels,
estimated_working_bytes: raw.estimated_working_bytes,
options: self.options,
};
Ok(NormalizedTexture { receipt, pixels })
}
}
pub struct NormalizedTexture {
receipt: NormalizationReceipt,
pixels: Vec<f32>,
}
impl std::fmt::Debug for NormalizedTexture {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NormalizedTexture").field("receipt", &self.receipt).field("sample_count", &self.pixels.len()).finish()
}
}
impl NormalizedTexture {
pub fn receipt(&self) -> &NormalizationReceipt {
&self.receipt
}
pub fn rgba(&self) -> &[f32] {
&self.pixels
}
}
fn zeroed<T: Default + Clone>(length: usize) -> Result<Vec<T>> {
let mut values = Vec::new();
values.try_reserve_exact(length).map_err(|_| invalid("cannot allocate normalization buffer"))?;
values.resize(length, T::default());
Ok(values)
}
fn check_cancel(cancelled: &impl Fn() -> bool) -> Result<()> {
if cancelled() { Err(invalid("texture normalization cancelled")) } else { Ok(()) }
}
fn digest_bytes(bytes: &[u8], cancelled: &impl Fn() -> bool) -> Result<String> {
let mut digest = Sha256::new();
for chunk in bytes.chunks(64 * 1024) {
check_cancel(cancelled)?;
digest.update(chunk);
}
Ok(format!("{:x}", digest.finalize()))
}
fn srgb_to_linear(value: f64) -> f64 {
if value <= 0.04045 { value / 12.92 } else { ((value + 0.055) / 1.055).powf(2.4) }
}
fn destination_index(source: [u32; 2], output: [u32; 2], orientation: Orientation, x: u32, y: u32) -> Result<usize> {
let [width, height] = source;
if x >= width || y >= height || width == 0 || height == 0 {
return Err(invalid("invalid orientation coordinate"));
}
let reverse_x = width - 1 - x;
let reverse_y = height - 1 - y;
let (dx, dy) = match orientation {
Orientation::NoTransforms => (x, y),
Orientation::Rotate90 => (reverse_y, x),
Orientation::Rotate180 => (reverse_x, reverse_y),
Orientation::Rotate270 => (y, reverse_x),
Orientation::FlipHorizontal => (reverse_x, y),
Orientation::FlipVertical => (x, reverse_y),
Orientation::Rotate90FlipH => (y, x),
Orientation::Rotate270FlipH => (reverse_y, reverse_x),
};
let [output_width, output_height] = output;
if dx >= output_width || dy >= output_height {
return Err(invalid("invalid oriented output dimensions"));
}
let index = u64::from(dy)
.checked_mul(u64::from(output_width))
.and_then(|value| value.checked_add(u64::from(dx)))
.and_then(|value| value.checked_mul(4))
.ok_or_else(|| invalid("orientation index overflow"))?;
usize::try_from(index).map_err(|_| invalid("orientation index overflow"))
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,326 @@
use super::{ColorConversion, UnprofiledColor, digest_bytes, icc, zeroed};
use crate::texture_input::{MAX_TEXTURE_EDGE, Result, invalid};
use image::{ImageDecoder, ImageFormat, Limits, metadata::Orientation};
use std::io::Cursor;
mod jpeg_markers;
mod png_markers;
pub(super) const CODEC_BYTES: usize = 64 * 1024 * 1024;
const CMS_WORKSPACE_ESTIMATE: usize = 16 * 1024 * 1024;
const MAX_EXIF_BYTES: usize = 64 * 1024;
const SRGB_CHROMATICITIES: [u32; 8] = [31270, 32900, 64000, 33000, 30000, 60000, 15000, 6000];
#[derive(PartialEq)]
struct Metadata {
icc: Option<Vec<u8>>,
orientation: Orientation,
exif_present: bool,
orientation_tag_present: bool,
srgb: bool,
gamma: Option<u32>,
chromaticities: Option<[u32; 8]>,
other_color: bool,
}
pub(super) struct RawImage {
pub dimensions: [u32; 2],
pub source_bits: u8,
pub orientation: Orientation,
pub exif_present: bool,
pub orientation_tag_present: bool,
pub icc: Option<Vec<u8>>,
pub estimated_working_bytes: usize,
bits: u8,
channels: usize,
pixels: Vec<u8>,
srgb: bool,
gamma: Option<u32>,
chromaticities: Option<[u32; 8]>,
other_color: bool,
}
impl RawImage {
pub fn has_png_color_metadata(&self) -> bool {
self.srgb || self.gamma.is_some() || self.chromaticities.is_some() || self.other_color
}
pub fn conversion(&self, role: crate::texture_input::MapRole, policy: UnprofiledColor) -> Result<ColorConversion> {
if role != crate::texture_input::MapRole::BaseColor {
if self.icc.is_some() {
return Err(invalid("ICC profiles on data maps require an explicit separate interpretation policy"));
}
return Ok(ColorConversion::RawData);
}
if self.other_color {
return Err(invalid("PNG CICP/HDR metadata is outside initial SDR texture normalization"));
}
if let Some(profile) = &self.icc {
if self.srgb {
return Err(invalid("PNG has conflicting ICC and sRGB declarations"));
}
if self.channels < 3 {
return Err(invalid("ICC conversion initially requires RGB/RGBA source samples"));
}
icc::admit(profile)?;
return Ok(ColorConversion::IccMatrixTrc { profile_sha256: digest_bytes(profile, &|| false)? });
}
if self.srgb {
return Ok(ColorConversion::PngSrgb);
}
if policy == UnprofiledColor::Refuse {
return Err(invalid("unprofiled color requires an explicit sRGB-primary declaration"));
}
if self.chromaticities.is_some_and(|values| values.into_iter().zip(SRGB_CHROMATICITIES).any(|(a, b)| a.abs_diff(b) > 1)) {
return Err(invalid("PNG chromaticities differ from the declared sRGB primaries"));
}
if let Some(gamma) = self.gamma {
let encoding_gamma = f64::from(gamma) / 100_000.0;
if !(0.1..=10.0).contains(&encoding_gamma) {
return Err(invalid("PNG encoding gamma is outside [0.1, 10]"));
}
return Ok(ColorConversion::PngGammaSrgbPrimaries { encoding_gamma });
}
Ok(ColorConversion::DeclaredSrgb)
}
pub fn pixel(&self, x: u32, y: u32) -> Result<[f64; 4]> {
let [width, height] = self.dimensions;
if x >= width || y >= height {
return Err(invalid("pixel lies outside decoded image"));
}
let pixel = usize::try_from(u64::from(y) * u64::from(width) + u64::from(x)).map_err(|_| invalid("pixel offset overflow"))?;
let base = pixel.checked_mul(self.channels).ok_or_else(|| invalid("sample offset overflow"))?;
let sample = |channel: usize| -> Result<f64> {
let index = base.checked_add(channel).ok_or_else(|| invalid("sample offset overflow"))?;
if self.bits == 16 {
let start = index.checked_mul(2).ok_or_else(|| invalid("16-bit sample offset overflow"))?;
let end = start.checked_add(2).ok_or_else(|| invalid("16-bit sample offset overflow"))?;
let bytes: [u8; 2] = self.pixels.get(start..end).and_then(|v| v.try_into().ok()).ok_or_else(|| invalid("truncated 16-bit pixel"))?;
Ok(f64::from(u16::from_be_bytes(bytes)) / 65535.0)
} else {
self.pixels.get(index).map(|v| f64::from(*v) / 255.0).ok_or_else(|| invalid("truncated 8-bit pixel"))
}
};
match self.channels {
1 => {
let value = sample(0)?;
Ok([value, value, value, 1.0])
}
2 => {
let value = sample(0)?;
Ok([value, value, value, sample(1)?])
}
3 => Ok([sample(0)?, sample(1)?, sample(2)?, 1.0]),
4 => Ok([sample(0)?, sample(1)?, sample(2)?, sample(3)?]),
_ => Err(invalid("unsupported decoded channel layout")),
}
}
}
fn dimensions(width: u32, height: u32, encoded: usize, budget: usize) -> Result<usize> {
if width == 0 || height == 0 || width > MAX_TEXTURE_EDGE || height > MAX_TEXTURE_EDGE {
return Err(invalid("normalization dimensions exceed 2048x2048"));
}
let pixels = usize::try_from(u64::from(width) * u64::from(height)).map_err(|_| invalid("image size overflow"))?;
let rows = usize::try_from(u64::from(width) * 3 * 8 * 2).map_err(|_| invalid("row workspace overflow"))?;
let estimate = pixels
.checked_mul(8 + 16)
.and_then(|v| v.checked_add(encoded))
.and_then(|v| v.checked_add(CODEC_BYTES + CMS_WORKSPACE_ESTIMATE + 2 * icc::MAX_ICC_BYTES + rows))
.ok_or_else(|| invalid("normalization working estimate overflow"))?;
if estimate > budget {
return Err(invalid("normalization exceeds its conservative working budget"));
}
Ok(estimate)
}
fn exif_u16(bytes: &[u8], offset: usize, little: bool) -> Result<u16> {
let end = offset.checked_add(2).ok_or_else(|| invalid("EXIF offset overflow"))?;
let data: [u8; 2] = bytes.get(offset..end).and_then(|v| v.try_into().ok()).ok_or_else(|| invalid("truncated EXIF SHORT"))?;
Ok(if little { u16::from_le_bytes(data) } else { u16::from_be_bytes(data) })
}
fn exif_u32(bytes: &[u8], offset: usize, little: bool) -> Result<u32> {
let end = offset.checked_add(4).ok_or_else(|| invalid("EXIF offset overflow"))?;
let data: [u8; 4] = bytes.get(offset..end).and_then(|v| v.try_into().ok()).ok_or_else(|| invalid("truncated EXIF LONG"))?;
Ok(if little { u32::from_le_bytes(data) } else { u32::from_be_bytes(data) })
}
/// Read only classic TIFF IFD0's Orientation. Other EXIF values/thumbnail IFDs are
/// not decoded or certified. TIFF's default for a missing orientation is top-left.
fn orientation(exif: Option<&[u8]>) -> Result<(Orientation, bool)> {
let Some(bytes) = exif else {
return Ok((Orientation::NoTransforms, false));
};
if bytes.len() > MAX_EXIF_BYTES {
return Err(invalid("EXIF metadata exceeds 64 KiB"));
}
let little = match bytes.get(..2) {
Some(b"II") => true,
Some(b"MM") => false,
_ => return Err(invalid("invalid EXIF byte order")),
};
if exif_u16(bytes, 2, little)? != 42 {
return Err(invalid("EXIF needs a classic TIFF header"));
}
let start = usize::try_from(exif_u32(bytes, 4, little)?).map_err(|_| invalid("EXIF IFD offset overflow"))?;
if start < 8 || start % 2 != 0 {
return Err(invalid("invalid EXIF IFD0 offset"));
}
let count = usize::from(exif_u16(bytes, start, little)?);
if count == 0 || count > 128 {
return Err(invalid("EXIF IFD0 needs 1..128 entries"));
}
let begin = start.checked_add(2).ok_or_else(|| invalid("EXIF IFD offset overflow"))?;
let end = begin.checked_add(count * 12).ok_or_else(|| invalid("EXIF IFD size overflow"))?;
let entries = bytes.get(begin..end).ok_or_else(|| invalid("truncated EXIF IFD0"))?;
let _next_ifd = exif_u32(bytes, end, little)?;
let mut result = None;
for entry in entries.as_chunks::<12>().0 {
if exif_u16(entry, 0, little)? != 0x0112 {
continue;
}
if result.is_some() || exif_u16(entry, 2, little)? != 3 || exif_u32(entry, 4, little)? != 1 {
return Err(invalid("EXIF Orientation must be one unique SHORT"));
}
let value = u8::try_from(exif_u16(entry, 8, little)?).map_err(|_| invalid("invalid EXIF orientation"))?;
result = Some(Orientation::from_exif(value).ok_or_else(|| invalid("EXIF orientation must be in 1..8"))?);
}
Ok((result.unwrap_or(Orientation::NoTransforms), result.is_some()))
}
fn png_metadata(info: &png::Info<'_>) -> Result<Metadata> {
if info.animation_control.is_some() || info.frame_control.is_some() {
return Err(invalid("animated PNG textures are unsupported"));
}
let icc = match info.icc_profile.as_ref() {
Some(profile) if profile.len() <= icc::MAX_ICC_BYTES => {
let mut copy = zeroed::<u8>(profile.len())?;
copy.copy_from_slice(profile);
Some(copy)
}
Some(_) => return Err(invalid("embedded ICC exceeds 256 KiB")),
None => None,
};
let chromaticities = info.chrm_chunk.map(|chrm| {
[
chrm.white.0.into_scaled(),
chrm.white.1.into_scaled(),
chrm.red.0.into_scaled(),
chrm.red.1.into_scaled(),
chrm.green.0.into_scaled(),
chrm.green.1.into_scaled(),
chrm.blue.0.into_scaled(),
chrm.blue.1.into_scaled(),
]
});
let (orientation, orientation_tag_present) = orientation(info.exif_metadata.as_deref())?;
Ok(Metadata {
icc,
orientation,
exif_present: info.exif_metadata.is_some(),
orientation_tag_present,
srgb: info.srgb.is_some(),
gamma: info.gama_chunk.map(|v| v.into_scaled()),
chromaticities,
other_color: info.coding_independent_code_points.is_some()
|| info.mastering_display_color_volume.is_some()
|| info.content_light_level.is_some(),
})
}
pub(super) fn image(bytes: &[u8], format: ImageFormat, budget: usize, encoded_capacity: usize) -> Result<RawImage> {
match format {
ImageFormat::Png => png_image(bytes, budget, encoded_capacity),
ImageFormat::Jpeg => jpeg_image(bytes, budget, encoded_capacity),
_ => Err(invalid("normalization accepts PNG or JPEG only")),
}
}
fn png_image(bytes: &[u8], budget: usize, encoded_capacity: usize) -> Result<RawImage> {
let markers = png_markers::read(bytes)?;
let mut decoder = png::Decoder::new(Cursor::new(bytes));
decoder.set_limits(png::Limits { bytes: CODEC_BYTES });
decoder.set_ignore_text_chunk(true);
decoder.set_transformations(png::Transformations::EXPAND);
let mut reader = decoder.read_info().map_err(|e| invalid(e.to_string()))?;
let info = reader.info();
markers.check(info)?;
let size = [info.width, info.height];
let estimate = dimensions(info.width, info.height, encoded_capacity, budget)?;
let source_bits = info.bit_depth as u8;
let before = png_metadata(info)?;
let length = reader.output_buffer_size().ok_or_else(|| invalid("PNG decoded storage overflow"))?;
if length > 32 * 1024 * 1024 {
return Err(invalid("PNG decoded samples exceed 32 MiB"));
}
let mut pixels = zeroed::<u8>(length)?;
let output = reader.next_frame(&mut pixels).map_err(|e| invalid(e.to_string()))?;
reader.finish().map_err(|e| invalid(e.to_string()))?;
let after = png_metadata(reader.info())?;
if before != after {
return Err(invalid("late PNG color/orientation metadata requires explicit separate handling"));
}
if [output.width, output.height] != size || output.buffer_size() != pixels.len() {
return Err(invalid("PNG output dimensions changed"));
}
let bits = output.bit_depth as u8;
if !matches!(bits, 8 | 16) || output.color_type == png::ColorType::Indexed {
return Err(invalid("PNG expansion did not yield 8/16-bit samples"));
}
let Metadata { icc, orientation, exif_present, orientation_tag_present, srgb, gamma, chromaticities, other_color } = after;
Ok(RawImage {
dimensions: size,
source_bits,
orientation,
exif_present,
orientation_tag_present,
icc,
estimated_working_bytes: estimate,
bits,
channels: output.color_type.samples(),
pixels,
srgb,
gamma,
chromaticities,
other_color,
})
}
fn jpeg_image(bytes: &[u8], budget: usize, encoded_capacity: usize) -> Result<RawImage> {
let markers = jpeg_markers::read(bytes)?;
let mut decoder = image::codecs::jpeg::JpegDecoder::new(Cursor::new(bytes)).map_err(|e| invalid(e.to_string()))?;
let mut limits = Limits::default();
limits.max_image_width = Some(MAX_TEXTURE_EDGE);
limits.max_image_height = Some(MAX_TEXTURE_EDGE);
limits.max_alloc = Some(CODEC_BYTES as u64);
decoder.set_limits(limits).map_err(|e| invalid(e.to_string()))?;
let (width, height) = decoder.dimensions();
let estimate = dimensions(width, height, encoded_capacity, budget)?;
let channels = match decoder.color_type() {
image::ColorType::L8 => 1,
image::ColorType::Rgb8 => 3,
_ => return Err(invalid("normalization initially accepts only 8-bit JPEG channels")),
};
let icc = decoder.icc_profile().map_err(|e| invalid(e.to_string()))?;
if icc.as_ref().is_some_and(|p| p.len() > icc::MAX_ICC_BYTES || p.capacity() > icc::MAX_ICC_BYTES) {
return Err(invalid("embedded ICC exceeds 256 KiB"));
}
let exif = decoder.exif_metadata().map_err(|e| invalid(e.to_string()))?;
if markers != (icc.is_some(), exif.is_some()) {
return Err(invalid("JPEG decoder did not retain declared ICC/EXIF metadata"));
}
let (orientation, orientation_tag_present) = orientation(exif.as_deref())?;
let length = usize::try_from(decoder.total_bytes()).map_err(|_| invalid("JPEG decoded storage overflow"))?;
if length > 32 * 1024 * 1024 {
return Err(invalid("JPEG decoded samples exceed 32 MiB"));
}
let mut pixels = zeroed::<u8>(length)?;
decoder.read_image(&mut pixels).map_err(|e| invalid(e.to_string()))?;
Ok(RawImage {
dimensions: [width, height],
source_bits: 8,
orientation,
exif_present: exif.is_some(),
orientation_tag_present,
icc,
estimated_working_bytes: estimate,
bits: 8,
channels,
pixels,
srgb: false,
gamma: None,
chromaticities: None,
other_color: false,
})
}
@@ -0,0 +1,75 @@
//! Metadata presence/framing admission from T.81 and ICC's JPEG embedding note.
//! This does not decode entropy data or replace the established JPEG codec.
use crate::texture_input::{Result, invalid};
pub(super) fn read(bytes: &[u8]) -> Result<(bool, bool)> {
if bytes.get(..2) != Some(b"\xff\xd8") {
return Err(invalid("invalid JPEG SOI"));
}
let mut offset = 2;
let mut entropy = false;
let mut scan_seen = false;
let mut exif = false;
let mut icc = false;
let mut segments = 0;
while offset < bytes.len() {
if bytes.get(offset) != Some(&255) {
if !entropy {
return Err(invalid("JPEG marker expected"));
}
offset += 1;
continue;
}
while bytes.get(offset) == Some(&255) {
offset += 1;
}
let marker = *bytes.get(offset).ok_or_else(|| invalid("truncated JPEG marker"))?;
offset += 1;
if entropy && (marker == 0 || (0xd0..=0xd7).contains(&marker)) {
continue;
}
entropy = false;
segments += 1;
if segments > 4096 {
return Err(invalid("JPEG normalization exceeds 4096 marker segments"));
}
if marker == 0xd9 {
return if offset == bytes.len() { Ok((icc, exif)) } else { Err(invalid("JPEG EOI must be final")) };
}
if marker == 0 || marker == 0xd8 || marker == 1 || (0xd0..=0xd7).contains(&marker) {
return Err(invalid("unsupported JPEG standalone marker"));
}
let header_end = offset.checked_add(2).ok_or_else(|| invalid("JPEG offset overflow"))?;
let length: [u8; 2] = bytes.get(offset..header_end).and_then(|v| v.try_into().ok()).ok_or_else(|| invalid("truncated JPEG length"))?;
let length = usize::from(u16::from_be_bytes(length));
let end = offset.checked_add(length).ok_or_else(|| invalid("JPEG segment overflow"))?;
if length < 2 {
return Err(invalid("invalid JPEG segment length"));
}
let data = bytes.get(header_end..end).ok_or_else(|| invalid("truncated JPEG segment"))?;
if marker == 0xe2 && data.starts_with(b"ICC_PROFILE\0") {
if scan_seen || data.len() <= 14 {
return Err(invalid("late or truncated JPEG ICC metadata"));
}
icc = true; // The codec assembles sequences; presence must survive that validation.
}
if marker == 0xe1 && data.starts_with(b"Exif\0\0") {
if scan_seen || exif || data.len() <= 6 {
return Err(invalid("duplicate, late or truncated JPEG EXIF metadata"));
}
exif = true;
}
if (0xc0..=0xcf).contains(&marker)
&& !matches!(marker, 0xc4 | 0xc8 | 0xcc)
&& (!matches!(marker, 0xc0..=0xc2) || data.first() != Some(&8) || !matches!(data.get(5), Some(1 | 3)))
{
return Err(invalid("initial JPEG normalization requires 8-bit grayscale or three-component DCT input"));
}
if marker == 0xda {
scan_seen = true;
entropy = true;
}
offset = end;
}
Err(invalid("JPEG EOI missing"))
}
@@ -0,0 +1,66 @@
//! Bounded framing/presence checks; the PNG crate still decodes and validates chunks.
use crate::texture_input::{Result, invalid};
const SEMANTIC: [[u8; 4]; 8] = [*b"iCCP", *b"sRGB", *b"gAMA", *b"cHRM", *b"eXIf", *b"cICP", *b"mDCV", *b"cLLI"];
pub(super) struct Markers([bool; 8]);
impl Markers {
pub fn check(&self, info: &png::Info<'_>) -> Result<()> {
let parsed = [
info.icc_profile.is_some(),
info.srgb.is_some(),
info.gama_chunk.is_some(),
info.chrm_chunk.is_some(),
info.exif_metadata.is_some(),
info.coding_independent_code_points.is_some(),
info.mastering_display_color_volume.is_some(),
info.content_light_level.is_some(),
];
if self.0 != parsed {
return Err(invalid("PNG decoder did not retain declared color/orientation metadata"));
}
Ok(())
}
}
pub(super) fn read(bytes: &[u8]) -> Result<Markers> {
if bytes.get(..8) != Some(b"\x89PNG\r\n\x1a\n") {
return Err(invalid("invalid PNG signature"));
}
let mut offset = 8_usize;
let mut markers = [false; 8];
let mut pixels_seen = false;
for _ in 0..4096 {
let header_end = offset.checked_add(8).ok_or_else(|| invalid("PNG chunk offset overflow"))?;
let header = bytes.get(offset..header_end).ok_or_else(|| invalid("truncated PNG chunk header"))?;
let length: [u8; 4] = header.get(..4).and_then(|v| v.try_into().ok()).ok_or_else(|| invalid("invalid PNG length"))?;
let length = usize::try_from(u32::from_be_bytes(length)).map_err(|_| invalid("PNG length overflow"))?;
let kind = header.get(4..8).ok_or_else(|| invalid("invalid PNG chunk type"))?;
let end = header_end.checked_add(length).and_then(|v| v.checked_add(4)).ok_or_else(|| invalid("PNG chunk size overflow"))?;
if end > bytes.len() {
return Err(invalid("truncated PNG chunk"));
}
if let Some(index) = SEMANTIC.iter().position(|tag| tag == kind) {
let seen = markers.get_mut(index).ok_or_else(|| invalid("PNG metadata index overflow"))?;
if *seen || pixels_seen {
return Err(invalid("duplicate or late PNG color/orientation metadata is unsupported"));
}
*seen = true;
}
if kind == b"IDAT" {
pixels_seen = true;
}
if kind == b"IEND" {
if length != 0 || end != bytes.len() {
return Err(invalid("PNG IEND must be empty and final"));
}
if matches!(markers, [true, true, ..]) {
return Err(invalid("PNG has conflicting ICC and sRGB declarations"));
}
return Ok(Markers(markers));
}
offset = end;
}
Err(invalid("PNG normalization exceeds 4096 chunks"))
}
@@ -0,0 +1,307 @@
//! Bounded RGB matrix/TRC profile admission before calling the existing permissive CMS.
//! Header/tag layout follows the public ICC.1 profile specification, not another CAD tool.
use crate::texture_input::{Result, guarded, invalid};
use moxcms::{ColorProfile, DataColorSpace, Layout, ParsingOptions, ProfileClass, RenderingIntent, TransformF64Executor, TransformOptions};
use std::sync::Arc;
pub(super) const MAX_ICC_BYTES: usize = 256 * 1024;
const MAX_TAGS: usize = 64;
const MAX_TAG_BYTES: usize = 64 * 1024;
const MAX_TRC_VALUES: usize = 4096;
fn word(bytes: &[u8], offset: usize) -> Result<usize> {
let end = offset.checked_add(4).ok_or_else(|| invalid("ICC offset overflow"))?;
let value: [u8; 4] = bytes.get(offset..end).and_then(|v| v.try_into().ok()).ok_or_else(|| invalid("truncated ICC word"))?;
usize::try_from(u32::from_be_bytes(value)).map_err(|_| invalid("ICC word is too large"))
}
#[derive(Clone, Copy)]
struct TagRange {
signature: [u8; 4],
start: usize,
end: usize,
}
/// Reject unsupported conversion semantics instead of silently falling back to a matrix.
pub(super) fn admit(bytes: &[u8]) -> Result<()> {
if !(132..=MAX_ICC_BYTES).contains(&bytes.len()) || word(bytes, 0)? != bytes.len() {
return Err(invalid("ICC profile must have its declared size, at most 256 KiB"));
}
if !matches!(bytes.get(8), Some(2 | 4))
|| !matches!(bytes.get(12..16), Some(b"scnr" | b"mntr"))
|| bytes.get(16..20) != Some(b"RGB ")
|| bytes.get(20..24) != Some(b"XYZ ")
|| bytes.get(36..40) != Some(b"acsp")
|| word(bytes, 64)? > 3
{
return Err(invalid("only version-2/4 RGB input/display ICC profiles with XYZ PCS are supported"));
}
let count = word(bytes, 128)?;
if count == 0 || count > MAX_TAGS {
return Err(invalid("ICC profile must have 1..64 tags"));
}
let table_end = 132 + count * 12; // count has already been capped.
let table = bytes.get(132..table_end).ok_or_else(|| invalid("truncated ICC tag table"))?;
let mut ranges: Vec<TagRange> = Vec::new();
ranges.try_reserve_exact(count).map_err(|_| invalid("cannot allocate ICC tag table"))?;
for entry in table.as_chunks::<12>().0 {
let signature: [u8; 4] = entry.get(..4).and_then(|v| v.try_into().ok()).ok_or_else(|| invalid("invalid ICC tag"))?;
if !matches!(
&signature,
b"rXYZ"
| b"gXYZ"
| b"bXYZ"
| b"rTRC"
| b"gTRC"
| b"bTRC"
| b"wtpt"
| b"bkpt"
| b"chad"
| b"desc"
| b"cprt"
| b"dmnd"
| b"dmdd"
| b"tech"
| b"view"
| b"meas"
| b"lumi"
| b"chrm"
) {
return Err(invalid("ICC tag is outside the initial RGB matrix/TRC profile policy"));
}
let start = word(entry, 4)?;
let size = word(entry, 8)?;
let end = start.checked_add(size).ok_or_else(|| invalid("ICC tag range overflow"))?;
if start < table_end || start % 4 != 0 || !(8..=MAX_TAG_BYTES).contains(&size) {
return Err(invalid("ICC tag range is invalid or exceeds 64 KiB"));
}
let data = bytes.get(start..end).ok_or_else(|| invalid("ICC tag extends past its profile"))?;
for earlier in &ranges {
// ICC permits identical shared data ranges, notably shared tone curves.
if signature == earlier.signature || (start < earlier.end && earlier.start < end && (start != earlier.start || end != earlier.end)) {
return Err(invalid("ICC has duplicate tags or partially overlapping data"));
}
}
if data.get(4..8) != Some(&[0; 4]) {
return Err(invalid("ICC tag reserved bytes must be zero"));
}
if matches!(&signature, b"rXYZ" | b"gXYZ" | b"bXYZ" | b"wtpt" | b"bkpt" | b"lumi") && (size != 20 || data.get(..4) != Some(b"XYZ ")) {
return Err(invalid("ICC colorant/white-point tags must contain one XYZ value"));
}
if matches!(&signature, b"rTRC" | b"gTRC" | b"bTRC") {
admit_curve(data)?;
}
if &signature == b"chad" && (size != 44 || data.get(..4) != Some(b"sf32")) {
return Err(invalid("ICC chromatic adaptation must be one 3x3 matrix"));
}
if &signature == b"chrm" {
// Informational device chromaticities, not a replacement for PCS colorants.
// Initially admit three explicit channels (type 0), not enumerated phosphors.
if size != 36 || data.get(..4) != Some(b"chrm") || data.get(8..12) != Some(&[0, 3, 0, 0]) {
return Err(invalid("ICC chromaticities require three explicit device channels"));
}
for pair in data.get(12..).ok_or_else(|| invalid("truncated ICC chromaticities"))?.as_chunks::<8>().0 {
let x = word(pair, 0)?;
let y = word(pair, 4)?;
if y == 0 || x > 65536 || y > 65536 || x + y > 65536 {
return Err(invalid("ICC device chromaticities are outside the initial xy policy"));
}
}
}
ranges.push(TagRange { signature, start, end });
}
for required in [*b"rXYZ", *b"gXYZ", *b"bXYZ", *b"rTRC", *b"gTRC", *b"bTRC", *b"wtpt"] {
if !ranges.iter().any(|tag| tag.signature == required) {
return Err(invalid("ICC matrix/TRC profile is missing a required tag"));
}
}
Ok(())
}
fn admit_curve(data: &[u8]) -> Result<()> {
match data.get(..4) {
Some(b"curv") => {
let count = word(data, 8)?;
if count > MAX_TRC_VALUES || data.len() < 12 + count * 2 {
return Err(invalid("ICC tone curve exceeds 4096 values or is truncated"));
}
}
Some(b"para") => {
let kind: [u8; 2] = data.get(8..10).and_then(|v| v.try_into().ok()).ok_or_else(|| invalid("truncated ICC parametric curve"))?;
let parameters = match u16::from_be_bytes(kind) {
0 => 1,
1 => 3,
2 => 4,
3 => 5,
4 => 7,
_ => return Err(invalid("unsupported ICC parametric curve type")),
};
if data.len() < 12 + parameters * 4 {
return Err(invalid("truncated ICC parametric curve parameters"));
}
}
_ => return Err(invalid("ICC tone curve must be a bounded curve or parametric curve")),
}
Ok(())
}
/// ICC transform returns encoded sRGB; the caller separately linearizes RGB and preserves
/// alpha. Extended range is essential: the default destination LUT clips before callers
/// can detect gamut loss. f64 retains a 16-bit source LUT when an analytic curve is unavailable;
/// the CMS still uses f32 matrix/curve arithmetic internally, not full f64 colorimetry.
pub(super) fn prepare(bytes: &[u8]) -> Result<Arc<TransformF64Executor>> {
admit(bytes)?;
guarded(|| {
let options = ParsingOptions { max_profile_size: MAX_ICC_BYTES + 1, max_allowed_clut_size: 0, max_allowed_trc_size: MAX_TRC_VALUES };
let source = ColorProfile::new_from_slice_with_options(bytes, options).map_err(|e| invalid(format!("ICC profile: {e}")))?;
if source.color_space != DataColorSpace::Rgb
|| source.pcs != DataColorSpace::Xyz
|| !matches!(source.profile_class, ProfileClass::InputDevice | ProfileClass::DisplayDevice)
|| !source.is_matrix_shaper()
{
return Err(invalid("ICC profile does not provide the admitted RGB matrix/TRC conversion"));
}
// The pinned CMS's same-curve optimization can treat two matching curves as
// three matching curves. Keep initial admission narrower than that ambiguity.
if source.red_trc != source.green_trc || source.red_trc != source.blue_trc {
return Err(invalid("initial ICC conversion requires identical RGB tone curves"));
}
let options = TransformOptions {
rendering_intent: RenderingIntent::RelativeColorimetric,
allow_use_cicp_transfer: false,
prefer_fixed_point: false,
allow_extended_range_rgb_xyz: true,
..TransformOptions::default()
};
source.create_transform_f64(Layout::Rgb, &ColorProfile::new_srgb(), Layout::Rgb, options).map_err(|e| invalid(format!("ICC transform: {e}")))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn profile(linear: bool) -> Vec<u8> {
let mut profile = ColorProfile::new_srgb();
profile.cicp = None;
// Original even-length text keeps the pinned writer's tags aligned. Its default
// odd-length text can produce an unaligned following tag; admission stays strict.
let text =
moxcms::ProfileText::Localizable(vec![moxcms::LocalizableString::new("en".into(), "US".into(), "Original synthetic fixture".into())]);
profile.description = Some(text.clone());
profile.copyright = Some(text);
if linear {
let curve = moxcms::ToneReprCurve::Parametric(vec![1.0]);
profile.red_trc = Some(curve.clone());
profile.green_trc = Some(curve.clone());
profile.blue_trc = Some(curve);
}
profile.encode().unwrap()
}
#[test]
fn linear_rgb_profile_is_converted_instead_of_discarded() {
let transform = prepare(&profile(true)).unwrap();
let mut out = [0.0; 3];
transform.transform(&[0.25, 0.5, 0.75], &mut out).unwrap();
for (input, actual) in [0.25_f64, 0.5, 0.75].into_iter().zip(out) {
let expected = 1.055 * input.powf(1.0 / 2.4) - 0.055;
assert!((actual - expected).abs() < 3.0 / 65535.0, "{actual} != {expected}");
}
}
#[test]
fn adjacent_16_bit_source_values_remain_distinguishable() {
let transform = prepare(&profile(false)).unwrap();
let input = [32768.0 / 65535.0, 32769.0 / 65535.0, 0.0];
let mut out = [0.0; 3];
transform.transform(&input, &mut out).unwrap();
assert!(out[1] - out[0] > 0.5 / 65535.0);
assert!((out[0] - input[0]).abs() < 3.0 / 65535.0);
assert!((out[1] - input[1]).abs() < 3.0 / 65535.0);
}
#[test]
fn shared_curve_ranges_are_allowed_but_partial_overlap_is_not() {
let mut encoded = profile(false);
let count = word(&encoded, 128).unwrap();
let entries: Vec<_> = encoded[132..132 + count * 12]
.as_chunks::<12>()
.0
.iter()
.enumerate()
.map(|(i, tag)| (132 + i * 12, <[u8; 4]>::try_from(&tag[..4]).unwrap()))
.collect();
let red = entries.iter().find(|(_, signature)| signature == b"rTRC").unwrap().0;
let shared = encoded[red + 4..red + 12].to_vec();
for &(offset, signature) in &entries {
if signature == *b"gTRC" || signature == *b"bTRC" {
encoded[offset + 4..offset + 12].copy_from_slice(&shared);
}
}
assert!(prepare(&encoded).is_ok());
let mut overlap = profile(false);
let first_start = word(&overlap, 136).unwrap();
overlap[148..152].copy_from_slice(&(u32::try_from(first_start).unwrap() + 4).to_be_bytes());
assert!(admit(&overlap).unwrap_err().to_string().contains("overlapping"));
}
#[test]
fn malformed_profiles_and_unsupported_semantics_refuse_before_transform() {
let original = profile(false);
for length in [0, 1, 127, 131, original.len() - 1] {
assert!(admit(&original[..length]).is_err());
}
let mut large = vec![0; MAX_ICC_BYTES + 1];
assert!(admit(&large).is_err());
large.clear();
for (offset, replacement) in [(8, vec![3]), (16, b"CMYK".to_vec()), (128, 65u32.to_be_bytes().to_vec()), (132, b"A2B0".to_vec())] {
let mut bad = original.clone();
bad[offset..offset + replacement.len()].copy_from_slice(&replacement);
assert!(admit(&bad).is_err());
}
for (offset, replacement) in [(136, u32::MAX), (140, u32::MAX), (136, 253)] {
let mut bad = original.clone();
bad[offset..offset + 4].copy_from_slice(&replacement.to_be_bytes());
assert!(admit(&bad).is_err());
}
let mut duplicate = original;
let first: [u8; 4] = duplicate[132..136].try_into().unwrap();
duplicate[144..148].copy_from_slice(&first);
assert!(admit(&duplicate).is_err());
}
#[test]
fn explicit_device_chromaticities_are_validated_without_replacing_pcs_colorants() {
let mut encoded = profile(true);
let count = word(&encoded, 128).unwrap();
let table_end = 132 + count * 12;
encoded.splice(table_end..table_end, [0; 12]);
for entry in encoded[132..table_end].as_chunks_mut::<12>().0 {
let shifted = u32::try_from(word(entry, 4).unwrap() + 12).unwrap();
entry[4..8].copy_from_slice(&shifted.to_be_bytes());
}
let start = (encoded.len() + 3) & !3;
encoded.resize(start, 0);
let mut data = b"chrm\0\0\0\0\0\x03\0\0".to_vec();
for value in [0.64_f64, 0.33, 0.3, 0.6, 0.15, 0.06] {
data.extend_from_slice(&((value * 65536.0).round() as u32).to_be_bytes());
}
encoded.extend_from_slice(&data);
let size = u32::try_from(encoded.len()).unwrap();
encoded[..4].copy_from_slice(&size.to_be_bytes());
encoded[128..132].copy_from_slice(&u32::try_from(count + 1).unwrap().to_be_bytes());
encoded[table_end..table_end + 4].copy_from_slice(b"chrm");
encoded[table_end + 4..table_end + 8].copy_from_slice(&u32::try_from(start).unwrap().to_be_bytes());
encoded[table_end + 8..table_end + 12].copy_from_slice(&36_u32.to_be_bytes());
let mut result = [0.0; 3];
prepare(&encoded).unwrap().transform(&[0.25, 0.5, 0.75], &mut result).unwrap();
assert!((result[0] - (1.055 * 0.25_f64.powf(1.0 / 2.4) - 0.055)).abs() < 3.0 / 65535.0);
for (offset, replacement) in [(start + 9, 4), (start + 11, 1), (start + 4, 1), (start + 12, 255)] {
let mut bad = encoded.clone();
bad[offset] = replacement;
assert!(admit(&bad).is_err());
}
}
}
@@ -0,0 +1,365 @@
use super::*;
use crate::texture_input::TextureSource;
use std::{borrow::Cow, cell::Cell};
fn png(info: png::Info<'static>, pixels: &[u8]) -> Vec<u8> {
let mut bytes = Vec::new();
{
let encoder = png::Encoder::with_info(&mut bytes, info).unwrap();
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(pixels).unwrap();
}
bytes
}
fn info(width: u32, height: u32, color: png::ColorType, depth: png::BitDepth) -> png::Info<'static> {
let mut info = png::Info::with_size(width, height);
info.color_type = color;
info.bit_depth = depth;
info
}
fn options() -> NormalizationOptions {
NormalizationOptions { unprofiled_color: UnprofiledColor::DeclareSrgbPrimaries, orientation: OrientationPolicy::Apply, ..Default::default() }
}
fn exif(orientation: u8) -> Vec<u8> {
// Original minimal little-endian TIFF IFD: one SHORT Orientation entry.
let mut bytes = b"II\x2a\x00\x08\x00\x00\x00\x01\x00\x12\x01\x03\x00\x01\x00\x00\x00".to_vec();
bytes.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
bytes
}
fn linear_profile() -> Vec<u8> {
let mut profile = moxcms::ColorProfile::new_srgb();
profile.cicp = None;
let text = moxcms::ProfileText::Localizable(vec![moxcms::LocalizableString::new("en".into(), "US".into(), "Original synthetic fixture".into())]);
profile.description = Some(text.clone());
profile.copyright = Some(text);
let curve = moxcms::ToneReprCurve::Parametric(vec![1.0]);
profile.red_trc = Some(curve.clone());
profile.green_trc = Some(curve.clone());
profile.blue_trc = Some(curve);
profile.encode().unwrap()
}
fn close(actual: f32, expected: f64) {
assert!((f64::from(actual) - expected).abs() < 1e-7, "{actual} != {expected}");
}
#[test]
fn sixteen_bit_data_and_alpha_are_retained_without_eight_bit_quantization() {
let values = [32768_u16, 32769, 65535, 12345];
let bytes = png(info(1, 1, png::ColorType::Rgba, png::BitDepth::Sixteen), &values.into_iter().flat_map(u16::to_be_bytes).collect::<Vec<_>>());
assert!(TextureSource::new(bytes.clone(), MapRole::Roughness).is_err(), "legacy utility must retain its refusal");
let source = NormalizationSource::new(bytes.clone(), MapRole::Roughness, NormalizationOptions::default()).unwrap();
let result = source.decode(|| false).unwrap();
for (value, channel) in values.into_iter().zip(result.rgba()) {
close(*channel, f64::from(value) / 65535.0);
}
assert!(result.rgba()[1] > result.rgba()[0]);
assert_eq!(result.receipt().source_channel_bits, 16);
assert_eq!(result.receipt().conversion, ColorConversion::RawData);
assert_eq!(result.receipt().output_encoding, "data");
assert_eq!(result.receipt().source_sha256, format!("{:x}", Sha256::digest(&bytes)));
assert_eq!(source.decode(|| false).unwrap().receipt(), result.receipt());
}
#[test]
fn declared_color_is_linearized_once_and_alpha_stays_unassociated() {
let bytes = png(info(1, 1, png::ColorType::Rgba, png::BitDepth::Eight), &[128, 64, 255, 0]);
assert!(NormalizationSource::new(bytes.clone(), MapRole::BaseColor, Default::default()).unwrap().decode(|| false).is_err());
let source = NormalizationSource::new(bytes, MapRole::BaseColor, options()).unwrap();
let result = source.decode(|| false).unwrap();
close(result.rgba()[0], 0.21586050011389926);
close(result.rgba()[1], 0.05126945837404324);
close(result.rgba()[2], 1.0);
close(result.rgba()[3], 0.0);
assert_eq!(result.receipt().conversion, ColorConversion::DeclaredSrgb);
}
#[test]
fn png_gamma_and_srgb_metadata_have_explicit_meaning_and_data_is_not_color_corrected() {
let mut metadata = info(1, 1, png::ColorType::Grayscale, png::BitDepth::Eight);
metadata.gama_chunk = Some(png::ScaledFloat::from_scaled(100_000));
metadata.source_gamma = metadata.gama_chunk;
let bytes = png(metadata.clone(), &[128]);
let color = NormalizationSource::new(bytes.clone(), MapRole::BaseColor, options()).unwrap().decode(|| false).unwrap();
close(color.rgba()[0], 128.0 / 255.0);
assert_eq!(color.receipt().conversion, ColorConversion::PngGammaSrgbPrimaries { encoding_gamma: 1.0 });
let data = NormalizationSource::new(bytes, MapRole::Metallic, Default::default()).unwrap().decode(|| false).unwrap();
close(data.rgba()[0], 128.0 / 255.0);
assert!(data.receipt().ignored_png_color_metadata);
metadata.srgb = Some(png::SrgbRenderingIntent::RelativeColorimetric);
let tagged = NormalizationSource::new(png(metadata, &[128]), MapRole::BaseColor, Default::default()).unwrap().decode(|| false).unwrap();
close(tagged.rgba()[0], 0.21586050011389926);
assert_eq!(tagged.receipt().conversion, ColorConversion::PngSrgb);
}
#[test]
fn full_image_icc_conversion_is_nonidentity_and_never_changes_alpha_or_data_semantics() {
let profile = linear_profile();
let mut metadata = info(1, 1, png::ColorType::Rgba, png::BitDepth::Eight);
metadata.icc_profile = Some(Cow::Owned(profile.clone()));
let bytes = png(metadata, &[64, 128, 192, 80]);
let source = NormalizationSource::new(bytes.clone(), MapRole::BaseColor, Default::default()).unwrap();
let result = source.decode(|| false).unwrap();
for (channel, value) in result.rgba().iter().take(3).zip([64.0, 128.0, 192.0]) {
assert!((f64::from(*channel) - value / 255.0).abs() < 8.0 / 65535.0);
}
close(result.rgba()[3], 80.0 / 255.0);
assert_eq!(result.receipt().conversion, ColorConversion::IccMatrixTrc { profile_sha256: format!("{:x}", Sha256::digest(&profile)) });
assert!(
NormalizationSource::new(bytes, MapRole::Roughness, Default::default())
.unwrap()
.decode(|| false)
.unwrap_err()
.to_string()
.contains("data maps")
);
}
#[test]
fn every_orientation_is_an_exact_pixel_permutation_and_normal_vectors_refuse_rotation() {
let cases = [
(1, [3, 2], vec![1, 2, 3, 4, 5, 6]),
(2, [3, 2], vec![3, 2, 1, 6, 5, 4]),
(3, [3, 2], vec![6, 5, 4, 3, 2, 1]),
(4, [3, 2], vec![4, 5, 6, 1, 2, 3]),
(5, [2, 3], vec![1, 4, 2, 5, 3, 6]),
(6, [2, 3], vec![4, 1, 5, 2, 6, 3]),
(7, [2, 3], vec![6, 3, 5, 2, 4, 1]),
(8, [2, 3], vec![3, 6, 2, 5, 1, 4]),
];
for (orientation, dimensions, expected) in cases {
let mut metadata = info(3, 2, png::ColorType::Grayscale, png::BitDepth::Eight);
metadata.exif_metadata = Some(Cow::Owned(exif(orientation)));
let bytes = png(metadata, &[1, 2, 3, 4, 5, 6]);
let result = NormalizationSource::new(bytes.clone(), MapRole::Roughness, options()).unwrap().decode(|| false).unwrap();
assert_eq!(result.receipt().output_dimensions, dimensions);
assert_eq!(result.receipt().source_exif_orientation, orientation);
for (pixel, value) in result.rgba().as_chunks::<4>().0.iter().zip(expected) {
close(pixel[0], f64::from(value) / 255.0);
}
if orientation != 1 {
let normal = NormalizationSource::new(bytes.clone(), MapRole::NormalOpenGl, options()).unwrap().decode(|| false).unwrap_err();
assert!(normal.to_string().contains("vector/basis"));
assert!(NormalizationSource::new(bytes, MapRole::Roughness, Default::default()).unwrap().decode(|| false).is_err());
}
}
}
#[test]
fn cancellation_after_work_does_not_publish_partial_pixels_and_source_can_be_reused() {
let bytes = png(info(2, 3, png::ColorType::Grayscale, png::BitDepth::Eight), &[1, 2, 3, 4, 5, 6]);
let source = NormalizationSource::new(bytes, MapRole::Roughness, Default::default()).unwrap();
assert!(source.decode(|| true).unwrap_err().to_string().contains("cancelled"));
for threshold in [4, 6] {
let calls = Cell::new(0);
let error = source
.decode(|| {
let count = calls.get();
calls.set(count + 1);
count >= threshold
})
.unwrap_err();
assert!(error.to_string().contains("cancelled"));
}
assert_eq!(source.decode(|| false).unwrap().rgba().len(), 24);
}
#[test]
fn retained_encoded_capacity_is_charged_to_the_working_budget() {
let mut bytes = png(info(1, 1, png::ColorType::Grayscale, png::BitDepth::Eight), &[42]);
let baseline = NormalizationSource::new(bytes.clone(), MapRole::Roughness, Default::default()).unwrap().decode(|| false).unwrap();
let budget = baseline.receipt().estimated_working_bytes;
bytes.reserve_exact(65536);
let source =
NormalizationSource::new(bytes, MapRole::Roughness, NormalizationOptions { max_working_bytes: budget, ..Default::default() }).unwrap();
assert!(source.decode(|| false).unwrap_err().to_string().contains("working budget"));
assert_eq!(baseline.receipt().normalized_bytes, 16);
}
#[test]
fn exif_without_an_orientation_uses_the_tiff_default_and_big_endian_is_supported() {
let mut metadata = info(3, 2, png::ColorType::Grayscale, png::BitDepth::Eight);
let mut missing = exif(3);
missing[10..12].copy_from_slice(&0x0100_u16.to_le_bytes()); // ImageWidth, no Orientation.
metadata.exif_metadata = Some(Cow::Owned(missing));
let result = NormalizationSource::new(png(metadata.clone(), &[1, 2, 3, 4, 5, 6]), MapRole::Roughness, Default::default())
.unwrap()
.decode(|| false)
.unwrap();
assert!(result.receipt().exif_metadata_present);
assert!(!result.receipt().exif_orientation_tag_present);
assert_eq!(result.receipt().source_exif_orientation, 1);
let big = b"MM\x00\x2a\x00\x00\x00\x08\x00\x01\x01\x12\x00\x03\x00\x00\x00\x01\x00\x06\x00\x00\x00\x00\x00\x00".to_vec();
metadata.exif_metadata = Some(Cow::Owned(big));
let result = NormalizationSource::new(png(metadata, &[1, 2, 3, 4, 5, 6]), MapRole::Roughness, options()).unwrap().decode(|| false).unwrap();
assert_eq!(result.receipt().output_dimensions, [2, 3]);
for (pixel, value) in result.rgba().as_chunks::<4>().0.iter().zip([4.0, 1.0, 5.0, 2.0, 6.0, 3.0]) {
close(pixel[0], value / 255.0);
}
}
#[test]
fn budgets_dimensions_metadata_and_truncation_refuse_without_silent_repair() {
let bytes = png(info(1, 1, png::ColorType::Grayscale, png::BitDepth::Eight), &[1]);
for budget in [0, MAX_WORKING_BYTES + 1] {
assert!(
NormalizationSource::new(bytes.clone(), MapRole::Roughness, NormalizationOptions { max_working_bytes: budget, ..Default::default() })
.is_err()
);
}
assert!(
NormalizationSource::new(bytes.clone(), MapRole::Roughness, NormalizationOptions { max_working_bytes: 1, ..Default::default() }).is_err()
);
assert!(NormalizationSource::new(bytes[..bytes.len() - 1].to_vec(), MapRole::Roughness, Default::default()).is_err());
let oversized = png(info(2049, 1, png::ColorType::Grayscale, png::BitDepth::Eight), &vec![0; 2049]);
assert!(NormalizationSource::new(oversized, MapRole::Roughness, Default::default()).unwrap().decode(|| false).is_err());
let mut metadata = info(1, 1, png::ColorType::Rgb, png::BitDepth::Eight);
metadata.exif_metadata = Some(Cow::Owned(vec![0, 1, 2]));
assert!(NormalizationSource::new(png(metadata, &[1, 2, 3]), MapRole::Roughness, options()).unwrap().decode(|| false).is_err());
let mut metadata = info(1, 1, png::ColorType::Rgb, png::BitDepth::Eight);
metadata.chrm_chunk = Some(png::SourceChromaticities::new((0.3127, 0.3290), (0.64, 0.33), (0.21, 0.71), (0.15, 0.06)));
metadata.source_chromaticities = metadata.chrm_chunk;
assert!(NormalizationSource::new(png(metadata, &[1, 2, 3]), MapRole::BaseColor, options()).unwrap().decode(|| false).is_err());
}
#[test]
fn jpeg_decode_records_explicit_eight_bit_precision_and_linearization() {
use image::ImageEncoder;
let mut bytes = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 100).write_image(&[128, 128, 128], 1, 1, image::ExtendedColorType::Rgb8).unwrap();
let result = NormalizationSource::new(bytes, MapRole::BaseColor, options()).unwrap().decode(|| false).unwrap();
assert_eq!(result.receipt().format, "jpeg");
assert_eq!(result.receipt().source_channel_bits, 8);
assert_eq!(result.receipt().output_dimensions, [1, 1]);
assert!((f64::from(result.rgba()[0]) - 0.21586050011389926).abs() < 0.01, "JPEG is a lossy input codec");
}
#[test]
fn indexed_transparency_expands_without_premultiplying_color() {
let mut metadata = info(2, 1, png::ColorType::Indexed, png::BitDepth::Eight);
metadata.palette = Some(Cow::Owned(vec![255, 128, 64, 10, 20, 30]));
metadata.trns = Some(Cow::Owned(vec![0, 128]));
let result = NormalizationSource::new(png(metadata, &[0, 1]), MapRole::Roughness, Default::default()).unwrap().decode(|| false).unwrap();
for (actual, expected) in result.rgba().iter().zip([255.0, 128.0, 64.0, 0.0, 10.0, 20.0, 30.0, 128.0]) {
close(*actual, expected / 255.0);
}
}
#[test]
fn out_of_gamut_icc_requires_explicit_preview_clipping_and_records_loss() {
let mut profile = moxcms::ColorProfile::new_from_slice(&linear_profile()).unwrap();
profile.red_colorant.x *= 2.0; // Original synthetic stress matrix, not a measured material.
let mut metadata = info(1, 1, png::ColorType::Rgb, png::BitDepth::Eight);
metadata.icc_profile = Some(Cow::Owned(profile.encode().unwrap()));
let bytes = png(metadata, &[255, 0, 0]);
let error = NormalizationSource::new(bytes.clone(), MapRole::BaseColor, Default::default()).unwrap().decode(|| false).unwrap_err();
assert!(error.to_string().contains("gamut"), "{error}");
let result = NormalizationSource::new(bytes, MapRole::BaseColor, NormalizationOptions { gamut: GamutPolicy::Clip, ..Default::default() })
.unwrap()
.decode(|| false)
.unwrap();
assert!(result.receipt().clamped_rgb_channels > 0);
assert_eq!(result.receipt().gamut_policy, GamutPolicy::Clip);
assert!(result.rgba().iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)));
}
fn with_chunk(kind: [u8; 4], data: &[u8], late: bool) -> Vec<u8> {
let mut bytes = Vec::new();
{
let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
encoder.set_color(png::ColorType::Rgb);
let mut writer = encoder.write_header().unwrap();
if !late {
writer.write_chunk(png::chunk::ChunkType(kind), data).unwrap();
}
writer.write_image_data(&[20, 40, 60]).unwrap();
if late {
writer.write_chunk(png::chunk::ChunkType(kind), data).unwrap();
}
}
bytes
}
#[test]
fn malformed_or_late_semantic_chunks_never_fall_back_to_untagged_color() {
for (kind, data, late) in [
(*b"iCCP", vec![0, 0, 1, 2, 3], false),
(*b"sRGB", vec![255], false),
(*b"gAMA", vec![0], false),
(*b"eXIf", exif(6), true),
(*b"gAMA", 100_000_u32.to_be_bytes().to_vec(), true),
] {
let source = NormalizationSource::new(with_chunk(kind, &data, late), MapRole::BaseColor, options()).unwrap();
assert!(source.decode(|| false).is_err(), "silently accepted {kind:?}");
}
// Unknown ancillary metadata is left to the established codec, not blanket-refused.
assert!(NormalizationSource::new(with_chunk(*b"teSt", &[42], false), MapRole::BaseColor, options()).unwrap().decode(|| false).is_ok());
let mut bad_crc = with_chunk(*b"gAMA", &100_000_u32.to_be_bytes(), false);
let index = bad_crc.windows(4).position(|v| v == b"gAMA").unwrap() + 8;
bad_crc[index] ^= 1;
assert!(NormalizationSource::new(bad_crc, MapRole::BaseColor, options()).unwrap().decode(|| false).is_err());
}
#[test]
fn duplicate_conflicting_and_excessive_metadata_refuses() {
let mut bytes = with_chunk(*b"sRGB", &[0], false);
let srgb = bytes[33..46].to_vec(); // Original fixture's 13-byte sRGB chunk.
bytes.splice(33..33, srgb);
assert!(NormalizationSource::new(bytes, MapRole::BaseColor, options()).unwrap().decode(|| false).is_err());
let mut metadata = info(1, 1, png::ColorType::Rgb, png::BitDepth::Eight);
metadata.icc_profile = Some(Cow::Owned(linear_profile()));
let mut bytes = png(metadata, &[20, 40, 60]);
let srgb = with_chunk(*b"sRGB", &[0], false)[33..46].to_vec();
bytes.splice(33..33, srgb);
assert!(NormalizationSource::new(bytes, MapRole::BaseColor, options()).unwrap().decode(|| false).is_err());
let mut bytes = with_chunk(*b"teSt", &[42], false);
let chunk = bytes[33..46].to_vec();
bytes.splice(33..33, chunk.repeat(4096));
assert!(NormalizationSource::new(bytes, MapRole::BaseColor, options()).unwrap().decode(|| false).unwrap_err().to_string().contains("4096"));
}
#[test]
fn invalid_exif_values_types_and_offsets_refuse() {
for (offset, value) in [(18, 0), (18, 9), (12, 4), (14, 2), (4, 7)] {
let mut data = exif(1);
data[offset] = value;
let bytes = with_chunk(*b"eXIf", &data, false);
assert!(NormalizationSource::new(bytes, MapRole::Roughness, options()).unwrap().decode(|| false).is_err());
}
}
#[test]
fn differing_icc_channel_curves_refuse_instead_of_using_a_same_curve_optimization() {
let mut profile = moxcms::ColorProfile::new_from_slice(&linear_profile()).unwrap();
profile.blue_trc = Some(moxcms::ToneReprCurve::Parametric(vec![2.0]));
let mut metadata = info(1, 1, png::ColorType::Rgb, png::BitDepth::Eight);
metadata.icc_profile = Some(Cow::Owned(profile.encode().unwrap()));
let error =
NormalizationSource::new(png(metadata, &[128, 128, 128]), MapRole::BaseColor, Default::default()).unwrap().decode(|| false).unwrap_err();
assert!(error.to_string().contains("identical RGB tone curves"));
}
#[test]
fn jpeg_icc_conversion_and_metadata_presence_are_checked_without_fallback() {
use image::ImageEncoder;
let mut bytes = Vec::new();
{
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 100);
encoder.set_icc_profile(linear_profile()).unwrap();
encoder.write_image(&[128, 128, 128], 1, 1, image::ExtendedColorType::Rgb8).unwrap();
}
let result = NormalizationSource::new(bytes.clone(), MapRole::BaseColor, Default::default()).unwrap().decode(|| false).unwrap();
assert!((result.rgba()[0] - 128.0 / 255.0).abs() < 0.01, "linear ICC must differ from sRGB interpretation");
let position = bytes.windows(12).position(|v| v == b"ICC_PROFILE\0").unwrap();
let mut malformed = bytes.clone();
malformed[position + 12] = 0; // Illegal sequence number makes the codec return None.
assert!(NormalizationSource::new(malformed, MapRole::BaseColor, options()).unwrap().decode(|| false).unwrap_err().to_string().contains("retain"));
let mut duplicate_exif = bytes.clone();
let exif = [b"Exif\0\0".as_slice(), &exif(1)].concat();
let segment = [vec![255, 225], u16::try_from(exif.len() + 2).unwrap().to_be_bytes().to_vec(), exif].concat();
duplicate_exif.splice(2..2, segment.repeat(2));
assert!(NormalizationSource::new(duplicate_exif, MapRole::BaseColor, options()).unwrap().decode(|| false).is_err());
let mut late = bytes;
let end = late.len() - 2;
late.splice(end..end, segment);
assert!(NormalizationSource::new(late, MapRole::BaseColor, options()).unwrap().decode(|| false).unwrap_err().to_string().contains("late"));
}