From 1e984c8e46f0938639fb2c33e00ebaafb7614c24 Mon Sep 17 00:00:00 2001 From: selune <35337584+0xlillith@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:55:35 +0200 Subject: [PATCH] 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> --- Cargo.lock | 2 + Cargo.toml | 1 + ROADMAP.md | 6 + crates/render/Cargo.toml | 2 + crates/render/src/texture_input.rs | 40 +- .../render/src/texture_input/normalization.rs | 307 +++++++++++++++ .../src/texture_input/normalization/decode.rs | 326 ++++++++++++++++ .../normalization/decode/jpeg_markers.rs | 75 ++++ .../normalization/decode/png_markers.rs | 66 ++++ .../src/texture_input/normalization/icc.rs | 307 +++++++++++++++ .../src/texture_input/normalization/tests.rs | 365 ++++++++++++++++++ docs/render-texture-input.md | 4 + docs/render-texture-normalization.md | 132 +++++++ 13 files changed, 1616 insertions(+), 17 deletions(-) create mode 100644 crates/render/src/texture_input/normalization.rs create mode 100644 crates/render/src/texture_input/normalization/decode.rs create mode 100644 crates/render/src/texture_input/normalization/decode/jpeg_markers.rs create mode 100644 crates/render/src/texture_input/normalization/decode/png_markers.rs create mode 100644 crates/render/src/texture_input/normalization/icc.rs create mode 100644 crates/render/src/texture_input/normalization/tests.rs create mode 100644 docs/render-texture-normalization.md diff --git a/Cargo.lock b/Cargo.lock index e1e31115..d8d552ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3590,9 +3590,11 @@ name = "solidcraft-render" version = "0.12.0" dependencies = [ "image", + "moxcms", "png", "serde", "serde_json", + "sha2", "solidcraft-geom", ] diff --git a/Cargo.toml b/Cargo.toml index 73c703a8..c3af526a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index 21a2cf07..ef549430 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 999d90af..8e02c73f 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -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 } diff --git a/crates/render/src/texture_input.rs b/crates/render/src/texture_input.rs index 034f83d5..27f99870 100644 --- a/crates/render/src/texture_input.rs +++ b/crates/render/src/texture_input.rs @@ -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> { fn guarded(work: impl FnOnce() -> Result) -> Result { std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).map_err(|_| invalid("texture decoder failed"))? } +fn admitted_format(bytes: &[u8], capacity: usize) -> Result { + 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, role: MapRole) -> Result { - 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) { diff --git a/crates/render/src/texture_input/normalization.rs b/crates/render/src/texture_input/normalization.rs new file mode 100644 index 00000000..4dcee595 --- /dev/null +++ b/crates/render/src/texture_input/normalization.rs @@ -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>, + format: ImageFormat, + role: MapRole, + options: NormalizationOptions, +} +impl NormalizationSource { + pub fn new(bytes: Vec, role: MapRole, options: NormalizationOptions) -> Result { + 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 { + 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::(sample_count)?; + let row_channels = usize::try_from(u64::from(width) * 3).map_err(|_| invalid("normalization row overflow"))?; + let mut input = zeroed::(row_channels)?; + let mut converted = zeroed::(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, +} +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(length: usize) -> Result> { + 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 { + 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 { + 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; diff --git a/crates/render/src/texture_input/normalization/decode.rs b/crates/render/src/texture_input/normalization/decode.rs new file mode 100644 index 00000000..834539ac --- /dev/null +++ b/crates/render/src/texture_input/normalization/decode.rs @@ -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>, + orientation: Orientation, + exif_present: bool, + orientation_tag_present: bool, + srgb: bool, + gamma: Option, + 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>, + pub estimated_working_bytes: usize, + bits: u8, + channels: usize, + pixels: Vec, + srgb: bool, + gamma: Option, + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::(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 { + 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 { + 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::(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 { + 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::(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, + }) +} diff --git a/crates/render/src/texture_input/normalization/decode/jpeg_markers.rs b/crates/render/src/texture_input/normalization/decode/jpeg_markers.rs new file mode 100644 index 00000000..be9e3d2a --- /dev/null +++ b/crates/render/src/texture_input/normalization/decode/jpeg_markers.rs @@ -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")) +} diff --git a/crates/render/src/texture_input/normalization/decode/png_markers.rs b/crates/render/src/texture_input/normalization/decode/png_markers.rs new file mode 100644 index 00000000..d163a0d1 --- /dev/null +++ b/crates/render/src/texture_input/normalization/decode/png_markers.rs @@ -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 { + 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")) +} diff --git a/crates/render/src/texture_input/normalization/icc.rs b/crates/render/src/texture_input/normalization/icc.rs new file mode 100644 index 00000000..d0ebb1a5 --- /dev/null +++ b/crates/render/src/texture_input/normalization/icc.rs @@ -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 { + 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 = 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> { + 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 { + 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()); + } + } +} diff --git a/crates/render/src/texture_input/normalization/tests.rs b/crates/render/src/texture_input/normalization/tests.rs new file mode 100644 index 00000000..88a7d374 --- /dev/null +++ b/crates/render/src/texture_input/normalization/tests.rs @@ -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 { + 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 { + // 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 { + 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::>()); + 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 { + 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")); +} diff --git a/docs/render-texture-input.md b/docs/render-texture-input.md index c36516e6..de66fa3e 100644 --- a/docs/render-texture-input.md +++ b/docs/render-texture-input.md @@ -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 diff --git a/docs/render-texture-normalization.md b/docs/render-texture-normalization.md new file mode 100644 index 00000000..0f23a094 --- /dev/null +++ b/docs/render-texture-normalization.md @@ -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).