mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 11:37:13 -06:00
Rust 1.99 (stable since 2026-09-28) adds clippy::chunks_exact_to_as_chunks and widens clippy::question_mark, which failed `cargo xtask ci` on main. Same behaviour: the remainder that chunks_exact skipped is the remainder as_chunks returns separately. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
117 lines
5.0 KiB
Rust
117 lines
5.0 KiB
Rust
//! 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.as_chunks::<2>().0.iter().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))
|
|
}
|