Merge agent-codecs: lightcraft-codecs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 11:46:03 -04:00
commit 1c073db30f
18 changed files with 4307 additions and 2 deletions

1035
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -41,6 +41,16 @@ miniz_oxide = "0.8"
# codecs
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff", "webp"] }
zune-jpeg = { version = "0.5", default-features = false, features = ["std", "x86", "neon"] }
zune-core = { version = "0.5", default-features = false, features = ["std"] }
jpeg-decoder = { version = "0.3", default-features = false }
jpeg-encoder = { version = "0.7", default-features = false, features = ["std"] }
png = "0.18"
tiff = "0.11"
image-webp = "0.2"
moxcms = { version = "0.9", default-features = false, features = ["lut"] }
jxl-oxide = { version = "0.12", default-features = false }
ravif = { version = "0.13", default-features = false }
# ui
egui = "0.36"

41
crates/codecs/Cargo.toml Normal file
View File

@ -0,0 +1,41 @@
[package]
name = "lightcraft-codecs"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "LightCraft standard image codecs: sniffing, decode to linear light, ICC, encoders (L1)"
[lints]
workspace = true
[dependencies]
lightcraft-geom = { workspace = true }
lightcraft-color = { workspace = true }
lightcraft-raster = { workspace = true }
serde = { workspace = true }
bytemuck = { workspace = true }
thiserror = { workspace = true }
rayon = { workspace = true, optional = true }
image = { workspace = true, features = ["gif", "bmp"] }
zune-jpeg = { workspace = true }
zune-core = { workspace = true }
jpeg-decoder = { workspace = true }
jpeg-encoder = { workspace = true }
png = { workspace = true }
tiff = { workspace = true }
image-webp = { workspace = true }
moxcms = { workspace = true }
jxl-oxide = { workspace = true, optional = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
ravif = { workspace = true, optional = true }
[features]
default = ["parallel", "jxl", "avif"]
parallel = ["dep:rayon", "lightcraft-raster/parallel", "jpeg-decoder/rayon"]
jxl = ["dep:jxl-oxide"]
## AVIF encoding via ravif/rav1e (pure Rust, BSD). Native targets only; a no-op on wasm32.
avif = ["dep:ravif"]
[dev-dependencies]
proptest = { workspace = true }

View File

@ -0,0 +1,334 @@
//! Shared tail of every decoder: interpret device samples (with ICC / container hints), linearize,
//! split alpha, and optionally downscale in linear light.
use crate::icc::{self, IccColorModel, IccKind};
use crate::space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
use crate::{DecodeOptions, Decoded, Error, Format, Result, exif};
use lightcraft_raster::resample::{Filter, fit};
use lightcraft_raster::{Plane, Rgb32f, par_rows};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Model {
Gray,
Rgb,
/// Subtractive CMYK, 0 = no ink.
Cmyk,
}
impl Model {
pub fn channels(self) -> usize {
match self {
Model::Gray => 1,
Model::Rgb => 3,
Model::Cmyk => 4,
}
}
}
pub(crate) enum Buf {
U8(Vec<u8>),
U16(Vec<u16>),
F32(Vec<f32>),
}
impl Buf {
fn len(&self) -> usize {
match self {
Buf::U8(v) => v.len(),
Buf::U16(v) => v.len(),
Buf::F32(v) => v.len(),
}
}
#[inline]
fn norm(&self, i: usize) -> f32 {
match self {
Buf::U8(v) => v[i] as f32 / 255.0,
Buf::U16(v) => v[i] as f32 / 65535.0,
Buf::F32(v) => v[i],
}
}
}
/// Interleaved device samples as produced by a format decoder.
pub(crate) struct Raw {
pub width: usize,
pub height: usize,
pub model: Model,
/// One extra (last) channel of alpha.
pub alpha: bool,
/// Alpha is premultiplied into the colour channels.
pub premultiplied: bool,
pub buf: Buf,
pub bit_depth: u8,
}
/// Metadata and colour hints gathered from the container.
#[derive(Default)]
pub(crate) struct Meta {
pub icc: Option<Vec<u8>>,
pub exif: Option<Vec<u8>>,
pub xmp: Option<String>,
/// Orientation from the container itself (e.g. TIFF tag); EXIF blob is consulted otherwise.
pub orientation: Option<u16>,
/// Container-level colour description (PNG cHRM/gAMA/sRGB/cICP, JXL enum encoding, …).
pub hint: Option<SourceSpace>,
/// `image` already went through a CMS/transform producing this space (skip interpretation).
pub already_linear: Option<SourceSpace>,
}
pub(crate) fn check_size(format: Format, w: u64, h: u64, opts: &DecodeOptions) -> Result<()> {
if w == 0 || h == 0 {
return Err(Error::Malformed(format, "zero dimension".into()));
}
if w.saturating_mul(h) > opts.max_pixels {
return Err(Error::TooLarge(w, h));
}
Ok(())
}
pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u32), opts: &DecodeOptions) -> Result<Decoded> {
let n = raw.width * raw.height;
let ch = raw.model.channels() + raw.alpha as usize;
if n == 0 || raw.buf.len() < n * ch {
return Err(Error::Malformed(format, "sample buffer too short".into()));
}
let float = matches!(raw.buf, Buf::F32(_));
// Alpha plane (straight).
let alpha = raw.alpha.then(|| {
let mut a = Plane::new(raw.width, raw.height);
for (i, o) in a.data.iter_mut().enumerate() {
*o = raw.buf.norm(i * ch + ch - 1).clamp(0.0, 1.0);
}
a
});
let parsed_icc = meta.icc.as_deref().and_then(icc::parse);
let (image, space) = if let Some(space) = meta.already_linear.clone() {
(to_rgb_passthrough(&raw, ch), space)
} else if raw.model == Model::Cmyk {
cmyk(&raw, ch, meta.icc.as_deref(), parsed_icc.as_ref())
} else {
rgb_or_gray(&raw, ch, &meta, parsed_icc.as_ref(), float)
};
let mut image = image;
if raw.premultiplied
&& let Some(a) = &alpha
{
for (p, &av) in image.data.iter_mut().zip(&a.data) {
if av > 0.0 {
*p = p.map(|c| c / av);
}
}
}
let summary = meta.exif.as_deref().map(exif::summarize).unwrap_or_default();
let orientation = meta.orientation.or(summary.orientation).filter(|o| (1..=8).contains(o)).unwrap_or(1);
let (image, alpha) = match opts.max_size {
Some((mw, mh)) if (image.width as u32 > mw || image.height as u32 > mh) && mw > 0 && mh > 0 => {
let factor = (image.width as f64 / mw as f64).max(image.height as f64 / mh as f64);
let f = if factor >= 2.0 { Filter::Box } else { Filter::Mitchell };
let img = fit(&image, mw as usize, mh as usize, f);
let alpha = alpha.map(|a| fit(&a, mw as usize, mh as usize, f));
(img, alpha)
}
_ => (image, alpha),
};
Ok(Decoded {
format,
width: image.width as u32,
height: image.height as u32,
image,
has_alpha: alpha.is_some(),
alpha,
space,
bit_depth: raw.bit_depth,
float,
grayscale: raw.model == Model::Gray,
cmyk: raw.model == Model::Cmyk,
orientation,
exif: meta.exif,
xmp: meta.xmp,
icc: meta.icc,
source_width: source_dims.0,
source_height: source_dims.1,
})
}
fn to_rgb_passthrough(raw: &Raw, ch: usize) -> Rgb32f {
let gray = raw.model == Model::Gray;
let mut img = Rgb32f::new(raw.width, raw.height);
par_rows(&mut img.data, raw.width, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = (y * raw.width + x) * ch;
*o = if gray {
let v = raw.buf.norm(i);
[v, v, v]
} else {
[raw.buf.norm(i), raw.buf.norm(i + 1), raw.buf.norm(i + 2)]
};
}
});
img
}
/// Per-channel linearization via LUTs (8/16-bit) or direct evaluation (float).
fn linearize(raw: &Raw, ch: usize, trc: &[Trc; 3]) -> Rgb32f {
let gray = raw.model == Model::Gray;
let mut img = Rgb32f::new(raw.width, raw.height);
let w = raw.width;
match &raw.buf {
Buf::U8(v) => {
let luts: Vec<Vec<f32>> = trc.iter().map(|t| t.lut8()).collect();
par_rows(&mut img.data, w, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = (y * w + x) * ch;
*o = if gray {
let l = luts[1][v[i] as usize];
[l, l, l]
} else {
[luts[0][v[i] as usize], luts[1][v[i + 1] as usize], luts[2][v[i + 2] as usize]]
};
}
});
}
Buf::U16(v) => {
let same = trc[0] == trc[1] && trc[1] == trc[2];
let l0 = trc[1].lut16();
let (l1, l2) = if same || gray { (None, None) } else { (Some(trc[0].lut16()), Some(trc[2].lut16())) };
let lr = l1.as_ref().unwrap_or(&l0);
let lb = l2.as_ref().unwrap_or(&l0);
par_rows(&mut img.data, w, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = (y * w + x) * ch;
*o = if gray {
let l = l0[v[i] as usize];
[l, l, l]
} else {
[lr[v[i] as usize], l0[v[i + 1] as usize], lb[v[i + 2] as usize]]
};
}
});
}
Buf::F32(v) => {
let lin = trc.iter().all(|t| t.is_linear());
par_rows(&mut img.data, w, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = (y * w + x) * ch;
let s = |c: usize| {
let val = v[i + c];
if val.is_finite() { val } else { 0.0 }
};
*o = if gray {
let l = if lin { s(0) } else { trc[1].to_linear(s(0)) };
[l, l, l]
} else if lin {
[s(0), s(1), s(2)]
} else {
[trc[0].to_linear(s(0)), trc[1].to_linear(s(1)), trc[2].to_linear(s(2))]
};
}
});
}
}
img
}
fn device_samples(raw: &Raw, ch: usize, n_color: usize) -> Vec<f32> {
let n = raw.width * raw.height;
let mut out = Vec::with_capacity(n * n_color);
for p in 0..n {
for c in 0..n_color {
out.push(raw.buf.norm(p * ch + c).clamp(0.0, 1.0));
}
}
out
}
fn from_vec(w: usize, h: usize, data: Vec<[f32; 3]>) -> Rgb32f {
Rgb32f { width: w, height: h, data }
}
fn rgb_or_gray(raw: &Raw, ch: usize, meta: &Meta, info: Option<&icc::IccInfo>, float: bool) -> (Rgb32f, SourceSpace) {
let gray = raw.model == Model::Gray;
if let (Some(bytes), Some(info)) = (meta.icc.as_deref(), info) {
match (&info.kind, gray) {
(IccKind::MatrixTrc { to_xyz_d50, trc }, false) => {
let space = SourceSpace { named: info.named, to_xyz_d50: *to_xyz_d50, trc: Some(trc.clone()), origin: SpaceOrigin::IccMatrixTrc };
return (linearize(raw, ch, trc), space);
}
(IccKind::MatrixTrc { to_xyz_d50, trc }, true) => {
// Gray data with an RGB profile: neutral axis only needs the green curve.
let t = [trc[1].clone(), trc[1].clone(), trc[1].clone()];
let space = SourceSpace { named: info.named, to_xyz_d50: *to_xyz_d50, trc: Some(t.clone()), origin: SpaceOrigin::IccMatrixTrc };
return (linearize(raw, ch, &t), space);
}
(IccKind::Gray { trc }, true) => {
let t = [trc.clone(), trc.clone(), trc.clone()];
let mut space = SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::IccMatrixTrc);
space.trc = Some(t.clone());
return (linearize(raw, ch, &t), space);
}
(IccKind::NeedsCms, _) => {
let wanted = if gray { IccColorModel::Gray } else { IccColorModel::Rgb };
if info.model == wanted {
let nc = raw.model.channels();
let samples = device_samples(raw, ch, nc);
if let Some(px) = icc::cms_to_linear_rec2020(bytes, &samples, nc) {
let mut s = SourceSpace::named(NamedSpace::Rec2020, SpaceOrigin::IccCms);
s.trc = None;
return (from_vec(raw.width, raw.height, px), s);
}
}
}
_ => {}
}
}
if meta.icc.is_some() {
// Present but unusable (malformed, mismatched model, CMS failure): assume sRGB.
let s = SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::IccUnsupported);
let t = NamedSpace::Srgb.trc();
return (linearize(raw, ch, &[t.clone(), t.clone(), t]), s);
}
if let Some(h) = &meta.hint {
let t = h.trc.clone().unwrap_or([Trc::Srgb, Trc::Srgb, Trc::Srgb]);
return (linearize(raw, ch, &t), h.clone());
}
let adobe = meta.exif.as_deref().map(exif::summarize).is_some_and(|s| s.adobe_rgb_hint);
let mut s = if adobe {
SourceSpace::named(NamedSpace::AdobeRgb, SpaceOrigin::Container)
} else {
SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::Untagged)
};
if float {
// Untagged float data is conventionally scene-linear.
s.trc = Some([Trc::Linear, Trc::Linear, Trc::Linear]);
}
let t = s.trc.clone().unwrap_or([Trc::Srgb, Trc::Srgb, Trc::Srgb]);
(linearize(raw, ch, &t), s)
}
fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccInfo>) -> (Rgb32f, SourceSpace) {
let samples = device_samples(raw, ch, 4);
if let (Some(bytes), Some(info)) = (icc_bytes, info)
&& info.model == IccColorModel::Cmyk
&& let Some(px) = icc::cms_to_linear_rec2020(bytes, &samples, 4)
{
let mut s = SourceSpace::named(NamedSpace::Rec2020, SpaceOrigin::IccCms);
s.trc = None;
return (from_vec(raw.width, raw.height, px), s);
}
// Naive: RGB = (1 - C)(1 - K) … treated as sRGB-encoded.
let px = samples
.chunks_exact(4)
.map(|c| {
let k = 1.0 - c[3];
[(1.0 - c[0]) * k, (1.0 - c[1]) * k, (1.0 - c[2]) * k].map(lightcraft_color::transfer::srgb_to_linear)
})
.collect();
let origin = if icc_bytes.is_some() { SpaceOrigin::IccUnsupported } else { SpaceOrigin::Naive };
(from_vec(raw.width, raw.height, px), SourceSpace::named(NamedSpace::Srgb, origin))
}

301
crates/codecs/src/encode.rs Normal file
View File

@ -0,0 +1,301 @@
//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), lossless WebP (`image-webp`) and,
//! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`). Metadata (ICC/EXIF/XMP) is
//! embedded where the format allows.
use crate::{Error, Result};
use lightcraft_raster::Rgba8;
use serde::{Deserialize, Serialize};
/// Interleaved sample data. Integer samples are display-encoded (0..=MAX); float samples are
/// written as-is.
#[derive(Clone, Copy, Debug)]
pub enum Samples<'a> {
U8(&'a [u8]),
U16(&'a [u16]),
F32(&'a [f32]),
}
/// An image to encode: `channels` = 1 (gray), 2 (gray + alpha), 3 (RGB) or 4 (RGBA), straight alpha.
#[derive(Clone, Copy, Debug)]
pub struct EncodeImage<'a> {
pub width: u32,
pub height: u32,
pub channels: u8,
pub samples: Samples<'a>,
}
impl<'a> EncodeImage<'a> {
pub fn rgba8(img: &'a Rgba8) -> Self {
EncodeImage { width: img.width as u32, height: img.height as u32, channels: 4, samples: Samples::U8(bytemuck::cast_slice(&img.data)) }
}
pub fn new(width: u32, height: u32, channels: u8, samples: Samples<'a>) -> Self {
EncodeImage { width, height, channels, samples }
}
fn validate(&self) -> Result<()> {
if !(1..=4).contains(&self.channels) {
return Err(Error::Encode(format!("unsupported channel count {}", self.channels)));
}
if self.width == 0 || self.height == 0 {
return Err(Error::Encode("zero dimension".into()));
}
let need = self.width as u64 * self.height as u64 * self.channels as u64;
let have = match self.samples {
Samples::U8(s) => s.len(),
Samples::U16(s) => s.len(),
Samples::F32(s) => s.len(),
} as u64;
if have < need {
return Err(Error::Encode(format!("sample buffer too short: {have} < {need}")));
}
Ok(())
}
fn has_alpha(&self) -> bool {
self.channels == 2 || self.channels == 4
}
}
/// Metadata to embed.
#[derive(Clone, Copy, Debug, Default)]
pub struct EncodeMeta<'a> {
pub icc: Option<&'a [u8]>,
/// TIFF-structured EXIF (starting at `II`/`MM`, no `Exif\0\0` prefix).
pub exif: Option<&'a [u8]>,
pub xmp: Option<&'a str>,
}
/// JPEG chroma subsampling.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChromaSubsampling {
/// No subsampling (best quality).
#[default]
S444,
S422,
S420,
}
const XMP_NS: &[u8] = b"http://ns.adobe.com/xap/1.0/\0";
/// Encode baseline JPEG (8-bit; alpha is dropped). `quality` 1..=100.
pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsampling, meta: &EncodeMeta) -> Result<Vec<u8>> {
img.validate()?;
let Samples::U8(data) = img.samples else {
return Err(Error::Encode("JPEG needs 8-bit samples".into()));
};
if img.width > u16::MAX as u32 || img.height > u16::MAX as u32 {
return Err(Error::Encode("JPEG dimensions are limited to 65535".into()));
}
let n = img.width as usize * img.height as usize;
let (data, ct): (std::borrow::Cow<[u8]>, jpeg_encoder::ColorType) = match img.channels {
1 => (data[..n].into(), jpeg_encoder::ColorType::Luma),
2 => (data.chunks_exact(2).take(n).map(|c| c[0]).collect::<Vec<_>>().into(), jpeg_encoder::ColorType::Luma),
3 => (data[..n * 3].into(), jpeg_encoder::ColorType::Rgb),
_ => (data[..n * 4].into(), jpeg_encoder::ColorType::Rgba),
};
let mut out = Vec::new();
let mut enc = jpeg_encoder::Encoder::new(&mut out, quality.clamp(1, 100));
enc.set_sampling_factor(match subsampling {
ChromaSubsampling::S444 => jpeg_encoder::SamplingFactor::R_4_4_4,
ChromaSubsampling::S422 => jpeg_encoder::SamplingFactor::R_4_2_2,
ChromaSubsampling::S420 => jpeg_encoder::SamplingFactor::R_4_2_0,
});
// Standard tables: jpeg-encoder's optimized-table mode writes non-interleaved scans, which some
// decoders (incl. zune-jpeg 0.5) mishandle. Interleaved baseline is universally supported.
let e = |e: jpeg_encoder::EncodingError| Error::Encode(e.to_string());
if let Some(exif) = meta.exif {
if exif.len() > 65_527 {
return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into()));
}
enc.add_exif_metadata(exif).map_err(e)?;
}
if let Some(xmp) = meta.xmp {
let mut seg = XMP_NS.to_vec();
seg.extend_from_slice(xmp.as_bytes());
if seg.len() > 65_533 {
return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into()));
}
enc.add_app_segment(1, seg).map_err(e)?;
}
if let Some(icc) = meta.icc {
enc.add_icc_profile(icc).map_err(e)?;
}
enc.encode(&data, img.width as u16, img.height as u16, ct).map_err(e)?;
Ok(out)
}
/// Encode PNG (8- or 16-bit; float input is rejected — quantize first).
pub fn encode_png(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
img.validate()?;
let n = img.width as usize * img.height as usize * img.channels as usize;
let (bytes, depth): (Vec<u8>, png::BitDepth) = match img.samples {
Samples::U8(s) => (s[..n].to_vec(), png::BitDepth::Eight),
Samples::U16(s) => (s[..n].iter().flat_map(|v| v.to_be_bytes()).collect(), png::BitDepth::Sixteen),
Samples::F32(_) => return Err(Error::Encode("PNG needs 8- or 16-bit samples".into())),
};
let mut info = png::Info::with_size(img.width, img.height);
info.color_type = match img.channels {
1 => png::ColorType::Grayscale,
2 => png::ColorType::GrayscaleAlpha,
3 => png::ColorType::Rgb,
_ => png::ColorType::Rgba,
};
info.bit_depth = depth;
info.icc_profile = meta.icc.map(|b| b.to_vec().into());
info.exif_metadata = meta.exif.map(|b| b.to_vec().into());
let mut out = Vec::new();
let e = |e: png::EncodingError| Error::Encode(e.to_string());
{
let mut enc = png::Encoder::with_info(&mut out, info).map_err(e)?;
enc.set_compression(png::Compression::Balanced);
if let Some(xmp) = meta.xmp {
enc.add_itxt_chunk("XML:com.adobe.xmp".into(), xmp.into()).map_err(e)?;
}
let mut w = enc.write_header().map_err(e)?;
w.write_image_data(&bytes).map_err(e)?;
w.finish().map_err(e)?;
}
Ok(out)
}
/// TIFF compression.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum TiffCompression {
None,
Lzw,
#[default]
Deflate,
PackBits,
}
/// Encode TIFF: 8/16-bit integer or 32-bit float, gray/RGB with optional (unassociated) alpha.
/// ICC and XMP are embedded; EXIF is not (write it through the TIFF writer in `lightcraft-tiff`).
pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &EncodeMeta) -> Result<Vec<u8>> {
use tiff::encoder::{Compression, DeflateLevel, TiffEncoder, colortype};
use tiff::tags::Predictor;
img.validate()?;
let e = |e: tiff::TiffError| Error::Encode(e.to_string());
let mut cur = std::io::Cursor::new(Vec::new());
let comp = match compression {
TiffCompression::None => Compression::Uncompressed,
TiffCompression::Lzw => Compression::Lzw,
TiffCompression::Deflate => Compression::Deflate(DeflateLevel::Balanced),
TiffCompression::PackBits => Compression::Packbits,
};
let float = matches!(img.samples, Samples::F32(_));
let predictor =
if !float && matches!(compression, TiffCompression::Lzw | TiffCompression::Deflate) { Predictor::Horizontal } else { Predictor::None };
let mut enc = TiffEncoder::new(&mut cur).map_err(e)?.with_compression(comp).with_predictor(predictor);
let (w, h) = (img.width, img.height);
let n = w as usize * h as usize;
// Gray + alpha is stored as RGBA (the encoder has no GrayA colour type).
let ch = img.channels as usize;
macro_rules! write {
($ct:ty, $data:expr, $alpha:expr) => {{
let mut im = enc.new_image::<$ct>(w, h).map_err(e)?;
if let Some(icc) = meta.icc {
im.encoder().write_tag(tiff::tags::Tag::IccProfile, icc).map_err(e)?;
}
if let Some(xmp) = meta.xmp {
im.encoder().write_tag(tiff::tags::Tag::from_u16_exhaustive(700), xmp.as_bytes()).map_err(e)?;
}
if $alpha {
im.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[2u16][..]).map_err(e)?;
}
im.write_data($data).map_err(e)?;
}};
}
fn expand_ga<T: Copy>(s: &[T], n: usize) -> Vec<T> {
s.chunks_exact(2).take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect()
}
match img.samples {
Samples::U8(s) => match ch {
1 => write!(colortype::Gray8, &s[..n], false),
2 => write!(colortype::RGBA8, &expand_ga(s, n), true),
3 => write!(colortype::RGB8, &s[..n * 3], false),
_ => write!(colortype::RGBA8, &s[..n * 4], true),
},
Samples::U16(s) => match ch {
1 => write!(colortype::Gray16, &s[..n], false),
2 => write!(colortype::RGBA16, &expand_ga(s, n), true),
3 => write!(colortype::RGB16, &s[..n * 3], false),
_ => write!(colortype::RGBA16, &s[..n * 4], true),
},
Samples::F32(s) => match ch {
1 => write!(colortype::Gray32Float, &s[..n], false),
2 => write!(colortype::RGBA32Float, &expand_ga(s, n), true),
3 => write!(colortype::RGB32Float, &s[..n * 3], false),
_ => write!(colortype::RGBA32Float, &s[..n * 4], true),
},
}
Ok(cur.into_inner())
}
/// Encode lossless WebP (8-bit). ICC/EXIF/XMP are embedded in the extended (VP8X) container.
pub fn encode_webp_lossless(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
img.validate()?;
let Samples::U8(s) = img.samples else {
return Err(Error::Encode("WebP needs 8-bit samples".into()));
};
let n = img.width as usize * img.height as usize * img.channels as usize;
let ct = match img.channels {
1 => image_webp::ColorType::L8,
2 => image_webp::ColorType::La8,
3 => image_webp::ColorType::Rgb8,
_ => image_webp::ColorType::Rgba8,
};
let mut out = Vec::new();
let mut enc = image_webp::WebPEncoder::new(&mut out);
if let Some(icc) = meta.icc {
enc.set_icc_profile(icc.to_vec());
}
if let Some(exif) = meta.exif {
enc.set_exif_metadata(exif.to_vec());
}
if let Some(xmp) = meta.xmp {
enc.set_xmp_metadata(xmp.as_bytes().to_vec());
}
enc.encode(&s[..n], img.width, img.height, ct).map_err(|e| Error::Encode(e.to_string()))?;
Ok(out)
}
/// Encode AVIF (8-bit sRGB, 4:4:4) with `ravif` (rav1e). `quality` 1..=100, `speed` 1 (slow) ..= 10.
/// EXIF is embedded; ICC is not supported by the muxer (the file is tagged sRGB via `nclx`).
/// Returns [`Error::Encode`] on wasm32 or when built without the `avif` feature.
pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta) -> Result<Vec<u8>> {
img.validate()?;
#[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
{
let Samples::U8(s) = img.samples else {
return Err(Error::Encode("AVIF encoder needs 8-bit samples".into()));
};
let n = img.width as usize * img.height as usize;
let ch = img.channels as usize;
let px: Vec<ravif::RGBA8> = s
.chunks_exact(ch)
.take(n)
.map(|c| match ch {
1 => ravif::RGBA8::new(c[0], c[0], c[0], 255),
2 => ravif::RGBA8::new(c[0], c[0], c[0], c[1]),
3 => ravif::RGBA8::new(c[0], c[1], c[2], 255),
_ => ravif::RGBA8::new(c[0], c[1], c[2], c[3]),
})
.collect();
let mut enc = ravif::Encoder::new()
.with_quality(quality.clamp(1, 100) as f32)
.with_alpha_quality(quality.clamp(1, 100) as f32)
.with_speed(speed.clamp(1, 10));
if let Some(exif) = meta.exif {
enc = enc.with_exif(exif.to_vec());
}
let r = enc.encode_rgba(ravif::Img::new(&px[..], img.width as usize, img.height as usize)).map_err(|e| Error::Encode(e.to_string()))?;
let _ = img.has_alpha();
Ok(r.avif_file)
}
#[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))]
{
let _ = (quality, speed, meta, img.has_alpha());
Err(Error::Encode("AVIF encoding is not available in this build".into()))
}
}

166
crates/codecs/src/exif.rs Normal file
View File

@ -0,0 +1,166 @@
//! Minimal, bounds-checked TIFF/EXIF walking: just what decoding needs (orientation, embedded
//! thumbnail, colour-space hint). Full EXIF parsing lives in `lightcraft-meta`.
/// Byte-order aware reader over a TIFF-structured blob.
#[derive(Clone, Copy)]
pub(crate) struct Tiff<'a> {
pub data: &'a [u8],
le: bool,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct Entry {
pub tag: u16,
pub typ: u16,
pub count: u32,
/// Offset of the 4-byte value/offset field within `data`.
pub value_pos: usize,
}
impl<'a> Tiff<'a> {
pub fn new(data: &'a [u8]) -> Option<Tiff<'a>> {
let le = match data.get(0..2)? {
b"II" => true,
b"MM" => false,
_ => return None,
};
Some(Tiff { data, le })
}
pub fn u16_at(&self, pos: usize) -> Option<u16> {
let b = self.data.get(pos..pos.checked_add(2)?)?;
Some(if self.le { u16::from_le_bytes([b[0], b[1]]) } else { u16::from_be_bytes([b[0], b[1]]) })
}
pub fn u32_at(&self, pos: usize) -> Option<u32> {
let b = self.data.get(pos..pos.checked_add(4)?)?;
let a = [b[0], b[1], b[2], b[3]];
Some(if self.le { u32::from_le_bytes(a) } else { u32::from_be_bytes(a) })
}
pub fn first_ifd(&self) -> Option<usize> {
let magic = self.u16_at(2)?;
if magic != 42 {
return None;
}
Some(self.u32_at(4)? as usize)
}
/// Entries of the IFD at `pos` and the offset of the next IFD (0 = none).
pub fn ifd(&self, pos: usize) -> Option<(Vec<Entry>, usize)> {
let n = self.u16_at(pos)? as usize;
if n > 4096 {
return None;
}
let mut v = Vec::with_capacity(n);
for i in 0..n {
let e = pos + 2 + i * 12;
v.push(Entry { tag: self.u16_at(e)?, typ: self.u16_at(e + 2)?, count: self.u32_at(e + 4)?, value_pos: e + 8 });
}
let next = self.u32_at(pos + 2 + n * 12).unwrap_or(0) as usize;
Some((v, next))
}
/// First value of a SHORT/LONG entry.
pub fn uint(&self, e: &Entry) -> Option<u32> {
match e.typ {
3 => self.u16_at(e.value_pos).map(u32::from),
4 | 13 => self.u32_at(e.value_pos),
1 | 7 => self.data.get(e.value_pos).map(|b| *b as u32),
_ => None,
}
}
/// Raw bytes of an entry's value (for ASCII/UNDEFINED/BYTE types).
pub fn bytes(&self, e: &Entry) -> Option<&'a [u8]> {
let size = match e.typ {
1 | 2 | 6 | 7 => 1usize,
3 | 8 => 2,
4 | 9 | 11 | 13 => 4,
5 | 10 | 12 => 8,
_ => return None,
};
let len = size.checked_mul(e.count as usize)?;
let start = if len <= 4 { e.value_pos } else { self.u32_at(e.value_pos)? as usize };
self.data.get(start..start.checked_add(len)?)
}
}
/// What the decoder cares about from an EXIF (TIFF-structured) blob.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct ExifSummary {
pub orientation: Option<u16>,
/// (offset, length) of the IFD1 JPEG thumbnail within the blob.
pub thumbnail: Option<(usize, usize)>,
/// EXIF ColorSpace = 0xFFFF with InteropIndex "R03" (Adobe RGB (1998) "option" files).
pub adobe_rgb_hint: bool,
}
pub(crate) fn summarize(blob: &[u8]) -> ExifSummary {
let mut s = ExifSummary::default();
let Some(t) = Tiff::new(blob) else { return s };
let Some(ifd0) = t.first_ifd() else { return s };
let Some((entries, next)) = t.ifd(ifd0) else { return s };
let mut exif_ifd = None;
for e in &entries {
match e.tag {
0x0112 => s.orientation = t.uint(e).map(|v| v as u16).filter(|v| (1..=8).contains(v)),
0x8769 => exif_ifd = t.uint(e).map(|v| v as usize),
_ => {}
}
}
if next != 0
&& next != ifd0
&& let Some((e1, _)) = t.ifd(next)
{
let off = e1.iter().find(|e| e.tag == 0x0201).and_then(|e| t.uint(e));
let len = e1.iter().find(|e| e.tag == 0x0202).and_then(|e| t.uint(e));
if let (Some(o), Some(l)) = (off, len) {
let (o, l) = (o as usize, l as usize);
if l > 4 && o.checked_add(l).is_some_and(|end| end <= blob.len()) && blob[o..o + 2] == [0xFF, 0xD8] {
s.thumbnail = Some((o, l));
}
}
}
if let Some(p) = exif_ifd
&& let Some((ee, _)) = t.ifd(p)
{
let cs = ee.iter().find(|e| e.tag == 0xA001).and_then(|e| t.uint(e));
let interop = ee.iter().find(|e| e.tag == 0xA005).and_then(|e| t.uint(e));
if cs == Some(0xFFFF)
&& let Some((ie, _)) = interop.and_then(|p| t.ifd(p as usize))
{
s.adobe_rgb_hint = ie.iter().any(|e| e.tag == 1 && t.bytes(e).is_some_and(|b| b.starts_with(b"R03")));
}
}
s
}
/// Build a tiny EXIF blob (little-endian TIFF) with just an orientation tag. Used by tests and by
/// callers that need to stamp orientation on export when no source EXIF exists.
pub fn minimal_exif(orientation: u16) -> Vec<u8> {
let mut v = Vec::with_capacity(26);
v.extend_from_slice(b"II");
v.extend_from_slice(&42u16.to_le_bytes());
v.extend_from_slice(&8u32.to_le_bytes());
v.extend_from_slice(&1u16.to_le_bytes());
v.extend_from_slice(&0x0112u16.to_le_bytes());
v.extend_from_slice(&3u16.to_le_bytes());
v.extend_from_slice(&1u32.to_le_bytes());
v.extend_from_slice(&orientation.to_le_bytes());
v.extend_from_slice(&[0, 0]);
v.extend_from_slice(&0u32.to_le_bytes());
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn orientation_roundtrip() {
for o in 1..=8 {
assert_eq!(summarize(&minimal_exif(o)).orientation, Some(o));
}
}
#[test]
fn garbage_ok() {
assert_eq!(summarize(b"II*\0\xff\xff\xff\xff"), ExifSummary::default());
assert_eq!(summarize(&[]), ExifSummary::default());
}
}

252
crates/codecs/src/icc.rs Normal file
View File

@ -0,0 +1,252 @@
//! ICC profiles: parsing (via `moxcms`), matrix/TRC extraction, CMS fallback for LUT/CMYK profiles,
//! and writing matrix/TRC profiles for export embedding.
use crate::space::{NamedSpace, Trc, rgb_to_xyz_d50};
use lightcraft_color::{D50, Mat3, RgbSpace, bradford};
use moxcms::{
ColorProfile, DataColorSpace, Layout, LocalizableString, Matrix3d, ProfileText, RenderingIntent, ToneReprCurve, TransformOptions, Xyzd,
};
use serde::{Deserialize, Serialize};
/// Colour model of the profile's device space.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum IccColorModel {
Rgb,
Gray,
Cmyk,
Other,
}
/// How a profile can be applied.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum IccKind {
/// RGB matrix/TRC (shaper) profile: exact, applied by us.
MatrixTrc { to_xyz_d50: Mat3, trc: [Trc; 3] },
/// Grayscale profile with a `kTRC`.
Gray { trc: Trc },
/// LUT-based (A2B), CMYK or otherwise needs a full CMS conversion.
NeedsCms,
}
/// Summary of a parsed ICC profile.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct IccInfo {
pub model: IccColorModel,
pub kind: IccKind,
/// Recognised well-known RGB space (by colorants), if any.
pub named: Option<NamedSpace>,
pub description: Option<String>,
}
/// Parse an ICC profile. Returns `None` for malformed or unsupported data (never panics).
pub fn parse(bytes: &[u8]) -> Option<IccInfo> {
let p = std::panic::catch_unwind(|| ColorProfile::new_from_slice(bytes)).ok()?.ok()?;
Some(info_of(&p))
}
fn info_of(p: &ColorProfile) -> IccInfo {
let description = p.description.as_ref().and_then(text_of);
let model = match p.color_space {
DataColorSpace::Rgb => IccColorModel::Rgb,
DataColorSpace::Gray => IccColorModel::Gray,
DataColorSpace::Cmyk => IccColorModel::Cmyk,
_ => IccColorModel::Other,
};
let has_lut = p.lut_a_to_b_perceptual.is_some() || p.lut_a_to_b_colorimetric.is_some();
let kind = if model == IccColorModel::Rgb && p.is_matrix_shaper() && p.pcs == DataColorSpace::Xyz {
let c = [p.red_colorant, p.green_colorant, p.blue_colorant];
let to_xyz_d50 = Mat3([[c[0].x, c[1].x, c[2].x], [c[0].y, c[1].y, c[2].y], [c[0].z, c[1].z, c[2].z]]);
let trcs = [&p.red_trc, &p.green_trc, &p.blue_trc];
let trc = trcs.map(|t| t.as_ref().map(trc_from_icc).unwrap_or(Trc::Srgb));
if to_xyz_d50.determinant().abs() > 1e-6 && trc.iter().all(valid_trc) { IccKind::MatrixTrc { to_xyz_d50, trc } } else { IccKind::NeedsCms }
} else if model == IccColorModel::Gray && p.gray_trc.is_some() && !has_lut {
let trc = p.gray_trc.as_ref().map(trc_from_icc).unwrap_or(Trc::Srgb);
if valid_trc(&trc) { IccKind::Gray { trc } } else { IccKind::NeedsCms }
} else {
IccKind::NeedsCms
};
let named = match &kind {
IccKind::MatrixTrc { to_xyz_d50, .. } => NamedSpace::recognize(to_xyz_d50),
_ => None,
};
IccInfo { model, kind, named, description }
}
fn valid_trc(t: &Trc) -> bool {
match t {
Trc::Parametric(p) => matches!(p.len(), 1 | 3 | 4 | 5 | 7) && p.iter().all(|v| v.is_finite()),
Trc::Gamma(g) => g.is_finite() && *g > 0.0,
_ => true,
}
}
fn text_of(t: &ProfileText) -> Option<String> {
let s = match t {
ProfileText::PlainString(s) => s.clone(),
ProfileText::Localizable(v) => v.first().map(|l| l.value.clone())?,
ProfileText::Description(d) => {
if d.unicode_string.is_empty() {
d.ascii_string.clone()
} else {
d.unicode_string.clone()
}
}
};
let s = s.trim_end_matches('\0').trim().to_string();
if s.is_empty() { None } else { Some(s) }
}
fn trc_from_icc(t: &ToneReprCurve) -> Trc {
match t {
ToneReprCurve::Lut(v) if v.is_empty() => Trc::Linear,
ToneReprCurve::Lut(v) if v.len() == 1 => Trc::Gamma(v[0] as f32 / 256.0),
ToneReprCurve::Lut(v) => Trc::Table(v.clone()),
ToneReprCurve::Parametric(p) => Trc::Parametric(p.clone()),
}
}
fn trc_to_icc(t: &Trc) -> ToneReprCurve {
match t {
Trc::Linear => ToneReprCurve::Parametric(vec![1.0]),
Trc::Srgb => ToneReprCurve::Parametric(vec![2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045]),
Trc::Gamma(g) => ToneReprCurve::Parametric(vec![*g]),
Trc::Rec709 => ToneReprCurve::Parametric(vec![1.0 / 0.45, 1.0 / 1.099, 0.099 / 1.099, 1.0 / 4.5, 0.081]),
Trc::Parametric(p) => ToneReprCurve::Parametric(p.clone()),
Trc::Table(v) => ToneReprCurve::Lut(v.clone()),
}
}
fn xyzd(v: [f64; 3]) -> Xyzd {
Xyzd { x: v[0], y: v[1], z: v[2] }
}
/// ICC PCS illuminant (D50) as stored in profiles.
const ICC_D50: [f64; 3] = [0.9642, 1.0, 0.8249];
/// Build an ICC v4 display-class matrix/TRC profile for `space` with the given encoding curve
/// (D50 PCS, Bradford `chad`), for embedding in exported files.
pub fn write_matrix_trc(space: &RgbSpace, trc: &Trc) -> Vec<u8> {
let m = rgb_to_xyz_d50(space).0;
let mut p = ColorProfile::new_srgb();
p.cicp = None;
p.red_colorant = xyzd([m[0][0], m[1][0], m[2][0]]);
p.green_colorant = xyzd([m[0][1], m[1][1], m[2][1]]);
p.blue_colorant = xyzd([m[0][2], m[1][2], m[2][2]]);
let curve = trc_to_icc(trc);
p.red_trc = Some(curve.clone());
p.green_trc = Some(curve.clone());
p.blue_trc = Some(curve);
p.white_point = xyzd(ICC_D50);
p.media_white_point = Some(xyzd(ICC_D50));
p.chromatic_adaptation = Some(Matrix3d { v: bradford(space.white, D50).0 });
p.rendering_intent = RenderingIntent::Perceptual;
let desc = match trc {
t if t.is_linear() => format!("{} (linear)", space.name),
_ => space.name.to_string(),
};
p.description = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), desc)]));
p.copyright = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), "No copyright, use freely".into())]));
p.encode().unwrap_or_default()
}
/// A profile for one of the well-known spaces with its standard curve.
pub fn write_named(n: NamedSpace) -> Vec<u8> {
write_matrix_trc(&n.rgb_space(), &n.trc())
}
fn linear_rec2020_profile() -> ColorProfile {
let mut p = ColorProfile::new_bt2020();
p.cicp = None;
let lin = ToneReprCurve::Parametric(vec![1.0]);
p.red_trc = Some(lin.clone());
p.green_trc = Some(lin.clone());
p.blue_trc = Some(lin);
p
}
/// Convert device samples (0..1, interleaved, `channels` = 1, 3 or 4 matching the profile's colour
/// model) to linear Rec.2020 through a full CMS transform. `None` if the profile cannot be applied.
pub(crate) fn cms_to_linear_rec2020(icc: &[u8], samples: &[f32], channels: usize) -> Option<Vec<[f32; 3]>> {
let src = ColorProfile::new_from_slice(icc).ok()?;
let layout = match (src.color_space, channels) {
(DataColorSpace::Rgb, 3) => Layout::Rgb,
(DataColorSpace::Gray, 1) => Layout::Gray,
(DataColorSpace::Cmyk, 4) => Layout::Rgba,
_ => return None,
};
let dst = linear_rec2020_profile();
let opts = TransformOptions { rendering_intent: RenderingIntent::RelativeColorimetric, ..TransformOptions::default() };
let run = || -> Option<Vec<[f32; 3]>> {
let t = src.create_transform_f32(layout, &dst, Layout::Rgb, opts).ok()?;
let n = samples.len() / channels;
let mut out = vec![0f32; n * 3];
// Chunk to bound temporary memory and allow parallelism.
const ROWS: usize = 1 << 16;
let src_chunks = samples.chunks(ROWS * channels);
let dst_chunks = out.chunks_mut(ROWS * 3);
#[cfg(feature = "parallel")]
{
use rayon::prelude::*;
let pairs: Vec<_> = src_chunks.zip(dst_chunks).collect();
let ok = pairs.into_par_iter().all(|(s, d)| t.transform(s, d).is_ok());
if !ok {
return None;
}
}
#[cfg(not(feature = "parallel"))]
for (s, d) in src_chunks.zip(dst_chunks) {
t.transform(s, d).ok()?;
}
Some(out.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect())
};
std::panic::catch_unwind(run).ok().flatten()
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_color::{DISPLAY_P3, PROPHOTO, SRGB};
#[test]
fn write_then_parse_recognises() {
for n in NamedSpace::ALL {
let bytes = write_named(n);
assert!(bytes.len() > 128, "{n:?}");
let info = parse(&bytes).expect("parses");
assert_eq!(info.named, Some(n), "{n:?}");
match info.kind {
IccKind::MatrixTrc { trc, .. } => assert!(trc[0].approx_eq(&n.trc(), 2e-4), "{n:?} {:?}", trc[0]),
k => panic!("{k:?}"),
}
}
}
#[test]
fn linear_profile() {
let bytes = write_matrix_trc(&PROPHOTO, &Trc::Linear);
let info = parse(&bytes).unwrap();
assert_eq!(info.named, Some(NamedSpace::ProPhoto));
let IccKind::MatrixTrc { trc, .. } = info.kind else { panic!() };
assert!(trc[1].is_linear());
}
#[test]
fn cms_path_matches_matrix_path() {
let bytes = write_named(NamedSpace::DisplayP3);
let px = [0.2f32, 0.5, 0.8];
let out = cms_to_linear_rec2020(&bytes, &px, 3).unwrap()[0];
let lin = px.map(|v| Trc::Srgb.to_linear(v));
let m = DISPLAY_P3.to_space(&lightcraft_color::REC2020);
let want = m.apply_f32(lin);
for i in 0..3 {
assert!((out[i] - want[i]).abs() < 2e-3, "{out:?} vs {want:?}");
}
let _ = SRGB;
}
#[test]
fn garbage_is_none() {
assert!(parse(&[]).is_none());
assert!(parse(&[0u8; 200]).is_none());
}
}

256
crates/codecs/src/jpeg.rs Normal file
View File

@ -0,0 +1,256 @@
//! JPEG: marker parsing (EXIF/XMP/ICC/Adobe/MPF), full decode via `zune-jpeg`, DCT-scaled and
//! CMYK/YCCK decode via `jpeg-decoder`, embedded-preview thumbnails.
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::{DecodeOptions, Decoded, Error, Format, Result, Thumbnail, ThumbnailSource, exif};
const F: Format = Format::Jpeg;
/// What we learn from walking the marker segments before the first scan.
#[derive(Default, Debug)]
pub(crate) struct Markers {
pub width: u32,
pub height: u32,
pub components: u8,
pub precision: u8,
/// SOFn marker byte (0xC0 baseline, 0xC1 extended, 0xC2 progressive, …).
pub sof: u8,
/// Number of components in the first scan (< `components` for non-interleaved sequential files).
pub first_scan_components: u8,
pub exif: Option<Vec<u8>>,
/// Offset of the EXIF TIFF header within the file.
pub exif_offset: usize,
pub xmp: Option<String>,
pub icc: Option<Vec<u8>>,
pub adobe_transform: Option<u8>,
/// Offset of the MPF TIFF header within the file and its bytes.
pub mpf: Option<(usize, Vec<u8>)>,
}
pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
if !b.starts_with(&[0xFF, 0xD8]) {
return None;
}
let mut m = Markers::default();
let mut icc_chunks: Vec<(u8, &[u8])> = Vec::new();
let mut p = 2;
while p + 4 <= b.len() {
if b[p] != 0xFF {
// Tolerate garbage between segments: resync on the next 0xFF.
p += 1;
continue;
}
let marker = b[p + 1];
if marker == 0xFF {
p += 1;
continue;
}
if marker == 0xD8 || (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
p += 2;
continue;
}
if marker == 0xD9 {
break;
}
if marker == 0xDA {
m.first_scan_components = b.get(p + 4).copied().unwrap_or(0);
break;
}
let len = u16::from_be_bytes([b[p + 2], b[p + 3]]) as usize;
if len < 2 {
return None;
}
let start = p + 4;
let end = (p + 2 + len).min(b.len());
let seg = &b[start..end];
match marker {
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC => {
if seg.len() >= 6 && m.components == 0 {
m.precision = seg[0];
m.sof = marker;
m.height = u16::from_be_bytes([seg[1], seg[2]]) as u32;
m.width = u16::from_be_bytes([seg[3], seg[4]]) as u32;
m.components = seg[5];
}
}
0xE1 => {
if seg.starts_with(b"Exif\0\0") && m.exif.is_none() {
m.exif = Some(seg[6..].to_vec());
m.exif_offset = start + 6;
} else if let Some(x) = seg.strip_prefix(b"http://ns.adobe.com/xap/1.0/\0")
&& m.xmp.is_none()
{
m.xmp = Some(String::from_utf8_lossy(x).trim_end_matches('\0').to_string());
}
}
0xE2 => {
if seg.starts_with(b"ICC_PROFILE\0") && seg.len() >= 14 {
icc_chunks.push((seg[12], &seg[14..]));
} else if seg.starts_with(b"MPF\0") && m.mpf.is_none() {
m.mpf = Some((start + 4, seg[4..].to_vec()));
}
}
0xEE if seg.starts_with(b"Adobe") && seg.len() >= 12 => {
m.adobe_transform = Some(seg[11]);
}
_ => {}
}
p += 2 + len;
}
if !icc_chunks.is_empty() {
icc_chunks.sort_by_key(|c| c.0);
m.icc = Some(icc_chunks.iter().flat_map(|c| c.1.iter().copied()).collect());
}
Some(m)
}
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
if m.components == 0 {
return Err(Error::Malformed(F, "no frame header".into()));
}
check_size(F, m.width as u64, m.height as u64, opts)?;
let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= 2 * mw || m.height >= 2 * mh));
// zune-jpeg handles the common cases fastest; jpeg-decoder covers DCT scaling, CMYK/YCCK,
// 12-bit and non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling).
let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components;
let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved {
decode_jpeg_decoder(bytes, &m, scale_to)?
} else {
decode_zune(bytes, &m)?
};
let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, ..Default::default() };
finish(F, raw, meta, (m.width, m.height), opts)
}
fn decode_zune(bytes: &[u8], m: &Markers) -> Result<Raw> {
use zune_core::bytestream::ZCursor;
use zune_core::colorspace::ColorSpace;
use zune_core::options::DecoderOptions;
let gray = m.components == 1;
let opts = DecoderOptions::default().set_max_width(1 << 16).set_max_height(1 << 16).set_strict_mode(false).jpeg_set_out_colorspace(if gray {
ColorSpace::Luma
} else {
ColorSpace::RGB
});
let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), opts);
let px = d.decode().map_err(|e| Error::Malformed(F, e.to_string()))?;
let info = d.info().ok_or_else(|| Error::Malformed(F, "no info".into()))?;
let (w, h) = (info.width as usize, info.height as usize);
let model = if gray { Model::Gray } else { Model::Rgb };
if px.len() < w * h * model.channels() {
return Err(Error::Malformed(F, "short output".into()));
}
Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf: Buf::U8(px), bit_depth: 8 })
}
fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>) -> Result<Raw> {
use jpeg_decoder::PixelFormat;
let mut d = jpeg_decoder::Decoder::new(bytes);
d.read_info().map_err(|e| Error::Malformed(F, e.to_string()))?;
if let Some((mw, mh)) = scale_to {
// Ask for the smallest DCT scale still covering the target box.
let (sw, sh) = scaled_request(m.width, m.height, mw, mh);
d.scale(sw, sh).map_err(|e| Error::Malformed(F, e.to_string()))?;
}
let px = d.decode().map_err(|e| Error::Malformed(F, e.to_string()))?;
let info = d.info().ok_or_else(|| Error::Malformed(F, "no info".into()))?;
let (w, h) = (info.width as usize, info.height as usize);
let (model, buf, depth) = match info.pixel_format {
PixelFormat::L8 => (Model::Gray, Buf::U8(px), 8),
PixelFormat::L16 => {
let v = px.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
(Model::Gray, Buf::U16(v), 16)
}
PixelFormat::RGB24 => (Model::Rgb, Buf::U8(px), 8),
PixelFormat::CMYK32 => (Model::Cmyk, Buf::U8(px), 8),
};
Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth })
}
/// The box to request from `jpeg-decoder::scale` so the decoded image still covers `mw × mh` once
/// fitted with the source aspect ratio.
fn scaled_request(w: u32, h: u32, mw: u32, mh: u32) -> (u16, u16) {
let s = (mw as f64 / w as f64).min(mh as f64 / h as f64).min(1.0);
let tw = ((w as f64 * s).ceil() as u32).clamp(1, u16::MAX as u32);
let th = ((h as f64 * s).ceil() as u32).clamp(1, u16::MAX as u32);
(tw as u16, th as u16)
}
/// Fast path: an embedded JPEG preview (EXIF IFD1 thumbnail, or the largest MPF preview) whose long
/// edge is ≥ `max_edge`, decoded with DCT scaling and fitted.
pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32) -> Option<Thumbnail> {
let m = parse_markers(bytes)?;
let summary = m.exif.as_deref().map(exif::summarize).unwrap_or_default();
let orientation = summary.orientation.unwrap_or(1);
let mut candidates: Vec<(&[u8], ThumbnailSource)> = Vec::new();
if let (Some(ex), Some((o, l))) = (m.exif.as_deref(), summary.thumbnail) {
candidates.push((&ex[o..o + l], ThumbnailSource::ExifThumbnail));
}
for (o, l) in mpf_images(&m) {
if let Some(s) = bytes.get(o..o.saturating_add(l)) {
candidates.push((s, ThumbnailSource::MpfPreview));
}
}
// Smallest adequate preview wins.
let mut best: Option<(u32, &[u8], ThumbnailSource)> = None;
for (data, src) in candidates {
let Some(pm) = parse_markers(data) else { continue };
let long = pm.width.max(pm.height);
// Previews must have the main image's aspect ratio (within 2%), else they are letterboxed/cropped.
let aspect_ok = m.width > 0
&& m.height > 0
&& pm.height > 0
&& ((pm.width as f64 / pm.height as f64) / (m.width as f64 / m.height as f64) - 1.0).abs() < 0.02;
if long >= max_edge && aspect_ok && best.as_ref().is_none_or(|b| long < b.0) {
best = Some((long, data, src));
}
}
let (_, data, source) = best?;
let d = decode(data, &DecodeOptions::fit(max_edge, max_edge)).ok()?;
Some(Thumbnail { image: d.to_srgb8(), orientation, source, source_width: m.width, source_height: m.height })
}
/// (file offset, length) of MPF-listed images other than the primary.
fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
let Some((base, data)) = &m.mpf else { return vec![] };
let Some(t) = exif::Tiff::new(data) else { return vec![] };
let Some(ifd) = t.first_ifd() else { return vec![] };
let Some((entries, _)) = t.ifd(ifd) else { return vec![] };
let Some(e) = entries.iter().find(|e| e.tag == 0xB002) else { return vec![] };
let Some(list) = t.bytes(e) else { return vec![] };
let mut out = Vec::new();
for (i, rec) in list.chunks_exact(16).enumerate().take(16) {
if i == 0 {
continue;
}
let rd = |o: usize| {
let a = [rec[o], rec[o + 1], rec[o + 2], rec[o + 3]];
if data.starts_with(b"II") { u32::from_le_bytes(a) } else { u32::from_be_bytes(a) }
};
let (size, off) = (rd(4) as usize, rd(8) as usize);
if size > 0 && off > 0 {
out.push((base + off, size));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scaled_request_covers() {
assert_eq!(scaled_request(6000, 4000, 256, 256), (256, 171));
assert_eq!(scaled_request(100, 100, 256, 256), (100, 100));
}
#[test]
fn markers_on_garbage() {
assert!(parse_markers(b"").is_none());
assert!(parse_markers(&[0xFF, 0xD8, 0xFF, 0xE1, 0, 1]).is_none());
let m = parse_markers(&[0xFF, 0xD8, 0xFF, 0xE1, 0, 200, 1, 2]).unwrap();
assert!(m.exif.is_none());
}
}

76
crates/codecs/src/jxl.rs Normal file
View File

@ -0,0 +1,76 @@
//! JPEG XL decode via `jxl-oxide` (pure Rust). Enum-encoded images are rendered straight into linear
//! Rec.2020; ICC-tagged ones are rendered in their own space and interpreted with our ICC path.
//! jxl-oxide applies the codestream orientation itself, so `orientation` is reported as 1.
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
use crate::{DecodeOptions, Decoded, Error, Format, Result};
use jxl_oxide::color::{ColourSpace, EnumColourEncoding, Primaries, RenderingIntent, TransferFunction, WhitePoint};
use jxl_oxide::{AuxBoxData, JxlImage};
const F: Format = Format::Jxl;
fn err(e: impl std::fmt::Display) -> Error {
Error::Malformed(F, e.to_string())
}
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let mut img = JxlImage::builder().read(std::io::Cursor::new(bytes)).map_err(err)?;
let (w, h) = (img.width(), img.height());
check_size(F, w as u64, h as u64, opts)?;
let header = img.image_header();
let bits = header.metadata.bit_depth.bits_per_sample() as u8;
let float = matches!(header.metadata.bit_depth, jxl_oxide::image::BitDepth::FloatSample { .. });
let gray = header.metadata.grayscale();
let icc = img.original_icc().map(|v| v.to_vec());
let mut meta = Meta { orientation: Some(1), ..Default::default() };
if icc.is_none() {
img.request_color_encoding(EnumColourEncoding {
colour_space: if gray { ColourSpace::Grey } else { ColourSpace::Rgb },
white_point: WhitePoint::D65,
primaries: Primaries::Bt2100,
tf: TransferFunction::Linear,
rendering_intent: RenderingIntent::Relative,
});
let mut s = SourceSpace::named(NamedSpace::Rec2020, SpaceOrigin::Container);
s.trc = Some([Trc::Linear, Trc::Linear, Trc::Linear]);
meta.already_linear = Some(s);
} else {
meta.icc = icc;
}
if let Ok(AuxBoxData::Data(ex)) = img.aux_boxes().first_exif() {
meta.exif = Some(ex.payload().to_vec());
}
if let AuxBoxData::Data(x) = img.aux_boxes().first_xml() {
meta.xmp = Some(String::from_utf8_lossy(x).trim_end_matches('\0').to_string());
}
let render = img.render_frame(0).map_err(err)?;
let fb = render.image_all_channels();
let (fw, fh, ch) = (fb.width(), fb.height(), fb.channels());
let n_color = if gray { 1 } else { 3 };
let has_alpha = img.pixel_format().has_alpha();
if ch < n_color {
return Err(err("too few channels"));
}
let keep = n_color + has_alpha as usize;
let src = fb.buf();
let mut v = Vec::with_capacity(fw * fh * keep);
for px in src.chunks_exact(ch) {
v.extend_from_slice(&px[..keep.min(ch)]);
if keep > ch {
v.push(1.0);
}
}
let raw = Raw {
width: fw,
height: fh,
model: if gray { Model::Gray } else { Model::Rgb },
alpha: has_alpha,
premultiplied: false,
buf: Buf::F32(v),
bit_depth: bits,
};
let mut d = finish(F, raw, meta, (w, h), opts)?;
d.float = float;
Ok(d)
}

226
crates/codecs/src/lib.rs Normal file
View File

@ -0,0 +1,226 @@
//! Standard image codecs for LightCraft (layer L1).
//!
//! - [`sniff`] detects the container format (and flags camera raws for `lightcraft-raw`).
//! - [`decode`] decodes JPEG, PNG, TIFF, WebP, GIF, BMP, PSD (merged composite) and JPEG XL into
//! **linear-light** [`Rgb32f`] in the source's own primaries, described by [`SourceSpace`]; ICC
//! profiles (matrix/TRC exactly, LUT/CMYK via the `moxcms` CMS), 16-bit and float precision are
//! preserved. EXIF/XMP/ICC blobs are passed through untouched; orientation is reported, never applied.
//! - [`to_working`] converts a decode result to linear Rec.2020 D65 (the pipeline working space).
//! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode).
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
//!
//! No decoder panics on malformed input (property-tested).
#![forbid(unsafe_code)]
mod convert;
pub mod encode;
pub mod exif;
pub mod icc;
mod jpeg;
#[cfg(feature = "jxl")]
mod jxl;
mod other;
mod png_codec;
mod psd;
mod sniff;
pub mod space;
mod tiff_codec;
mod webp;
pub use encode::{
ChromaSubsampling, EncodeImage, EncodeMeta, Samples, TiffCompression, encode_avif, encode_jpeg, encode_png, encode_tiff, encode_webp_lossless,
};
pub use sniff::{Format, sniff};
pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
use lightcraft_raster::{Plane, Rgb32f, Rgba8};
use serde::{Deserialize, Serialize};
/// Codec errors.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("unrecognised image format")]
UnknownFormat,
#[error("{0:?} decoding is not supported: {1}")]
Unsupported(Format, &'static str),
#[error("malformed {0:?} data: {1}")]
Malformed(Format, String),
#[error("image too large: {0}×{1}")]
TooLarge(u64, u64),
#[error("encode error: {0}")]
Encode(String),
}
pub type Result<T> = std::result::Result<T, Error>;
/// Decode options.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct DecodeOptions {
/// Fit the result within this box (never upscales). JPEG uses DCT-domain scaling (1/2, 1/4,
/// 1/8) before a final linear-light resample; other formats decode fully then resample.
pub max_size: Option<(u32, u32)>,
/// Refuse images with more pixels than this (default 1 Gpx).
pub max_pixels: u64,
}
impl Default for DecodeOptions {
fn default() -> Self {
DecodeOptions { max_size: None, max_pixels: 1 << 30 }
}
}
impl DecodeOptions {
pub fn fit(w: u32, h: u32) -> Self {
DecodeOptions { max_size: Some((w, h)), ..Default::default() }
}
}
/// A decoded image.
#[derive(Clone, Debug)]
pub struct Decoded {
pub format: Format,
/// Linear light, in the primaries described by [`Decoded::space`].
pub image: Rgb32f,
/// Straight (unassociated) alpha 0..1, when the source has transparency.
pub alpha: Option<Plane>,
pub space: SourceSpace,
/// Bits per sample in the file (8, 16, 32, …).
pub bit_depth: u8,
/// Sample format in the file was floating point.
pub float: bool,
pub has_alpha: bool,
/// The source is grayscale (expanded to RGB in `image`).
pub grayscale: bool,
/// The source was CMYK (converted).
pub cmyk: bool,
/// EXIF orientation value 1..=8 (1 when absent). **Not applied** to `image`.
pub orientation: u16,
/// TIFF-structured EXIF bytes (starting at the `II`/`MM` byte-order mark), if embedded.
/// For TIFF files the file itself is the TIFF structure and this is `None`.
pub exif: Option<Vec<u8>>,
pub xmp: Option<String>,
pub icc: Option<Vec<u8>>,
/// Dimensions of `image`.
pub width: u32,
pub height: u32,
/// Full-resolution dimensions of the source (differs from `width`/`height` when scaled).
pub source_width: u32,
pub source_height: u32,
}
impl Decoded {
/// Pixels converted to linear Rec.2020 D65.
pub fn to_working(&self) -> Rgb32f {
to_working(self)
}
/// Display-encoded 8-bit sRGB (gamut clipped), with alpha.
pub fn to_srgb8(&self) -> Rgba8 {
let m = self.space.to_space(&lightcraft_color::SRGB);
let ident = m == lightcraft_color::Mat3::IDENTITY;
let m = m.to_f32();
let enc = lightcraft_color::transfer::encode_srgb8;
let mut out = Rgba8::new(self.image.width, self.image.height);
let alpha = self.alpha.as_ref();
lightcraft_raster::par_rows(&mut out.data, self.image.width, |y, row| {
let src = self.image.row(y);
let a = alpha.map(|a| a.row(y));
for (x, (o, p)) in row.iter_mut().zip(src).enumerate() {
let q = if ident { *p } else { mat_apply(&m, *p) };
let al = a.map(|a| (a[x].clamp(0.0, 1.0) * 255.0 + 0.5) as u8).unwrap_or(255);
*o = [enc(q[0]), enc(q[1]), enc(q[2]), al];
}
});
out
}
}
#[inline]
pub(crate) fn mat_apply(m: &[[f32; 3]; 3], p: [f32; 3]) -> [f32; 3] {
[
m[0][0] * p[0] + m[0][1] * p[1] + m[0][2] * p[2],
m[1][0] * p[0] + m[1][1] * p[1] + m[1][2] * p[2],
m[2][0] * p[0] + m[2][1] * p[1] + m[2][2] * p[2],
]
}
/// Decode any supported format (see [`Format::can_decode`]).
///
/// Malformed input yields an error, never a panic: on unwinding targets a panic inside a third-party
/// decoder is caught and reported as [`Error::Malformed`].
pub fn decode(bytes: &[u8], opts: DecodeOptions) -> Result<Decoded> {
let format = sniff(bytes).ok_or(Error::UnknownFormat)?;
std::panic::catch_unwind(|| decode_unguarded(bytes, format, &opts)).unwrap_or_else(|_| Err(Error::Malformed(format, "decoder panicked".into())))
}
/// [`decode`] without the panic guard (for fuzzing our own code paths).
#[doc(hidden)]
pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result<Decoded> {
let opts = *opts;
match format {
Format::Jpeg => jpeg::decode(bytes, &opts),
Format::Png => png_codec::decode(bytes, &opts),
Format::Tiff => tiff_codec::decode(bytes, &opts),
Format::WebP => webp::decode(bytes, &opts),
Format::Gif | Format::Bmp => other::decode(bytes, format, &opts),
Format::Psd => psd::decode(bytes, &opts),
#[cfg(feature = "jxl")]
Format::Jxl => jxl::decode(bytes, &opts),
#[cfg(not(feature = "jxl"))]
Format::Jxl => Err(Error::Unsupported(format, "built without the `jxl` feature")),
Format::Avif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed AV1 decoder yet")),
Format::Heif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed HEVC decoder yet")),
Format::RawTiffLike | Format::RawOther => Err(Error::Unsupported(format, "camera raw: decode with lightcraft-raw")),
}
}
/// Convert a decode result to linear Rec.2020 D65 (Bradford-adapted), the develop working space.
pub fn to_working(d: &Decoded) -> Rgb32f {
if d.space.is_working() {
return d.image.clone();
}
let m = d.space.to_working().to_f32();
d.image.map(|p| mat_apply(&m, p))
}
/// Where a thumbnail came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ThumbnailSource {
/// The JPEG thumbnail embedded in EXIF (IFD1).
ExifThumbnail,
/// An MPF (CIPA DC-007) preview image embedded in a JPEG.
MpfPreview,
/// A (DCT-)scaled decode of the main image.
Scaled,
}
/// A small display-ready image.
#[derive(Clone, Debug)]
pub struct Thumbnail {
/// 8-bit sRGB with alpha, fitted within `max_edge` (orientation **not** applied).
pub image: Rgba8,
pub orientation: u16,
pub source: ThumbnailSource,
pub source_width: u32,
pub source_height: u32,
}
/// Fast thumbnail: an embedded JPEG preview that is at least `max_edge` on its long side if one exists,
/// else a scaled decode. The result fits within `max_edge × max_edge`.
pub fn decode_thumbnail(bytes: &[u8], max_edge: u32) -> Result<Thumbnail> {
let max_edge = max_edge.max(1);
if sniff(bytes) == Some(Format::Jpeg)
&& let Some(t) = jpeg::embedded_thumbnail(bytes, max_edge)
{
return Ok(t);
}
let d = decode(bytes, DecodeOptions::fit(max_edge, max_edge))?;
Ok(Thumbnail {
image: d.to_srgb8(),
orientation: d.orientation,
source: ThumbnailSource::Scaled,
source_width: d.source_width,
source_height: d.source_height,
})
}

View File

@ -0,0 +1,38 @@
//! GIF (first frame) and BMP via the `image` crate's pure-Rust decoders.
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::{DecodeOptions, Decoded, Error, Format, Result};
use image::{DynamicImage, ImageDecoder};
pub(crate) fn decode(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result<Decoded> {
let err = |e: image::ImageError| Error::Malformed(format, e.to_string());
let cursor = std::io::Cursor::new(bytes);
let (img, icc) = match format {
Format::Gif => {
let mut d = image::codecs::gif::GifDecoder::new(cursor).map_err(err)?;
let (w, h) = d.dimensions();
check_size(format, w as u64, h as u64, opts)?;
let icc = d.icc_profile().ok().flatten();
(DynamicImage::from_decoder(d).map_err(err)?, icc)
}
Format::Bmp => {
let mut d = image::codecs::bmp::BmpDecoder::new(cursor).map_err(err)?;
let (w, h) = d.dimensions();
check_size(format, w as u64, h as u64, opts)?;
let icc = d.icc_profile().ok().flatten();
(DynamicImage::from_decoder(d).map_err(err)?, icc)
}
_ => return Err(Error::Unsupported(format, "not handled by this decoder")),
};
let (w, h) = (img.width(), img.height());
let alpha = img.color().has_alpha();
let gray = !img.color().has_color();
let (model, alpha, buf) = match (gray, alpha) {
(true, false) => (Model::Gray, false, img.into_luma8().into_raw()),
(true, true) => (Model::Gray, true, img.into_luma_alpha8().into_raw()),
(false, false) => (Model::Rgb, false, img.into_rgb8().into_raw()),
(false, true) => (Model::Rgb, true, img.into_rgba8().into_raw()),
};
let raw = Raw { width: w as usize, height: h as usize, model, alpha, premultiplied: false, buf: Buf::U8(buf), bit_depth: 8 };
finish(format, raw, Meta { icc, ..Default::default() }, (w, h), opts)
}

View File

@ -0,0 +1,116 @@
//! PNG decode via the `png` crate (8/16-bit, palette, tRNS, iCCP, eXIf, iTXt XMP, sRGB/cICP/cHRM/gAMA).
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
use crate::{DecodeOptions, Decoded, Error, Format, Result};
use lightcraft_color::{Mat3, RgbSpace, Xy};
const F: Format = Format::Png;
fn err(e: impl std::fmt::Display) -> Error {
Error::Malformed(F, e.to_string())
}
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let mut dec = png::Decoder::new(std::io::Cursor::new(bytes));
dec.set_transformations(png::Transformations::EXPAND);
dec.set_limits(png::Limits { bytes: opts.max_pixels.saturating_mul(8).min(usize::MAX as u64) as usize });
let mut reader = dec.read_info().map_err(err)?;
let (w, h) = {
let i = reader.info();
(i.width, i.height)
};
check_size(F, w as u64, h as u64, opts)?;
let size = reader.output_buffer_size().ok_or_else(|| err("buffer size overflow"))?;
let mut buf = vec![0u8; size];
let out = reader.next_frame(&mut buf).map_err(err)?;
// Text chunks after IDAT land in `info` once the stream is finished; ignore trailing errors.
let _ = reader.finish();
let info = reader.info();
let (model, alpha) = match out.color_type {
png::ColorType::Grayscale => (Model::Gray, false),
png::ColorType::GrayscaleAlpha => (Model::Gray, true),
png::ColorType::Rgb => (Model::Rgb, false),
png::ColorType::Rgba => (Model::Rgb, true),
png::ColorType::Indexed => return Err(err("palette not expanded")),
};
buf.truncate(out.buffer_size());
let (samples, depth) = match out.bit_depth {
png::BitDepth::Sixteen => (Buf::U16(buf.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect()), 16),
_ => (Buf::U8(buf), info.bit_depth as u8),
};
let raw = Raw { width: out.width as usize, height: out.height as usize, model, alpha, premultiplied: false, buf: samples, bit_depth: depth };
let xmp = info
.utf8_text
.iter()
.find(|t| t.keyword == "XML:com.adobe.xmp")
.and_then(|t| t.get_text().ok())
.or_else(|| info.uncompressed_latin1_text.iter().find(|t| t.keyword == "XML:com.adobe.xmp").map(|t| t.text.clone()));
let exif = info.exif_metadata.as_ref().map(|e| {
let e = e.to_vec();
// Some writers keep the JPEG "Exif\0\0" prefix.
if e.starts_with(b"Exif\0\0") { e[6..].to_vec() } else { e }
});
let meta = Meta { icc: info.icc_profile.as_ref().map(|c| c.to_vec()), exif, xmp, hint: container_hint(info), ..Default::default() };
finish(F, raw, meta, (w, h), opts)
}
fn container_hint(info: &png::Info) -> Option<SourceSpace> {
if let Some(c) = &info.coding_independent_code_points {
let named = match c.color_primaries {
1 => Some(NamedSpace::Srgb),
9 => Some(NamedSpace::Rec2020),
12 => Some(NamedSpace::DisplayP3),
_ => None,
};
let trc = match c.transfer_function {
13 => Some(Trc::Srgb),
1 | 6 | 14 | 15 => Some(Trc::Rec709),
8 => Some(Trc::Linear),
4 => Some(Trc::Gamma(2.2)),
_ => None,
};
if let (Some(n), Some(t)) = (named, trc) {
let mut s = SourceSpace::named(n, SpaceOrigin::Container);
s.trc = Some([t.clone(), t.clone(), t]);
return Some(s);
}
}
if info.srgb.is_some() {
return Some(SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::Container));
}
let gamma = info.gama_chunk.or(info.source_gamma).map(|g| g.into_value()).filter(|g| *g > 0.0 && g.is_finite());
let chrm = info.chrm_chunk.or(info.source_chromaticities);
if gamma.is_none() && chrm.is_none() {
return None;
}
let trc = match gamma {
Some(g) if (g - 1.0).abs() < 1e-3 => Trc::Linear,
Some(g) => Trc::Gamma(1.0 / g),
None => Trc::Srgb,
};
let mut s = SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::Container);
if let Some(c) = chrm {
let xy = |p: (png::ScaledFloat, png::ScaledFloat)| Xy::new(p.0.into_value() as f64, p.1.into_value() as f64);
let space = RgbSpace { name: "PNG cHRM", r: xy(c.red), g: xy(c.green), b: xy(c.blue), white: xy(c.white) };
let valid = [space.r, space.g, space.b, space.white].iter().all(|p| p.y > 1e-4 && p.x >= 0.0 && p.x + p.y <= 1.0 + 1e-6);
let m = if valid { rgb_to_xyz_d50_checked(&space) } else { None };
if let Some(m) = m {
s.to_xyz_d50 = m;
s.named = NamedSpace::recognize(&m);
}
}
s.trc = Some([trc.clone(), trc.clone(), trc]);
Some(s)
}
fn rgb_to_xyz_d50_checked(space: &RgbSpace) -> Option<Mat3> {
let p = [space.r.to_xyz(), space.g.to_xyz(), space.b.to_xyz()];
let m = Mat3([[p[0][0], p[1][0], p[2][0]], [p[0][1], p[1][1], p[2][1]], [p[0][2], p[1][2], p[2][2]]]);
if m.determinant().abs() < 1e-9 {
return None;
}
Some(crate::space::rgb_to_xyz_d50(space))
}

244
crates/codecs/src/psd.rs Normal file
View File

@ -0,0 +1,244 @@
//! Photoshop PSD/PSB merged-composite reader (clean-room, from the public file-format description):
//! 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw or PackBits/RLE image data;
//! ICC (resource 1039), EXIF (1058), XMP (1060); transparency when the layer count is negative.
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::{DecodeOptions, Decoded, Error, Format, Result};
const F: Format = Format::Psd;
fn err(s: &str) -> Error {
Error::Malformed(F, s.to_string())
}
struct Rd<'a> {
b: &'a [u8],
p: usize,
}
impl<'a> Rd<'a> {
fn take(&mut self, n: usize) -> Result<&'a [u8]> {
let end = self.p.checked_add(n).filter(|&e| e <= self.b.len()).ok_or_else(|| err("truncated"))?;
let s = &self.b[self.p..end];
self.p = end;
Ok(s)
}
fn u16(&mut self) -> Result<u16> {
let s = self.take(2)?;
Ok(u16::from_be_bytes([s[0], s[1]]))
}
fn u32(&mut self) -> Result<u32> {
let s = self.take(4)?;
Ok(u32::from_be_bytes([s[0], s[1], s[2], s[3]]))
}
fn u64(&mut self) -> Result<u64> {
let s = self.take(8)?;
Ok(u64::from_be_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]))
}
}
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let mut r = Rd { b: bytes, p: 0 };
if r.take(4)? != b"8BPS" {
return Err(err("bad signature"));
}
let version = r.u16()?;
let psb = match version {
1 => false,
2 => true,
_ => return Err(err("bad version")),
};
r.take(6)?;
let channels = r.u16()? as usize;
let height = r.u32()? as usize;
let width = r.u32()? as usize;
let depth = r.u16()?;
let mode = r.u16()?;
check_size(F, width as u64, height as u64, opts)?;
if channels == 0 || channels > 56 {
return Err(err("bad channel count"));
}
// Colour mode data (palette for indexed).
let cm_len = r.u32()? as usize;
let color_mode_data = r.take(cm_len)?;
// Image resources.
let res_len = r.u32()? as usize;
let res = r.take(res_len)?;
let (icc, exif, xmp) = resources(res);
// Layer and mask information: only the layer count sign matters (merged transparency).
let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize };
let lm = r.take(lm_len)?;
let merged_transparency = if lm.len() >= if psb { 10 } else { 6 } {
let off = if psb { 8 } else { 4 };
let count = i16::from_be_bytes([lm[off], lm[off + 1]]);
count < 0
} else {
false
};
// Image data.
let compression = r.u16()?;
let bpc = match depth {
8 => 1usize,
16 => 2,
32 => 4,
1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")),
_ => return Err(err("bad depth")),
};
let plane = width * height * bpc;
let row_bytes = width * bpc;
let mut planes: Vec<Vec<u8>> = Vec::with_capacity(channels);
match compression {
0 => {
for _ in 0..channels {
planes.push(r.take(plane)?.to_vec());
}
}
1 => {
let counts_len = channels * height;
let mut counts = Vec::with_capacity(counts_len);
for _ in 0..counts_len {
counts.push(if psb { r.u32()? as usize } else { r.u16()? as usize });
}
for c in 0..channels {
let mut out = Vec::with_capacity(plane);
for y in 0..height {
let src = r.take(counts[c * height + y])?;
unpackbits(src, row_bytes, &mut out);
}
planes.push(out);
}
}
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
}
let (model, n_color) = match mode {
1 | 8 => (Model::Gray, 1),
2 => (Model::Rgb, 1),
3 => (Model::Rgb, 3),
4 => (Model::Cmyk, 4),
7 => (Model::Gray, 1),
9 => return Err(Error::Unsupported(F, "Lab PSD")),
_ => return Err(err("unknown colour mode")),
};
if channels < n_color {
return Err(err("too few channels"));
}
let alpha = merged_transparency && channels > n_color && mode != 2;
let used = n_color + alpha as usize;
let n = width * height;
// Interleave to device samples.
let buf = if mode == 2 {
// Indexed → 8-bit RGB via the 768-byte palette.
if color_mode_data.len() < 768 || depth != 8 {
return Err(err("bad indexed palette"));
}
let mut v = Vec::with_capacity(n * 3);
for &i in &planes[0] {
let i = i as usize;
v.extend_from_slice(&[color_mode_data[i], color_mode_data[256 + i], color_mode_data[512 + i]]);
}
Buf::U8(v)
} else {
match bpc {
1 => {
let mut v = vec![0u8; n * used];
for (c, p) in planes.iter().take(used).enumerate() {
for i in 0..n {
let x = p[i];
// PSD CMYK stores 255 = no ink; our CMYK convention is 0 = no ink.
v[i * used + c] = if model == Model::Cmyk && c < 4 { 255 - x } else { x };
}
}
Buf::U8(v)
}
2 => {
let mut v = vec![0u16; n * used];
for (c, p) in planes.iter().take(used).enumerate() {
for i in 0..n {
let x = u16::from_be_bytes([p[2 * i], p[2 * i + 1]]);
v[i * used + c] = if model == Model::Cmyk && c < 4 { 65535 - x } else { x };
}
}
Buf::U16(v)
}
_ => {
let mut v = vec![0f32; n * used];
for (c, p) in planes.iter().take(used).enumerate() {
for i in 0..n {
let x = f32::from_be_bytes([p[4 * i], p[4 * i + 1], p[4 * i + 2], p[4 * i + 3]]);
v[i * used + c] = if model == Model::Cmyk && c < 4 { 1.0 - x } else { x };
}
}
Buf::F32(v)
}
}
};
let raw = Raw { width, height, model, alpha, premultiplied: false, buf, bit_depth: depth as u8 };
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts)
}
/// PackBits: append exactly `row_bytes` bytes (zero-padded if the run data is short).
fn unpackbits(mut src: &[u8], row_bytes: usize, out: &mut Vec<u8>) {
let target = out.len() + row_bytes;
while out.len() < target && !src.is_empty() {
let h = src[0] as i8;
src = &src[1..];
if h >= 0 {
let n = (h as usize + 1).min(src.len()).min(target - out.len());
out.extend_from_slice(&src[..n]);
src = &src[(h as usize + 1).min(src.len())..];
} else if h != -128 {
let n = ((1 - h as isize) as usize).min(target - out.len());
if let Some(&b) = src.first() {
out.extend(std::iter::repeat_n(b, n));
src = &src[1..];
}
}
}
out.resize(target, 0);
}
fn resources(mut b: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<String>) {
let (mut icc, mut exif, mut xmp) = (None, None, None);
while b.len() >= 12 && &b[0..4] == b"8BIM" {
let id = u16::from_be_bytes([b[4], b[5]]);
let name_len = b[6] as usize;
let name_total = (1 + name_len + 1) & !1;
let p = 6 + name_total;
if p + 4 > b.len() {
break;
}
let size = u32::from_be_bytes([b[p], b[p + 1], b[p + 2], b[p + 3]]) as usize;
let start = p + 4;
let Some(data) = start.checked_add(size).and_then(|end| b.get(start..end)) else { break };
match id {
1039 => icc = Some(data.to_vec()),
1058 => exif = Some(if data.starts_with(b"Exif\0\0") { data[6..].to_vec() } else { data.to_vec() }),
1060 => xmp = Some(String::from_utf8_lossy(data).trim_end_matches('\0').to_string()),
_ => {}
}
let next = start + ((size + 1) & !1);
b = b.get(next..).unwrap_or(&[]);
}
(icc, exif, xmp)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn packbits() {
let mut out = Vec::new();
unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], 6, &mut out);
assert_eq!(out, vec![0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]);
let mut out = Vec::new();
unpackbits(&[0x05, 1], 4, &mut out);
assert_eq!(out, vec![1, 0, 0, 0]);
}
}

236
crates/codecs/src/sniff.rs Normal file
View File

@ -0,0 +1,236 @@
//! Format detection from leading bytes.
use crate::exif::Tiff;
use serde::{Deserialize, Serialize};
/// Image container formats LightCraft recognises.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Format {
Jpeg,
Png,
/// Ordinary (non-camera-raw) TIFF, incl. BigTIFF.
Tiff,
WebP,
Avif,
/// HEIC / HEIF (ISO-BMFF with HEVC).
Heif,
/// JPEG XL (bare codestream or ISO-BMFF container).
Jxl,
Gif,
Bmp,
/// Photoshop PSD / PSB (merged composite).
Psd,
/// TIFF-structured camera raw (DNG, CR2, NEF, ARW, PEF, ORF, RW2, SRW, 3FR, IIQ, ERF, …):
/// route to `lightcraft-raw`.
RawTiffLike,
/// Non-TIFF camera raw containers (CR3, RAF, CRW, MRW, X3F): route to `lightcraft-raw`.
RawOther,
}
impl Format {
/// Canonical lowercase file extension.
pub fn extension(self) -> &'static str {
match self {
Format::Jpeg => "jpg",
Format::Png => "png",
Format::Tiff => "tif",
Format::WebP => "webp",
Format::Avif => "avif",
Format::Heif => "heic",
Format::Jxl => "jxl",
Format::Gif => "gif",
Format::Bmp => "bmp",
Format::Psd => "psd",
Format::RawTiffLike => "dng",
Format::RawOther => "raw",
}
}
pub fn mime(self) -> &'static str {
match self {
Format::Jpeg => "image/jpeg",
Format::Png => "image/png",
Format::Tiff => "image/tiff",
Format::WebP => "image/webp",
Format::Avif => "image/avif",
Format::Heif => "image/heic",
Format::Jxl => "image/jxl",
Format::Gif => "image/gif",
Format::Bmp => "image/bmp",
Format::Psd => "image/vnd.adobe.photoshop",
Format::RawTiffLike | Format::RawOther => "image/x-raw",
}
}
/// Whether [`crate::decode`] can decode this format in this build.
pub fn can_decode(self) -> bool {
match self {
Format::Jxl => cfg!(feature = "jxl"),
Format::Avif | Format::Heif | Format::RawTiffLike | Format::RawOther => false,
_ => true,
}
}
/// Camera raw formats (decode with `lightcraft-raw`).
pub fn is_raw(self) -> bool {
matches!(self, Format::RawTiffLike | Format::RawOther)
}
}
/// Detect the format from the first bytes of a file (a few KiB suffices; TIFF raw detection walks IFDs
/// when more of the file is available).
pub fn sniff(b: &[u8]) -> Option<Format> {
if b.len() < 4 {
return None;
}
if b.starts_with(&[0xFF, 0xD8, 0xFF]) {
return Some(Format::Jpeg);
}
if b.starts_with(b"\x89PNG\r\n\x1a\n") {
return Some(Format::Png);
}
if b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a") {
return Some(Format::Gif);
}
if b.len() >= 12 && &b[0..4] == b"RIFF" && &b[8..12] == b"WEBP" {
return Some(Format::WebP);
}
if b.starts_with(&[0xFF, 0x0A]) || b.starts_with(&[0, 0, 0, 0x0C, b'J', b'X', b'L', b' ', 0x0D, 0x0A, 0x87, 0x0A]) {
return Some(Format::Jxl);
}
if b.starts_with(b"8BPS") && b.len() >= 6 && matches!(u16::from_be_bytes([b[4], b[5]]), 1 | 2) {
return Some(Format::Psd);
}
if b.starts_with(b"FUJIFILMCCD-RAW") || b.starts_with(b"\0MRM") || b.starts_with(b"FOVb") {
return Some(Format::RawOther);
}
if b.len() >= 14 && &b[0..2] == b"II" && &b[6..14] == b"HEAPCCDR" {
return Some(Format::RawOther);
}
if b.len() >= 12 && &b[4..8] == b"ftyp" {
return sniff_bmff(b);
}
if b.starts_with(b"IIRO") || b.starts_with(b"IIRS") || b.starts_with(b"MMOR") || b.starts_with(b"IIU\0") {
// Olympus ORF / Panasonic RW2: TIFF-structured with a private magic.
return Some(Format::RawTiffLike);
}
if b.starts_with(b"II*\0") || b.starts_with(b"MM\0*") {
return Some(if tiff_is_raw(b) { Format::RawTiffLike } else { Format::Tiff });
}
if b.starts_with(b"II+\0") || b.starts_with(b"MM\0+") {
return Some(Format::Tiff);
}
if b.len() >= 26 && b.starts_with(b"BM") {
let dib = u32::from_le_bytes([b[14], b[15], b[16], b[17]]);
if matches!(dib, 12 | 16 | 40 | 52 | 56 | 64 | 108 | 124) {
return Some(Format::Bmp);
}
}
None
}
fn sniff_bmff(b: &[u8]) -> Option<Format> {
let size = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize;
let end = size.clamp(12, b.len().min(4096));
let mut brands: Vec<&[u8]> = vec![&b[8..12]];
let mut p = 16;
while p + 4 <= end {
brands.push(&b[p..p + 4]);
p += 4;
}
let has = |x: &[u8]| brands.contains(&x);
if has(b"crx ") {
return Some(Format::RawOther);
}
if has(b"jxl ") {
return Some(Format::Jxl);
}
if has(b"avif") || has(b"avis") {
return Some(Format::Avif);
}
if [&b"heic"[..], b"heix", b"hevc", b"hevx", b"heim", b"heis", b"hevm", b"hevs", b"mif1", b"msf1"].iter().any(|x| has(x)) {
return Some(Format::Heif);
}
None
}
/// Heuristic camera-raw detection for classic TIFF: DNGVersion, the CR2 signature, CFA/LinearRaw
/// photometric or raw-only compressions in IFD0 or its SubIFDs, or a camera Make with 10–16-bit data
/// and no ordinary photometric.
fn tiff_is_raw(b: &[u8]) -> bool {
if b.len() >= 10 && &b[8..10] == b"CR" {
return true;
}
let Some(t) = Tiff::new(b) else { return false };
let Some(ifd0) = t.first_ifd() else { return false };
let mut stack = vec![ifd0];
let mut seen = 0;
while let Some(pos) = stack.pop() {
seen += 1;
if seen > 16 {
break;
}
let Some((entries, _)) = t.ifd(pos) else { continue };
for e in &entries {
match e.tag {
0xC612 => return true, // DNGVersion
0x0106 if matches!(t.uint(e), Some(32803 | 34892)) => return true,
0x0103 if matches!(t.uint(e), Some(32767 | 32769 | 32770 | 34316 | 34713 | 65000 | 65535)) => return true,
0x014A => {
// SubIFDs (LONG or IFD type): walk them.
let n = e.count.min(8) as usize;
if n == 1 {
if let Some(p) = t.uint(e) {
stack.push(p as usize);
}
} else if let Some(off) = t.u32_at(e.value_pos) {
for i in 0..n {
if let Some(p) = t.u32_at(off as usize + 4 * i) {
stack.push(p as usize);
}
}
}
}
_ => {}
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn magic_numbers() {
assert_eq!(sniff(b"\xFF\xD8\xFF\xE0\0\x10JFIF"), Some(Format::Jpeg));
assert_eq!(sniff(b"\x89PNG\r\n\x1a\n...."), Some(Format::Png));
assert_eq!(sniff(b"GIF89a......"), Some(Format::Gif));
assert_eq!(sniff(b"RIFF\0\0\0\0WEBPVP8 "), Some(Format::WebP));
assert_eq!(sniff(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), Some(Format::Avif));
assert_eq!(sniff(b"\0\0\0\x18ftypheic\0\0\0\0mif1heic"), Some(Format::Heif));
assert_eq!(sniff(b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom"), Some(Format::RawOther));
assert_eq!(sniff(&[0xFF, 0x0A, 0xFA, 0x7F]), Some(Format::Jxl));
assert_eq!(sniff(b"8BPS\0\x01\0\0\0\0\0\0"), Some(Format::Psd));
assert_eq!(sniff(b"FUJIFILMCCD-RAW 0201"), Some(Format::RawOther));
assert_eq!(sniff(b"II*\0\x08\0\0\0\0\0"), Some(Format::Tiff));
assert_eq!(sniff(b"II*\0\x10\0\0\0CR\x02\0"), Some(Format::RawTiffLike));
assert_eq!(sniff(b"IIRO\x08\0\0\0"), Some(Format::RawTiffLike));
assert_eq!(sniff(b"nope"), None);
assert_eq!(sniff(b""), None);
}
#[test]
fn dng_detected() {
// II*, IFD at 8 with a single DNGVersion entry.
let mut b = b"II*\0\x08\0\0\0".to_vec();
b.extend_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&0xC612u16.to_le_bytes());
b.extend_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&4u32.to_le_bytes());
b.extend_from_slice(&[1, 4, 0, 0]);
b.extend_from_slice(&0u32.to_le_bytes());
assert_eq!(sniff(&b), Some(Format::RawTiffLike));
}
}

321
crates/codecs/src/space.rs Normal file
View File

@ -0,0 +1,321 @@
//! Source colour spaces, transfer curves and the conversion to the working space.
use lightcraft_color::{ADOBE_RGB, D50, DISPLAY_P3, Mat3, PROPHOTO, REC2020, RgbSpace, SRGB, WORKING, bradford};
use serde::{Deserialize, Serialize};
/// A transfer curve (encoded value → linear light), as found in ICC `curv`/`para` tags and container
/// colour descriptions.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Trc {
/// Identity (already linear).
Linear,
/// IEC 61966-2-1 sRGB curve (also used by Display P3).
Srgb,
/// Pure power law `y = x^g`.
Gamma(f32),
/// ITU-R BT.709 / BT.2020 OETF inverse.
Rec709,
/// ICC parametric curve parameters `[g, a, b, c, d, e, f]` (1, 3, 4, 5 or 7 values; ICC function types 0–4).
Parametric(Vec<f32>),
/// Sampled curve over `[0, 1]` (ICC `curv` with ≥ 2 entries), linearly interpolated.
Table(Vec<u16>),
}
impl Trc {
/// Encoded value → linear. Negative values are mirrored and values above 1 are extrapolated,
/// so extended-range float data survives.
pub fn to_linear(&self, v: f32) -> f32 {
if v < 0.0 {
return -self.to_linear(-v);
}
match self {
Trc::Linear => v,
Trc::Srgb => {
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}
Trc::Gamma(g) => v.powf(*g),
Trc::Rec709 => {
if v < 0.081 {
v / 4.5
} else {
((v + 0.099) / 1.099).powf(1.0 / 0.45)
}
}
Trc::Parametric(p) => eval_parametric(p, v),
Trc::Table(t) => eval_table(t, v),
}
}
/// Linear → encoded value (inverse of [`Trc::to_linear`]); tables are inverted numerically.
pub fn from_linear(&self, v: f32) -> f32 {
if v < 0.0 {
return -self.from_linear(-v);
}
match self {
Trc::Linear => v,
Trc::Srgb => lightcraft_color::transfer::linear_to_srgb(v),
Trc::Gamma(g) => {
if *g > 0.0 {
v.powf(1.0 / g)
} else {
v
}
}
Trc::Rec709 => {
if v < 0.018 {
v * 4.5
} else {
1.099 * v.powf(0.45) - 0.099
}
}
Trc::Parametric(_) | Trc::Table(_) => {
// Monotone curves: bisection on [0, max(1, v)].
let (mut lo, mut hi) = (0.0f32, 1.0f32);
while self.to_linear(hi) < v && hi < 1e6 {
hi *= 2.0;
}
for _ in 0..40 {
let mid = 0.5 * (lo + hi);
if self.to_linear(mid) < v {
lo = mid;
} else {
hi = mid;
}
}
0.5 * (lo + hi)
}
}
}
/// True for the identity curve (including degenerate ICC encodings of it).
pub fn is_linear(&self) -> bool {
match self {
Trc::Linear => true,
Trc::Gamma(g) => (*g - 1.0).abs() < 1e-4,
Trc::Parametric(p) => p.len() == 1 && (p[0] - 1.0).abs() < 1e-4,
Trc::Table(t) => t.is_empty(),
_ => false,
}
}
/// Whether two curves agree to within `tol` on a sample grid.
pub fn approx_eq(&self, other: &Trc, tol: f32) -> bool {
(0..=32).all(|i| {
let x = i as f32 / 32.0;
(self.to_linear(x) - other.to_linear(x)).abs() <= tol
})
}
/// 256-entry linearization table for 8-bit data.
pub(crate) fn lut8(&self) -> Vec<f32> {
(0..256).map(|i| self.to_linear(i as f32 / 255.0)).collect()
}
/// 65536-entry linearization table for 16-bit data.
pub(crate) fn lut16(&self) -> Vec<f32> {
if self.is_linear() {
return (0..65536).map(|i| i as f32 / 65535.0).collect();
}
(0..65536).map(|i| self.to_linear(i as f32 / 65535.0)).collect()
}
}
fn eval_parametric(p: &[f32], x: f32) -> f32 {
let pw = |b: f32, g: f32| if b > 0.0 { b.powf(g) } else { 0.0 };
match p.len() {
1 => pw(x, p[0]),
3 => {
let (g, a, b) = (p[0], p[1], p[2]);
if a != 0.0 && x >= -b / a { pw(a * x + b, g) } else { 0.0 }
}
4 => {
let (g, a, b, c) = (p[0], p[1], p[2], p[3]);
if a != 0.0 && x >= -b / a { pw(a * x + b, g) + c } else { c }
}
5 => {
let (g, a, b, c, d) = (p[0], p[1], p[2], p[3], p[4]);
if x >= d { pw(a * x + b, g) } else { c * x }
}
7 => {
let (g, a, b, c, d, e, f) = (p[0], p[1], p[2], p[3], p[4], p[5], p[6]);
if x >= d { pw(a * x + b, g) + e } else { c * x + f }
}
_ => x,
}
}
fn eval_table(t: &[u16], x: f32) -> f32 {
match t.len() {
0 => x,
1 => x.powf(t[0] as f32 / 256.0),
n => {
let pos = x * (n - 1) as f32;
let i = (pos.floor() as usize).min(n - 2);
let f = pos - i as f32;
let a = t[i] as f32 / 65535.0;
let b = t[i + 1] as f32 / 65535.0;
a + (b - a) * f
}
}
}
/// Well-known RGB spaces we recognise (from ICC colorants or container tags).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum NamedSpace {
Srgb,
DisplayP3,
AdobeRgb,
ProPhoto,
Rec2020,
}
impl NamedSpace {
pub const ALL: [NamedSpace; 5] = [NamedSpace::Srgb, NamedSpace::DisplayP3, NamedSpace::AdobeRgb, NamedSpace::ProPhoto, NamedSpace::Rec2020];
pub fn rgb_space(self) -> RgbSpace {
match self {
NamedSpace::Srgb => SRGB,
NamedSpace::DisplayP3 => DISPLAY_P3,
NamedSpace::AdobeRgb => ADOBE_RGB,
NamedSpace::ProPhoto => PROPHOTO,
NamedSpace::Rec2020 => REC2020,
}
}
/// The space's standard encoding curve.
pub fn trc(self) -> Trc {
match self {
NamedSpace::Srgb | NamedSpace::DisplayP3 => Trc::Srgb,
NamedSpace::AdobeRgb => Trc::Gamma(563.0 / 256.0),
NamedSpace::ProPhoto => Trc::Gamma(1.8),
NamedSpace::Rec2020 => Trc::Rec709,
}
}
/// Linear RGB → XYZ (D50 PCS, Bradford-adapted), as an ICC matrix/TRC profile would store it.
pub fn to_xyz_d50(self) -> Mat3 {
rgb_to_xyz_d50(&self.rgb_space())
}
pub fn name(self) -> &'static str {
self.rgb_space().name
}
/// Recognise a space from its D50-adapted colorant matrix (tolerant to s15Fixed16 rounding and
/// the small variations between vendors' profiles).
pub fn recognize(to_xyz_d50: &Mat3) -> Option<NamedSpace> {
NamedSpace::ALL.into_iter().find(|n| {
let m = n.to_xyz_d50();
(0..3).all(|r| (0..3).all(|c| (m.0[r][c] - to_xyz_d50.0[r][c]).abs() < 0.004))
})
}
}
/// `space`'s linear RGB → XYZ relative to D50 (Bradford), the ICC PCS.
pub fn rgb_to_xyz_d50(space: &RgbSpace) -> Mat3 {
bradford(space.white, D50).mul(&space.to_xyz())
}
/// Where a decoded image's colour interpretation came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpaceOrigin {
/// No colour information in the file: assumed sRGB.
Untagged,
/// A container-level description (PNG `sRGB`/`cHRM`/`gAMA`, JPEG XL enum colour encoding, …).
Container,
/// An embedded ICC matrix/TRC profile, applied exactly.
IccMatrixTrc,
/// An embedded LUT-based (or CMYK/Lab) ICC profile, converted by the CMS to linear Rec.2020.
IccCms,
/// An embedded ICC profile we could not apply (malformed/unsupported): **fell back to sRGB**.
IccUnsupported,
/// A naive conversion without a profile (e.g. CMYK → RGB without an ICC profile), then sRGB.
Naive,
}
/// The colour space of [`crate::Decoded::image`]: linear RGB with these primaries.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SourceSpace {
/// Recognised well-known space, if the primaries match one.
pub named: Option<NamedSpace>,
/// Linear image RGB → CIE XYZ relative to D50 (ICC PCS; white Y = 1).
pub to_xyz_d50: Mat3,
/// The encoding curves that were removed during decode (R, G, B), if known.
pub trc: Option<[Trc; 3]>,
pub origin: SpaceOrigin,
}
impl SourceSpace {
pub fn named(n: NamedSpace, origin: SpaceOrigin) -> SourceSpace {
let t = n.trc();
SourceSpace { named: Some(n), to_xyz_d50: n.to_xyz_d50(), trc: Some([t.clone(), t.clone(), t]), origin }
}
/// Linear source RGB → linear `dst` RGB (Bradford from D50 to `dst`'s white).
pub fn to_space(&self, dst: &RgbSpace) -> Mat3 {
if let Some(n) = self.named {
let s = n.rgb_space();
if s == *dst {
return Mat3::IDENTITY;
}
return s.to_space(dst);
}
dst.from_xyz().mul(&bradford(D50, dst.white)).mul(&self.to_xyz_d50)
}
/// Linear source RGB → linear Rec.2020 D65 (the pipeline working space).
pub fn to_working(&self) -> Mat3 {
self.to_space(&WORKING)
}
/// True when the pixels need a matrix to reach the working space.
pub fn is_working(&self) -> bool {
self.named == Some(NamedSpace::Rec2020)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn named_recognition_roundtrip() {
for n in NamedSpace::ALL {
assert_eq!(NamedSpace::recognize(&n.to_xyz_d50()), Some(n));
}
}
#[test]
fn trc_inverse() {
let curves = [
Trc::Srgb,
Trc::Gamma(2.2),
Trc::Rec709,
Trc::Parametric(vec![2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045]),
Trc::Table((0..1024).map(|i| ((i as f32 / 1023.0).powf(2.2) * 65535.0) as u16).collect()),
];
for c in curves {
for i in 0..=20 {
let x = i as f32 / 20.0;
let y = c.to_linear(x);
assert!((c.from_linear(y) - x).abs() < 2e-3, "{c:?} {x}");
}
}
assert!(Trc::Srgb.approx_eq(&Trc::Parametric(vec![2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045]), 1e-5));
}
#[test]
fn working_matrix_identity_for_rec2020() {
let s = SourceSpace::named(NamedSpace::Rec2020, SpaceOrigin::Container);
assert_eq!(s.to_working(), Mat3::IDENTITY);
let p3 = SourceSpace::named(NamedSpace::DisplayP3, SpaceOrigin::Container);
let w = p3.to_working().apply([1.0, 1.0, 1.0]);
for c in w {
assert!((c - 1.0).abs() < 1e-6);
}
}
}

View File

@ -0,0 +1,179 @@
//! TIFF decode via the `tiff` crate: 8/16/32-bit integer, 16/32/64-bit float, gray/RGB/CMYK/palette,
//! alpha (associated or not), ICC (34675), XMP (700), orientation (274). First image only.
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::{DecodeOptions, Decoded, Error, Format, Result};
use tiff::ColorType;
use tiff::decoder::{Decoder, DecodingResult, Limits};
use tiff::tags::Tag;
const F: Format = Format::Tiff;
fn err(e: impl std::fmt::Display) -> Error {
Error::Malformed(F, e.to_string())
}
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let mut limits = Limits::default();
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
limits.ifd_value_size = 32 << 20;
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits);
let (w, h) = d.dimensions().map_err(err)?;
check_size(F, w as u64, h as u64, opts)?;
let ct = d.colortype().map_err(err)?;
let icc = tag_bytes(&mut d, Tag::IccProfile);
let xmp = tag_bytes(&mut d, Tag::from_u16_exhaustive(700)).map(|v| String::from_utf8_lossy(&v).trim_end_matches('\0').to_string());
let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16);
let photometric = d.get_tag_u32(Tag::PhotometricInterpretation).ok();
let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2);
let extra = d.get_tag_u16_vec(Tag::ExtraSamples).ok().unwrap_or_default();
let colormap = d.get_tag_u16_vec(Tag::ColorMap).ok();
let compression = d.get_tag_u32(Tag::Compression).ok();
let (model, n_color, n_samples, bits) = match ct {
ColorType::Gray(b) => (Model::Gray, 1, 1, b),
ColorType::GrayA(b) => (Model::Gray, 1, 2, b),
ColorType::RGB(b) => (Model::Rgb, 3, 3, b),
ColorType::RGBA(b) => (Model::Rgb, 3, 4, b),
ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b),
ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b),
ColorType::Palette(b) => (Model::Rgb, 1, 1, b),
// JPEG-compressed YCbCr is converted to RGB by the JPEG decoder.
ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b),
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth),
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
other => return Err(Error::Unsupported(F, unsupported_name(other))),
};
let has_alpha = n_samples > n_color && !matches!(ct, ColorType::Palette(_));
let premultiplied = has_alpha && extra.first() == Some(&1);
let res = d.read_image().map_err(err)?;
let (wu, hu) = (w as usize, h as usize);
let n = wu * hu;
let mut buf = match res {
DecodingResult::U8(v) => Buf::U8(v),
DecodingResult::U16(v) => Buf::U16(v),
// Rare integer widths: keep 16 significant bits (plenty for display-referred data).
DecodingResult::U32(v) => Buf::U16(v.into_iter().map(|x| (x >> 16) as u16).collect()),
DecodingResult::U64(v) => Buf::U16(v.into_iter().map(|x| (x >> 48) as u16).collect()),
DecodingResult::I8(v) => Buf::U8(v.into_iter().map(|x| (x.max(0) as u8) << 1).collect()),
DecodingResult::I16(v) => Buf::U16(v.into_iter().map(|x| (x.max(0) as u16) << 1).collect()),
DecodingResult::I32(v) => Buf::U16(v.into_iter().map(|x| (x.max(0) >> 15) as u16).collect()),
DecodingResult::I64(v) => Buf::U16(v.into_iter().map(|x| (x.max(0) >> 47) as u16).collect()),
DecodingResult::F16(v) => Buf::F32(v.into_iter().map(|x| x.to_f32()).collect()),
DecodingResult::F32(v) => Buf::F32(v),
DecodingResult::F64(v) => Buf::F32(v.into_iter().map(|x| x as f32).collect()),
};
let bit_depth = bits;
if planar && n_samples > 1 {
buf = interleave(buf, n, n_samples);
}
// Palette → 16-bit RGB.
if let ColorType::Palette(b) = ct {
let map = colormap.ok_or_else(|| err("palette image without ColorMap"))?;
let entries = 1usize << b.min(16);
if map.len() < 3 * entries {
return Err(err("short ColorMap"));
}
let idx: Vec<usize> = match &buf {
Buf::U8(v) => v.iter().map(|&i| i as usize).collect(),
Buf::U16(v) => v.iter().map(|&i| i as usize).collect(),
Buf::F32(_) => return Err(err("float palette")),
};
let mut out = Vec::with_capacity(n * 3);
for &i in idx.iter().take(n) {
let i = i.min(entries - 1);
out.extend_from_slice(&[map[i], map[entries + i], map[2 * entries + i]]);
}
buf = Buf::U16(out);
let raw = Raw { width: wu, height: hu, model: Model::Rgb, alpha: false, premultiplied: false, buf, bit_depth };
let meta = Meta { icc, xmp, orientation, ..Default::default() };
return finish(F, raw, meta, (w, h), opts);
}
// WhiteIsZero → invert.
if photometric == Some(0) {
match &mut buf {
Buf::U8(v) => v.iter_mut().for_each(|x| *x = 255 - *x),
Buf::U16(v) => v.iter_mut().for_each(|x| *x = 65535 - *x),
Buf::F32(v) => v.iter_mut().for_each(|x| *x = 1.0 - *x),
}
}
// Drop extra samples beyond colour + one alpha.
let keep = n_color + has_alpha as usize;
if n_samples != keep {
buf = select_channels(buf, n, n_samples, keep);
}
let raw = Raw { width: wu, height: hu, model, alpha: has_alpha, premultiplied, buf, bit_depth };
let meta = Meta { icc, xmp, orientation, ..Default::default() };
finish(F, raw, meta, (w, h), opts)
}
/// A BYTE/UNDEFINED tag as bytes (the decoder may surface unknown byte tags as unsigned values).
fn tag_bytes<R: std::io::Read + std::io::Seek>(d: &mut Decoder<R>, tag: Tag) -> Option<Vec<u8>> {
use tiff::decoder::ifd::Value;
fn byte(v: &Value) -> Option<u8> {
match v {
Value::Byte(b) => Some(*b),
Value::Unsigned(u) => u8::try_from(*u).ok(),
Value::Short(u) => u8::try_from(*u).ok(),
Value::SignedByte(b) => Some(*b as u8),
_ => None,
}
}
match d.find_tag(tag).ok()?? {
Value::List(v) => v.iter().map(byte).collect(),
Value::Ascii(s) => Some(s.into_bytes()),
v => byte(&v).map(|b| vec![b]),
}
}
fn unsupported_name(ct: ColorType) -> &'static str {
match ct {
ColorType::Lab(_) => "CIE L*a*b* TIFF",
ColorType::YCbCr(_) => "uncompressed YCbCr TIFF",
_ => "colour type",
}
}
fn interleave(buf: Buf, n: usize, s: usize) -> Buf {
fn go<T: Copy + Default>(v: Vec<T>, n: usize, s: usize) -> Vec<T> {
if v.len() < n * s {
return v;
}
let mut out = vec![T::default(); n * s];
for c in 0..s {
for i in 0..n {
out[i * s + c] = v[c * n + i];
}
}
out
}
match buf {
Buf::U8(v) => Buf::U8(go(v, n, s)),
Buf::U16(v) => Buf::U16(go(v, n, s)),
Buf::F32(v) => Buf::F32(go(v, n, s)),
}
}
fn select_channels(buf: Buf, n: usize, s: usize, keep: usize) -> Buf {
fn go<T: Copy>(v: Vec<T>, n: usize, s: usize, keep: usize) -> Vec<T> {
if v.len() < n * s {
return v;
}
let mut out = Vec::with_capacity(n * keep);
for px in v.chunks_exact(s).take(n) {
out.extend_from_slice(&px[..keep]);
}
out
}
match buf {
Buf::U8(v) => Buf::U8(go(v, n, s, keep)),
Buf::U16(v) => Buf::U16(go(v, n, s, keep)),
Buf::F32(v) => Buf::F32(go(v, n, s, keep)),
}
}

26
crates/codecs/src/webp.rs Normal file
View File

@ -0,0 +1,26 @@
//! WebP decode via `image-webp` (lossy + lossless, alpha, ICCP/EXIF/XMP chunks; first frame of
//! animations).
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::{DecodeOptions, Decoded, Error, Format, Result};
const F: Format = Format::WebP;
fn err(e: impl std::fmt::Display) -> Error {
Error::Malformed(F, e.to_string())
}
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let mut d = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).map_err(err)?;
let (w, h) = d.dimensions();
check_size(F, w as u64, h as u64, opts)?;
let alpha = d.has_alpha();
let size = d.output_buffer_size().ok_or_else(|| err("buffer size overflow"))?;
let mut buf = vec![0u8; size];
d.read_image(&mut buf).map_err(err)?;
let icc = d.icc_profile().ok().flatten();
let exif = d.exif_metadata().ok().flatten().map(|e| if e.starts_with(b"Exif\0\0") { e[6..].to_vec() } else { e });
let xmp = d.xmp_metadata().ok().flatten().map(|x| String::from_utf8_lossy(&x).trim_end_matches('\0').to_string());
let raw = Raw { width: w as usize, height: h as usize, model: Model::Rgb, alpha, premultiplied: false, buf: Buf::U8(buf), bit_depth: 8 };
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (w, h), opts)
}

View File

@ -0,0 +1,452 @@
//! Encode → decode round trips on generated fixtures.
use lightcraft_codecs::exif::minimal_exif;
use lightcraft_codecs::icc::{self, write_named};
use lightcraft_codecs::*;
use lightcraft_color::{DISPLAY_P3, REC2020, transfer::srgb_to_linear};
use lightcraft_raster::Rgba8;
fn gradient(w: usize, h: usize) -> Rgba8 {
Rgba8::from_fn(w, h, |x, y| [(x * 255 / w.max(2).saturating_sub(1).max(1)) as u8, (y * 255 / h.max(2)) as u8, ((x + y) % 256) as u8, 255])
}
fn smooth(w: usize, h: usize) -> Rgba8 {
Rgba8::from_fn(w, h, |x, y| {
let fx = x as f32 / w as f32;
let fy = y as f32 / h as f32;
[(fx * 255.0) as u8, (fy * 255.0) as u8, ((1.0 - fx) * 200.0) as u8, (((fx + fy) * 0.5) * 255.0) as u8]
})
}
fn psnr8(a: &Rgba8, b: &Rgba8) -> f64 {
let mut se = 0.0f64;
for (p, q) in a.data.iter().zip(&b.data) {
for c in 0..3 {
let d = p[c] as f64 - q[c] as f64;
se += d * d;
}
}
let mse = se / (a.data.len() * 3) as f64;
if mse == 0.0 { f64::INFINITY } else { 10.0 * (255.0 * 255.0 / mse).log10() }
}
#[test]
fn jpeg_roundtrip_with_metadata() {
let img = smooth(97, 61);
let icc = write_named(NamedSpace::Srgb);
let exif = minimal_exif(6);
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF/></x:xmpmeta>"#;
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(xmp) };
for sub in [ChromaSubsampling::S444, ChromaSubsampling::S422, ChromaSubsampling::S420] {
let bytes = encode_jpeg(&EncodeImage::rgba8(&img), 95, sub, &meta).unwrap();
assert_eq!(sniff(&bytes), Some(Format::Jpeg));
let d = decode(&bytes, DecodeOptions::default()).unwrap();
assert_eq!((d.width, d.height), (97, 61));
assert_eq!(d.orientation, 6, "orientation passes through, not applied");
assert_eq!(d.exif.as_deref(), Some(&exif[..]));
assert_eq!(d.xmp.as_deref(), Some(xmp));
assert_eq!(d.icc.as_deref(), Some(&icc[..]));
assert_eq!(d.space.named, Some(NamedSpace::Srgb));
assert_eq!(d.space.origin, SpaceOrigin::IccMatrixTrc);
assert!(!d.has_alpha);
let back = d.to_srgb8();
let p = psnr8(&img, &back);
assert!(p > 32.0, "{sub:?} psnr {p}");
}
}
#[test]
fn jpeg_gray_and_scaled_decode() {
let g: Vec<u8> = (0..128 * 96).map(|i| (i % 128 * 2) as u8).collect();
let bytes = encode_jpeg(&EncodeImage::new(128, 96, 1, Samples::U8(&g)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
let d = decode(&bytes, DecodeOptions::default()).unwrap();
assert!(d.grayscale);
assert_eq!(d.space.origin, SpaceOrigin::Untagged);
let p = d.image.get(64, 10);
assert!((p[0] - p[1]).abs() < 1e-6 && (p[1] - p[2]).abs() < 1e-6);
let big = smooth(1000, 600);
let bytes = encode_jpeg(&EncodeImage::rgba8(&big), 85, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
let d = decode(&bytes, DecodeOptions::fit(100, 100)).unwrap();
assert_eq!((d.width, d.height), (100, 60));
assert_eq!((d.source_width, d.source_height), (1000, 600));
// Colour of the scaled decode matches a box-filtered full decode.
let full = decode(&bytes, DecodeOptions::default()).unwrap();
let a = d.image.get(50, 30);
let b = full.image.get(505, 305);
for c in 0..3 {
assert!((a[c] - b[c]).abs() < 0.03, "{a:?} {b:?}");
}
}
#[test]
fn jpeg_cmyk_converts() {
// 0 = no ink: white, cyan, black.
let px: Vec<u8> = [[0u8, 0, 0, 0], [255, 0, 0, 0], [0, 0, 0, 255]].iter().flat_map(|p| p.repeat(16 * 16)).collect();
let img: Vec<u8> = px;
let mut out = Vec::new();
let enc = jpeg_encoder::Encoder::new(&mut out, 95);
enc.encode(&img, 16, 48, jpeg_encoder::ColorType::Cmyk).unwrap();
let d = decode(&out, DecodeOptions::default()).unwrap();
assert!(d.cmyk);
assert_eq!(d.space.origin, SpaceOrigin::Naive);
let white = d.image.get(8, 8);
let cyan = d.image.get(8, 24);
let black = d.image.get(8, 40);
assert!(white.iter().all(|&v| v > 0.9), "{white:?}");
assert!(cyan[0] < 0.05 && cyan[1] > 0.8 && cyan[2] > 0.8, "{cyan:?}");
assert!(black.iter().all(|&v| v < 0.02), "{black:?}");
}
#[test]
fn png_8_and_16_bit_exact() {
let img = gradient(33, 17);
let bytes = encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap();
let d = decode(&bytes, DecodeOptions::default()).unwrap();
assert!(d.has_alpha);
assert_eq!(d.bit_depth, 8);
assert_eq!(d.to_srgb8(), img);
// 16-bit: precision preserved exactly through linearization.
let (w, h) = (64u32, 8u32);
let data: Vec<u16> = (0..w * h * 3).map(|i| (i * 1021 % 65536) as u16).collect();
let bytes = encode_png(&EncodeImage::new(w, h, 3, Samples::U16(&data)), &EncodeMeta::default()).unwrap();
let d = decode(&bytes, DecodeOptions::default()).unwrap();
assert_eq!(d.bit_depth, 16);
for (i, p) in d.image.data.iter().enumerate() {
for c in 0..3 {
let want = Trc::Srgb.to_linear(data[i * 3 + c] as f32 / 65535.0);
assert!((p[c] - want).abs() < 1e-6);
}
}
// Two adjacent 16-bit codes stay distinct.
let data = [30000u16, 30000, 30000, 30001, 30001, 30001];
let bytes = encode_png(&EncodeImage::new(2, 1, 3, Samples::U16(&data)), &EncodeMeta::default()).unwrap();
let d = decode(&bytes, DecodeOptions::default()).unwrap();
assert!(d.image.get(1, 0)[0] > d.image.get(0, 0)[0]);
}
#[test]
fn png_metadata_and_gray_alpha() {
let data: Vec<u8> = (0..20 * 10).flat_map(|i| [(i % 256) as u8, 128]).collect();
let exif = minimal_exif(3);
let meta = EncodeMeta { icc: None, exif: Some(&exif), xmp: Some("<xmp/>") };
let bytes = encode_png(&EncodeImage::new(20, 10, 2, Samples::U8(&data)), &meta).unwrap();
let d = decode(&bytes, DecodeOptions::default()).unwrap();
assert!(d.grayscale && d.has_alpha);
assert_eq!(d.orientation, 3);
assert_eq!(d.xmp.as_deref(), Some("<xmp/>"));
let a = d.alpha.as_ref().unwrap().get(0, 0);
assert!((a - 128.0 / 255.0).abs() < 1e-6);
}
#[test]
fn p3_icc_to_working() {
let icc = write_named(NamedSpace::DisplayP3);
let data = [255u8, 0, 0, 0, 255, 0, 128, 128, 128];
let bytes = encode_png(&EncodeImage::new(3, 1, 3, Samples::U8(&data)), &EncodeMeta { icc: Some(&icc), ..Default::default() }).unwrap();
let d = decode(&bytes, DecodeOptions::default()).unwrap();
assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
assert_eq!(d.space.origin, SpaceOrigin::IccMatrixTrc);
let wk = to_working(&d);
let m = DISPLAY_P3.to_space(&REC2020);
for (i, px) in [[1.0f32, 0.0, 0.0], [0.0, 1.0, 0.0], [srgb_to_linear(128.0 / 255.0); 3]].iter().enumerate() {
let want = m.apply_f32(*px);
let got = wk.get(i, 0);
for c in 0..3 {
assert!((got[c] - want[c]).abs() < 1e-3, "{i}: {got:?} vs {want:?}");
}
}
// Gray stays neutral in the working space.
let g = wk.get(2, 0);
assert!((g[0] - g[1]).abs() < 1e-4 && (g[1] - g[2]).abs() < 1e-4);
}
#[test]
fn custom_matrix_icc_is_exact() {
// A non-standard space: goes through the generic matrix/TRC path, not the named one.
let space = lightcraft_color::RgbSpace {
name: "Custom",
r: lightcraft_color::Xy::new(0.66, 0.32),
g: lightcraft_color::Xy::new(0.25, 0.65),
b: lightcraft_color::Xy::new(0.14, 0.07),
white: lightcraft_color::D65,
};
let icc = icc::write_matrix_trc(&space, &Trc::Gamma(2.0));
let data = [255u8, 0, 0];
let bytes = encode_png(&EncodeImage::new(1, 1, 3, Samples::U8(&data)), &EncodeMeta { icc: Some(&icc), ..Default::default() }).unwrap();
let d = decode(&bytes, DecodeOptions::default()).unwrap();
assert_eq!(d.space.named, None);
let got = to_working(&d).get(0, 0);
let want = space.to_space(&REC2020).apply_f32([1.0, 0.0, 0.0]);
for c in 0..3 {
assert!((got[c] - want[c]).abs() < 2e-3, "{got:?} vs {want:?}");
}
}
#[test]
fn tiff_depths_and_compressions() {
let (w, h) = (40u32, 24u32);
let n = (w * h) as usize;
let u8s: Vec<u8> = (0..n * 3).map(|i| (i * 7 % 256) as u8).collect();
let u16s: Vec<u16> = (0..n * 4).map(|i| (i * 977 % 65536) as u16).collect();
let f32s: Vec<f32> = (0..n * 3).map(|i| i as f32 / 100.0 - 3.0).collect();
let icc = write_named(NamedSpace::AdobeRgb);
for comp in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] {
let b = encode_tiff(&EncodeImage::new(w, h, 3, Samples::U8(&u8s)), comp, &EncodeMeta::default()).unwrap();
assert_eq!(sniff(&b), Some(Format::Tiff));
let d = decode(&b, DecodeOptions::default()).unwrap();
assert_eq!(d.bit_depth, 8);
for (i, p) in d.image.data.iter().enumerate() {
assert!((p[1] - srgb_to_linear(u8s[i * 3 + 1] as f32 / 255.0)).abs() < 1e-6);
}
let meta = EncodeMeta { icc: Some(&icc), xmp: Some("<x/>"), ..Default::default() };
let b = encode_tiff(&EncodeImage::new(w, h, 4, Samples::U16(&u16s)), comp, &meta).unwrap();
let d = decode(&b, DecodeOptions::default()).unwrap();
assert_eq!(d.bit_depth, 16);
assert!(d.has_alpha);
assert_eq!(d.space.named, Some(NamedSpace::AdobeRgb));
assert_eq!(d.xmp.as_deref(), Some("<x/>"));
let a = d.alpha.as_ref().unwrap();
for i in 0..n {
assert!((a.data[i] - u16s[i * 4 + 3] as f32 / 65535.0).abs() < 1e-6);
let want = Trc::Gamma(563.0 / 256.0).to_linear(u16s[i * 4] as f32 / 65535.0);
assert!((d.image.data[i][0] - want).abs() < 1e-4, "{} vs {want}", d.image.data[i][0]);
}
// Float: exact (untagged float is taken as linear), including out-of-range values.
let b = encode_tiff(&EncodeImage::new(w, h, 3, Samples::F32(&f32s)), comp, &EncodeMeta::default()).unwrap();
let d = decode(&b, DecodeOptions::default()).unwrap();
assert!(d.float);
assert_eq!(d.bit_depth, 32);
for (i, p) in d.image.data.iter().enumerate() {
assert_eq!(p[2], f32s[i * 3 + 2]);
}
}
}
#[test]
fn tiff_cmyk_and_orientation() {
use tiff::encoder::{TiffEncoder, colortype};
let mut cur = std::io::Cursor::new(Vec::new());
{
let mut enc = TiffEncoder::new(&mut cur).unwrap();
let mut im = enc.new_image::<colortype::CMYK8>(2, 1).unwrap();
im.encoder().write_tag(tiff::tags::Tag::Orientation, 8u16).unwrap();
im.write_data(&[0u8, 0, 0, 0, 0, 255, 255, 0]).unwrap();
}
let d = decode(&cur.into_inner(), DecodeOptions::default()).unwrap();
assert!(d.cmyk);
assert_eq!(d.orientation, 8);
let white = d.image.get(0, 0);
let red = d.image.get(1, 0);
assert!(white.iter().all(|&v| (v - 1.0).abs() < 1e-5), "{white:?}");
assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}");
}
#[test]
fn webp_lossless_exact() {
let img = gradient(31, 19);
let icc = write_named(NamedSpace::Srgb);
let exif = minimal_exif(2);
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some("<w/>") };
let bytes = encode_webp_lossless(&EncodeImage::rgba8(&img), &meta).unwrap();
assert_eq!(sniff(&bytes), Some(Format::WebP));
let d = decode(&bytes, DecodeOptions::default()).unwrap();
assert_eq!(d.to_srgb8(), img);
assert_eq!(d.orientation, 2);
assert_eq!(d.icc.as_deref(), Some(&icc[..]));
assert_eq!(d.xmp.as_deref(), Some("<w/>"));
}
#[test]
fn gif_and_bmp_decode() {
use image::ImageEncoder;
let img = gradient(16, 8);
let rgba = img.as_bytes();
let mut bmp = Vec::new();
image::codecs::bmp::BmpEncoder::new(&mut bmp).write_image(&rgba, 16, 8, image::ExtendedColorType::Rgba8).unwrap();
assert_eq!(sniff(&bmp), Some(Format::Bmp));
let d = decode(&bmp, DecodeOptions::default()).unwrap();
assert_eq!(d.to_srgb8(), img);
let mut gif = Vec::new();
{
let mut enc = image::codecs::gif::GifEncoder::new(&mut gif);
let flat = Rgba8::filled(16, 8, [10, 200, 30, 255]);
let buf = image::RgbaImage::from_raw(16, 8, flat.as_bytes()).unwrap();
enc.encode_frame(image::Frame::new(buf)).unwrap();
}
assert_eq!(sniff(&gif), Some(Format::Gif));
let d = decode(&gif, DecodeOptions::default()).unwrap();
let p = d.to_srgb8().get(3, 3);
assert!((p[0] as i32 - 10).abs() <= 2 && (p[1] as i32 - 200).abs() <= 2, "{p:?}");
}
/// Hand-built PSD: RGB 8-bit, raw and RLE merged data, ICC resource, negative layer count (alpha).
fn build_psd(rle: bool, with_alpha: bool, icc: &[u8]) -> Vec<u8> {
let (w, h) = (3u32, 2u32);
let channels: u16 = if with_alpha { 4 } else { 3 };
let mut b = b"8BPS".to_vec();
b.extend_from_slice(&1u16.to_be_bytes());
b.extend_from_slice(&[0; 6]);
b.extend_from_slice(&channels.to_be_bytes());
b.extend_from_slice(&h.to_be_bytes());
b.extend_from_slice(&w.to_be_bytes());
b.extend_from_slice(&8u16.to_be_bytes());
b.extend_from_slice(&3u16.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data
// resources: ICC
let mut res = b"8BIM".to_vec();
res.extend_from_slice(&1039u16.to_be_bytes());
res.extend_from_slice(&[0, 0]); // empty pascal name, padded
res.extend_from_slice(&(icc.len() as u32).to_be_bytes());
res.extend_from_slice(icc);
if icc.len() % 2 == 1 {
res.push(0);
}
b.extend_from_slice(&(res.len() as u32).to_be_bytes());
b.extend_from_slice(&res);
// layer & mask info: layer info length + negative layer count
if with_alpha {
let mut lm = Vec::new();
lm.extend_from_slice(&2u32.to_be_bytes());
lm.extend_from_slice(&(-1i16).to_be_bytes());
b.extend_from_slice(&(lm.len() as u32).to_be_bytes());
b.extend_from_slice(&lm);
} else {
b.extend_from_slice(&0u32.to_be_bytes());
}
let planes: Vec<[u8; 6]> = vec![[255, 0, 0, 10, 20, 30], [0, 255, 0, 40, 50, 60], [0, 0, 255, 70, 80, 90], [255, 255, 128, 0, 64, 255]];
let planes = &planes[..channels as usize];
if rle {
b.extend_from_slice(&1u16.to_be_bytes());
// Each row: literal run of 3 bytes = [2, a, b, c] (4 bytes)
for _ in 0..channels as usize * h as usize {
b.extend_from_slice(&4u16.to_be_bytes());
}
for p in planes {
for row in p.chunks(3) {
b.push(2);
b.extend_from_slice(row);
}
}
} else {
b.extend_from_slice(&0u16.to_be_bytes());
for p in planes {
b.extend_from_slice(p);
}
}
b
}
#[test]
fn psd_composite() {
let icc = write_named(NamedSpace::ProPhoto);
for rle in [false, true] {
for alpha in [false, true] {
let b = build_psd(rle, alpha, &icc);
assert_eq!(sniff(&b), Some(Format::Psd));
let d = decode(&b, DecodeOptions::default()).unwrap();
assert_eq!((d.width, d.height), (3, 2));
assert_eq!(d.has_alpha, alpha);
assert_eq!(d.space.named, Some(NamedSpace::ProPhoto));
let p = d.image.get(0, 0);
assert!((p[0] - 1.0).abs() < 1e-5 && p[1].abs() < 1e-6 && p[2].abs() < 1e-6);
let q = d.image.get(1, 1);
assert!((q[0] - Trc::Gamma(1.8).to_linear(20.0 / 255.0)).abs() < 1e-4);
if alpha {
assert!((d.alpha.as_ref().unwrap().get(2, 1) - 1.0).abs() < 1e-6);
}
}
}
}
#[test]
fn thumbnail_prefers_embedded_exif() {
// Main image 800x600 with a 200x150 EXIF thumbnail of a different colour.
let main = Rgba8::filled(800, 600, [200, 30, 30, 255]);
let thumb = Rgba8::filled(200, 150, [30, 30, 200, 255]);
let thumb_jpeg = encode_jpeg(&EncodeImage::rgba8(&thumb), 80, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
let exif = exif_with_thumbnail(&thumb_jpeg, 6);
let bytes =
encode_jpeg(&EncodeImage::rgba8(&main), 80, ChromaSubsampling::S420, &EncodeMeta { exif: Some(&exif), ..Default::default() }).unwrap();
let t = decode_thumbnail(&bytes, 128).unwrap();
assert_eq!(t.source, ThumbnailSource::ExifThumbnail);
assert_eq!(t.orientation, 6);
assert_eq!((t.image.width, t.image.height), (128, 96));
assert!(t.image.get(10, 10)[2] > 150);
assert_eq!((t.source_width, t.source_height), (800, 600));
// Asking for more than the thumbnail offers falls back to a scaled decode.
let t = decode_thumbnail(&bytes, 256).unwrap();
assert_eq!(t.source, ThumbnailSource::Scaled);
assert_eq!((t.image.width, t.image.height), (256, 192));
assert!(t.image.get(10, 10)[0] > 150);
}
/// Little-endian EXIF with IFD0 (orientation) → IFD1 (JPEGInterchangeFormat/Length) + the thumbnail.
fn exif_with_thumbnail(jpeg: &[u8], orientation: u16) -> Vec<u8> {
let mut v = b"II*\0".to_vec();
v.extend_from_slice(&8u32.to_le_bytes());
// IFD0 at 8: 1 entry, next IFD at 26
v.extend_from_slice(&1u16.to_le_bytes());
v.extend_from_slice(&0x0112u16.to_le_bytes());
v.extend_from_slice(&3u16.to_le_bytes());
v.extend_from_slice(&1u32.to_le_bytes());
v.extend_from_slice(&orientation.to_le_bytes());
v.extend_from_slice(&[0, 0]);
v.extend_from_slice(&26u32.to_le_bytes());
// IFD1 at 26: 2 entries (30 bytes), data at 56
v.extend_from_slice(&2u16.to_le_bytes());
v.extend_from_slice(&0x0201u16.to_le_bytes());
v.extend_from_slice(&4u16.to_le_bytes());
v.extend_from_slice(&1u32.to_le_bytes());
v.extend_from_slice(&56u32.to_le_bytes());
v.extend_from_slice(&0x0202u16.to_le_bytes());
v.extend_from_slice(&4u16.to_le_bytes());
v.extend_from_slice(&1u32.to_le_bytes());
v.extend_from_slice(&(jpeg.len() as u32).to_le_bytes());
v.extend_from_slice(&0u32.to_le_bytes());
assert_eq!(v.len(), 56);
v.extend_from_slice(jpeg);
v
}
#[test]
fn working_space_identity_for_srgb_white() {
let img = Rgba8::filled(4, 4, [255, 255, 255, 255]);
let b = encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap();
let d = decode(&b, DecodeOptions::default()).unwrap();
let w = to_working(&d).get(1, 1);
for c in w {
assert!((c - 1.0).abs() < 1e-5);
}
}
#[test]
fn avif_encode_native() {
let img = smooth(64, 48);
let r = encode_avif(&EncodeImage::rgba8(&img), 70, 10, &EncodeMeta::default());
if cfg!(feature = "avif") {
let b = r.unwrap();
assert_eq!(sniff(&b), Some(Format::Avif));
assert!(matches!(decode(&b, DecodeOptions::default()), Err(Error::Unsupported(Format::Avif, _))));
} else {
assert!(r.is_err());
}
}
#[test]
fn raw_routing() {
let mut b = b"II*\0\x08\0\0\0".to_vec();
b.extend_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&0xC612u16.to_le_bytes());
b.extend_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&4u32.to_le_bytes());
b.extend_from_slice(&[1, 4, 0, 0]);
b.extend_from_slice(&0u32.to_le_bytes());
assert!(matches!(decode(&b, DecodeOptions::default()), Err(Error::Unsupported(Format::RawTiffLike, _))));
}