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

Generated
+2
View File
@@ -3590,9 +3590,11 @@ name = "solidcraft-render"
version = "0.12.0"
dependencies = [
"image",
"moxcms",
"png",
"serde",
"serde_json",
"sha2",
"solidcraft-geom",
]
+1
View File
@@ -36,6 +36,7 @@ syn = { version = "2", features = ["full"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
sha2 = "0.10"
moxcms = { version = "0.8.1", default-features = false, features = ["extended_range"] }
semver = "1"
# geometry kernel (Apache-2.0; see plan/adr/0001-geometry-kernel.md). Only crates/kernel uses it.
+6
View File
@@ -103,6 +103,12 @@ retain their saved two-finger scroll preference; camera motion is available thro
## Render foundation
- Explicit texture normalization (#1218) produces completed float RGBA with
role-aware linear color/data semantics, bounded matrix/TRC ICC conversion and
opt-in EXIF orientation. It retains high-precision raw samples and records lossy
gamut clipping only when explicitly requested. It is not a renderer/resource
consumer; see [the supported policy and limits](docs/render-texture-normalization.md).
- Opt-in opaque PBR (#1046) connects persisted surface parameters to the integrated
product viewport and CPU PNG, using a fixed direct-light studio. Geometry and mass
stay unchanged. HDRI, transmission and textures remain separate gates.
+2
View File
@@ -12,7 +12,9 @@ workspace = true
solidcraft-geom = { workspace = true }
serde = { workspace = true }
image = { workspace = true, features = ["jpeg"] }
moxcms = { workspace = true }
png = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+23 -17
View File
@@ -4,6 +4,8 @@ use image::{ImageDecoder, ImageFormat, ImageReader, Limits};
use serde::{Deserialize, Serialize};
use std::{io::Cursor, sync::Arc};
pub mod normalization;
pub const MAX_TEXTURE_BYTES: usize = 16 * 1024 * 1024;
pub const MAX_TEXTURE_EDGE: u32 = 2048;
const MAX_CODEC_BYTES: u64 = 64 * 1024 * 1024;
@@ -71,25 +73,29 @@ fn reader(bytes: &[u8], format: ImageFormat) -> ImageReader<Cursor<&[u8]>> {
fn guarded<T>(work: impl FnOnce() -> Result<T>) -> Result<T> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).map_err(|_| invalid("texture decoder failed"))?
}
fn admitted_format(bytes: &[u8], capacity: usize) -> Result<ImageFormat> {
if bytes.is_empty() || bytes.len() > MAX_TEXTURE_BYTES || capacity > MAX_TEXTURE_BYTES {
return Err(invalid("texture input must contain at most 16 MiB"));
}
let format = image::guess_format(bytes).map_err(|_| invalid("texture must be PNG or JPEG"))?;
if !matches!(format, ImageFormat::Png | ImageFormat::Jpeg) {
return Err(invalid("texture must be PNG or JPEG; HDR uses the Radiance input utility"));
}
// Whole-image codecs may return pixels before noticing a missing trailer. This
// marker check rejects that truncation; it is not a general container validator.
let complete = match format {
ImageFormat::Png => bytes.ends_with(b"\x00\x00\x00\x00IEND\xae\x42\x60\x82"),
ImageFormat::Jpeg => bytes.ends_with(b"\xff\xd9"),
_ => false,
};
if !complete {
return Err(invalid("texture is missing its final terminator"));
}
Ok(format)
}
impl TextureSource {
pub fn new(bytes: Vec<u8>, role: MapRole) -> Result<Self> {
if bytes.is_empty() || bytes.len() > MAX_TEXTURE_BYTES || bytes.capacity() > MAX_TEXTURE_BYTES {
return Err(invalid("texture input must contain at most 16 MiB"));
}
let format = image::guess_format(&bytes).map_err(|_| invalid("texture must be PNG or JPEG"))?;
if !matches!(format, ImageFormat::Png | ImageFormat::Jpeg) {
return Err(invalid("texture must be PNG or JPEG; HDR uses the Radiance input utility"));
}
// Whole-image codecs may return pixels before noticing a missing trailer. This
// marker check rejects that truncation; it is not a general container validator.
let complete = match format {
ImageFormat::Png => bytes.ends_with(b"\x00\x00\x00\x00IEND\xae\x42\x60\x82"),
ImageFormat::Jpeg => bytes.ends_with(b"\xff\xd9"),
_ => false,
};
if !complete {
return Err(invalid("texture is missing its final terminator"));
}
let format = admitted_format(&bytes, bytes.capacity())?;
let (width, height) = guarded(|| {
let mut decoder = reader(&bytes, format).into_decoder().map_err(|e| invalid(e.to_string()))?;
if !matches!(decoder.color_type(), image::ColorType::L8 | image::ColorType::La8 | image::ColorType::Rgb8 | image::ColorType::Rgba8) {
@@ -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"));
}
+4
View File
@@ -1,5 +1,9 @@
# Bounded texture input (resource admission)
The separate [explicit normalization API](render-texture-normalization.md) handles
admitted high-precision/color/orientation conversions. The strict utility described
below retains its existing refusals; normalization does not enable texture rendering.
`solidcraft_render::texture_input` is an independent input utility for #900, coordinated
with #792 and #813. It accepts in-memory PNG/JPEG bytes and supplies completed RGBA8
pixels. It does not change a document, read files, assign materials, add a resource-pack
+132
View File
@@ -0,0 +1,132 @@
# Explicit texture normalization (#1218)
`texture_input::normalization` is a pure-Rust, in-memory input utility, separate
from the strict RGBA8 [texture admission API](render-texture-input.md). It does not
install a renderer, command, resource format, document property or GPU texture.
The old API still refuses 16-bit samples, ICC and nonidentity EXIF orientation.
## Input, policy and completed output
`NormalizationSource::new(bytes, role, options)` retains immutable encoded storage
and admits format/storage/budget only. `decode(cancelled)` admits metadata and
completes normalization before returning an immutable `NormalizedTexture`.
Header admission is not successful decoding. No partial result is returned.
- PNG channels expand to RGB/gray with alpha at their original 8/16-bit sample
depth; palette/sub-byte expansion uses the existing PNG codec. Output is RGBA
f32, without an implicit RGBA8 preview. This preserves distinguishable adjacent
16-bit raw samples; ICC conversion and floating-point arithmetic are not lossless.
- JPEG is initially 8-bit grayscale or three-component sequential/progressive DCT;
four-component/CMYK, arithmetic and lossless JPEG are outside this policy.
JPEG decoding is lossy irrespective of the output precision.
- Base color returns **linear sRGB** RGB and unassociated normalized alpha. A
renderer must not apply sRGB decoding again. Alpha never receives a color transform.
- Roughness, metallic, AO and OpenGL normal channels remain normalized **data**.
They receive no gamma correction, ICC conversion, normal renormalization, channel
convention conversion or filename inference. ICC on a data map refuses.
- Untagged color refuses by default. `DeclareSrgbPrimaries` is an explicit caller
declaration, not source certification: it uses PNG gAMA when present (encoding
gamma in [0.1, 10]), otherwise the sRGB transfer function. Non-sRGB cHRM refuses.
An explicit sRGB chunk selects sRGB transfer; ICC plus sRGB refuses.
- PNG HDR/CICP metadata and animation refuse for base color; HDR environments use
the separate [Radiance input utility](render-hdr-input.md). Data roles record
ignored PNG color metadata, rather than applying it to data channels.
- EXIF uses classic TIFF IFD0 only: either byte order, at most 128 entries,
one SHORT Orientation in 1..8. Missing Orientation uses TIFF's top-left default,
with distinct receipt flags for absent EXIF and absent Orientation. Other EXIF
fields/thumbnail IFDs are not interpreted or certified. Malformed admission refuses.
Nonidentity orientation requires `Apply`; all eight operations are exact pixel
permutations, without interpolation. Oriented normal maps refuse until a vector
and tangent-basis conversion contract exists.
## ICC scope and measured limitations
The already locked moxcms 0.8.1 is now a direct dependency (BSD-3-Clause OR
Apache-2.0). PNG 0.18.1 and SHA-256 were also already in the lockfile. No dependency
version, copied source, external image/profile, commercial asset or material dataset
is added. Fixtures are generated from original synthetic values in test code.
Initial ICC admission supports version 2/4 RGB input/display matrix/TRC profiles
with XYZ PCS and **identical RGB tone curves**. Required colorant/white-point/curve
tags are checked; CLUT and unknown conversion tags refuse. Limits: 256 KiB profile,
64 tags, 64 KiB per tag and 4096 sampled curve values. Offsets are four-byte aligned;
exact shared ranges are allowed, partial overlaps and duplicate signatures refuse.
The optional informational `chrm` tag initially admits three explicit device xy
channels (type 0, bounded xy values); PCS colorants still define the conversion.
This is bounded supported-profile admission, not a complete ICC conformance checker.
Conversion uses relative colorimetric intent, no source CICP override or fixed-point
preference, and **extended range**. Testing found the default destination LUT clips
before loss can be counted. Extended range preserves excursions until our policy.
f64 rows retain the CMS's 16-bit source LUT where analytic evaluation is unavailable;
the library still uses f32 matrix/curve arithmetic internally. No universal accuracy
or full-f64 colorimetry is claimed. Unequal RGB curves refuse because the pinned
same-curve optimization can treat two matching curves as three matching curves.
`GamutPolicy::Refuse` rejects encoded destination RGB outside [0, 1], allowing an
explicit edge tolerance of 3/65535 for finite-precision roundoff. `Clip` is an
explicit lossy preview policy. Every clamped RGB channel, including edge-tolerance
clamps, is counted. Nonfinite values always refuse. Output RGB is then linearized.
## Resource and provenance boundaries
- Encoded length **and capacity**: at most 16 MiB. Dimensions: at most 2048 per edge.
Raw decoded storage: at most 32 MiB. Completed float RGBA: at most 64 MiB.
- Maximum working admission: 256 MiB, charging retained encoded capacity, 24 bytes
per pixel for raw/output allowances, a 64 MiB codec reservation, 16 MiB conservative
CMS workspace, two 256 KiB profile allowances and two f64 RGB rows. The constructor
requires encoded capacity plus codec reservation before header work. Codec defaults
outside our control, allocator overhead and other jobs mean this is **not an RSS,
CPU-time or combined GPU/CPU ceiling**. Owned large buffers use fallible allocation.
- Framing/presence checks cap PNG chunks and JPEG marker segments at 4096, refuse
duplicate/late semantic metadata and require the codec to retain declared ICC/EXIF
information. JPEG scan stuffing/restarts are skipped without entropy decoding.
Existing codecs remain responsible for pixels and format validation; no second
compression decoder is introduced. PNG is fully finished before publication.
- Cancellation is checked before/after whole-image decode/profile setup, per
normalization row and during hashing. It cannot interrupt codec/profile construction.
The host must schedule this work away from the interactive UI thread. Worker ownership,
document generations, stale-result rejection, pooling and browser scheduling remain
mandatory consumer work; an immutable completed object does not implement those jobs.
- A read-only serializable receipt records recipe version 1, source and normalized
SHA-256 (little-endian f32 sample bytes), role, precision, dimensions, color conversion,
ICC identity, orientation operations/presence, chosen policies, clamp count, storage
and working estimate. It includes no paths or license approval. Hashes identify bytes,
not publishers. The receipt is diagnostic, not a persistent resource-pack schema or
permission to redistribute images/profiles. Per-file provenance and licensing remain
the host/library's responsibility.
Tests use independent analytic sRGB/linear expectations, adjacent 16-bit samples,
alpha/palette data, original ICC stress profiles, all eight orientations, malformed
and ignored metadata, budgets and cancellation. This utility alone cannot demonstrate
wood shading, material assignment, PBR/HDRI, GPU behavior or solver properties.
## Individually licensed file probe (2026-10-15)
The same [Black Oak Veneer](https://polyhaven.com/a/black_oak_veneer) 1K diffuse
files that the legacy utility refused now complete normalization with their actual
embedded ICC profile. Author: Jenelle van Heerden; asset license:
[Poly Haven CC0](https://polyhaven.com/license), verified at retrieval. Provider API
calls identified SolidCraft; this is an offline validation probe, not live API integration.
Files, generated output and probe harness are not bundled or committed.
| Input | Source SHA-256 | Completed result |
|---|---|---|
| PNG, 5,262,864 bytes | `3303ebadba1277488e43b0936b27743525711985db0da62ac9f989f695f6065c` | 16-bit source, 1024x1024 linear-sRGB RGBA f32, 16,777,216 output bytes, zero clamped channels |
| JPEG, 580,236 bytes | `7893f9bfeda626364f0a803dea6327c9f3a7b2f2fe330f6b4f326f6b06c0c265` | 8-bit source, same dimensions/encoding/storage, zero clamped channels |
Both receipts identify ICC SHA-256
`500fa2f01de5a0e051ce3b4111b0c94aa223cda0f4ccfbc1c631adc30a050616`.
The first probe refused its optional `chrm` tag. Support was added from ICC.1
sections 9.2.16/10.2, with an original valid/malformed fixture; no profile was stripped
or source primaries guessed. EXIF is present without Orientation, so TIFF's identity
default applies. PNG/JPEG output hashes differ as expected for different input bytes
and JPEG loss; these are completed input results, not wood rendering evidence.
Local receipts retain download URL/license/retrieval metadata and provider MD5 checks.
Any future shipped file needs its own ATTRIBUTION.md entry and reviewed provenance.
Public format references: [PNG third edition](https://www.w3.org/TR/png-3/),
[TIFF 6](https://www.itu.int/itudoc/itu-t/com16/tiff-fx/docs/tiff6.pdf),
[ICC.1](https://www.color.org/specification/ICC1v43_2010-12.pdf),
[ICC embedding note](https://www.color.org/technotes/ICC-Technote-ProfileEmbedding.pdf)
and [JPEG T.81](https://www.w3.org/Graphics/JPEG/itu-t81.pdf).