Merge agent-codecs: lightcraft-codecs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1c073db30f
File diff suppressed because it is too large
Load Diff
10
Cargo.toml
10
Cargo.toml
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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, _))));
|
||||
}
|
||||
Loading…
Reference in New Issue