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:
1 parent
a47e82ba94
commit
1e984c8e46
13 files changed
+1616
-17
No files matched your search
Generated
+2
@@ -3590,9 +3590,11 @@ name = "solidcraft-render"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
"png",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"solidcraft-geom",
|
||||
]
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
Reference in new issue
Block a user