mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
M1.1: codecs fuzz/corpus/bench tests, thumbnail options, README
Property tests (random bytes behind every magic, mutated/truncated files), corpus tests (corpus/images, pngsuite, raw routing, system ICC profiles), ignored 24 MP JPEG benchmark, decode_thumbnail_with(min_embedded_edge), fast deflate levels for PNG/TIFF, README with formats, licences, gaps. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
304180a500
commit
fa59a060dc
8 files changed
+483
-8
No files matched your search
@@ -0,0 +1,104 @@
|
||||
# lightcraft-codecs (L1)
|
||||
|
||||
Standard image formats for LightCraft: format sniffing, decoding to **linear light** with colour
|
||||
management, fast thumbnails, and encoders with metadata embedding. Pure Rust, no C dependencies,
|
||||
builds for `wasm32-unknown-unknown`.
|
||||
|
||||
## API at a glance
|
||||
|
||||
```rust
|
||||
let fmt: Option<Format> = sniff(&bytes); // JPEG/PNG/TIFF/WebP/AVIF/HEIF/JXL/GIF/BMP/PSD/raw
|
||||
let d: Decoded = decode(&bytes, DecodeOptions::default())?;
|
||||
// d.image: Rgb32f, linear light in the source primaries (d.space: SourceSpace)
|
||||
// d.alpha, d.bit_depth, d.float, d.orientation (EXIF value, NOT applied), d.exif/xmp/icc (raw blobs)
|
||||
let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford)
|
||||
let fast = decode(&bytes, DecodeOptions::fit(2048, 2048))?; // JPEG: DCT-domain 1/2..1/8 scaling
|
||||
let t: Thumbnail = decode_thumbnail(&bytes, 256)?; // embedded EXIF/MPF preview or scaled decode
|
||||
|
||||
let icc = icc::write_named(NamedSpace::DisplayP3); // or icc::write_matrix_trc(&space, &Trc::…)
|
||||
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(&xmp) };
|
||||
encode_jpeg(&EncodeImage::rgba8(&img), 90, ChromaSubsampling::S444, &meta)?;
|
||||
encode_png(&EncodeImage::new(w, h, 4, Samples::U16(&px)), &meta)?;
|
||||
encode_tiff(&EncodeImage::new(w, h, 3, Samples::F32(&px)), TiffCompression::Deflate, &meta)?;
|
||||
encode_webp_lossless(&img, &meta)?;
|
||||
encode_avif(&img, 70, 6, &meta)?; // native + feature `avif`
|
||||
```
|
||||
|
||||
`Format::RawTiffLike` (DNG, CR2, NEF, ARW, PEF, ORF, RW2, SRW, …) and `Format::RawOther` (CR3, RAF,
|
||||
CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `decode` returns
|
||||
`Error::Unsupported` for them.
|
||||
|
||||
## Formats
|
||||
|
||||
| Format | Decode | Encode | Notes |
|
||||
|---|---|---|---|
|
||||
| JPEG | yes (zune-jpeg; jpeg-decoder for DCT scaling, CMYK/YCCK, 12-bit, non-interleaved scans) | yes (jpeg-encoder, baseline, 4:4:4/4:2:2/4:2:0) | EXIF (APP1), XMP (APP1, ≤ 64 KiB), ICC (APP2, multi-chunk), Adobe APP14, MPF previews |
|
||||
| PNG | yes, 1–16-bit, palette, tRNS, interlaced | 8/16-bit, gray/GA/RGB/RGBA | iCCP, eXIf, iTXt XMP; sRGB/cICP/cHRM/gAMA honoured when no ICC |
|
||||
| TIFF | 8/16-bit int (32/64-bit int kept to 16 bits), 16/32/64-bit float, gray/RGB/CMYK/palette, alpha (assoc./unassoc.), planar, LZW/Deflate/PackBits/JPEG | 8/16-bit, 32-bit float; none/LZW/Deflate/PackBits | ICC (34675), XMP (700), Orientation; first IFD only; EXIF not written (use `lightcraft-tiff`) |
|
||||
| WebP | lossy + lossless, alpha, first frame of animations | lossless only | ICCP/EXIF/XMP chunks |
|
||||
| GIF / BMP | yes (first GIF frame) | — | via `image` |
|
||||
| PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | — | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported |
|
||||
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | — | enum colour → rendered straight to linear Rec.2020; ICC → our ICC path; orientation applied by the decoder (reported as 1) |
|
||||
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8-bit sRGB, EXIF; no ICC in the muxer |
|
||||
| HEIC/HEIF | **no** (sniff only) | — | see gaps |
|
||||
|
||||
## Colour
|
||||
|
||||
Decoders never guess silently; `SourceSpace::origin` says where the interpretation came from:
|
||||
`IccMatrixTrc` (profile applied exactly: TRC linearization + colorants → XYZ D50), `IccCms` (LUT-based
|
||||
or CMYK profile converted by the CMS straight to linear Rec.2020), `Container` (PNG chunks, JXL enum,
|
||||
EXIF `R03` Adobe RGB hint), `Untagged` (assumed sRGB; untagged float data assumed linear),
|
||||
`IccUnsupported` (profile present but unusable → **sRGB fallback**), `Naive` (CMYK without profile).
|
||||
|
||||
ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB, Display P3,
|
||||
Adobe RGB (1998), ProPhoto (ROMM) and Rec.2020 are recognised by colorants. `icc::write_matrix_trc`
|
||||
emits v4 display profiles (D50 PCS, Bradford `chad`) for export embedding.
|
||||
|
||||
## Dependencies and licences
|
||||
|
||||
All pure Rust, verified with `cargo info` / `cargo tree`:
|
||||
|
||||
| Crate | Licence | Use |
|
||||
|---|---|---|
|
||||
| zune-jpeg, zune-core | MIT OR Apache-2.0 OR Zlib | JPEG decode |
|
||||
| jpeg-decoder | MIT OR Apache-2.0 | scaled / CMYK JPEG decode |
|
||||
| jpeg-encoder | (MIT OR Apache-2.0) AND IJG | JPEG encode (IJG: permissive, attribution in docs) |
|
||||
| png | MIT OR Apache-2.0 | PNG |
|
||||
| tiff | MIT | TIFF |
|
||||
| image-webp | MIT OR Apache-2.0 | WebP |
|
||||
| image (gif, bmp only) | MIT OR Apache-2.0 | GIF/BMP |
|
||||
| moxcms | BSD-3-Clause OR Apache-2.0 | ICC parse/write, CMS |
|
||||
| jxl-oxide | MIT OR Apache-2.0 | JPEG XL decode |
|
||||
| ravif (+ rav1e, avif-serialize) | BSD-3-Clause / BSD-2-Clause | AVIF encode (native only; heavy to compile) |
|
||||
|
||||
Deliberately **not** used: anything AGPL from imazen (heic, zenjpeg, jxl-encoder, rav1d-safe),
|
||||
jpegxl-rs / imagequant (GPL / C), dav1d / libheif (C).
|
||||
|
||||
## Performance (Apple M-series, release, synthetic 24 MP JPEG q90 4:2:0)
|
||||
|
||||
Run `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocapture`.
|
||||
|
||||
| Operation | Time |
|
||||
|---|---|
|
||||
| full decode → linear `Rgb32f` | ~92 ms (≈ 260 MP/s) |
|
||||
| decode fit 2048 (DCT 1/2) | ~81 ms |
|
||||
| thumbnail 256, scaled decode (DCT 1/8) | ~54 ms (entropy decoding bound) |
|
||||
| thumbnail 256 from embedded EXIF/MPF preview | ~0.6 ms |
|
||||
| `to_working` matrix | ~23 ms |
|
||||
| PNG / TIFF-deflate encode (fast levels) | ~430 ms / ~900 ms |
|
||||
|
||||
## Gaps / limitations
|
||||
|
||||
- **AVIF and HEIC decode**: no permissively licensed, pure-Rust AV1/HEVC decoder exists today
|
||||
(dav1d/libheif are C; rav1d-safe and imazen's heic are AGPL). Files are sniffed and rejected with
|
||||
`Error::Unsupported`; a raw-style embedded-preview path could be added for HEIC thumbnails.
|
||||
- JPEG: arithmetic coding unsupported (as in both decoders); extended XMP (> 64 KiB) neither read
|
||||
nor written; EXIF > 64 KiB not written. The encoder uses standard Huffman tables (interleaved
|
||||
baseline) for maximum compatibility.
|
||||
- Thumbnails under 15 ms need an embedded preview ≥ the requested edge (EXIF IFD1 or MPF). Most
|
||||
cameras store a 160 px EXIF thumbnail: use `decode_thumbnail_with` with `min_embedded_edge: 160`
|
||||
for an instant placeholder, otherwise the DCT-scaled path takes ~50 ms for 24 MP.
|
||||
- TIFF: first image only; EXIF is not re-emitted on encode; Lab/uncompressed-YCbCr unsupported.
|
||||
- PSD: only the merged composite (no layers); ZIP-compressed composites unsupported.
|
||||
- Panics inside third-party decoders are caught (`catch_unwind`) on unwinding targets; on wasm32
|
||||
(`panic = abort`) we rely on the property tests of our own code plus the decoders' own hardening.
|
||||
@@ -147,7 +147,7 @@ pub fn encode_png(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
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);
|
||||
enc.set_compression(png::Compression::Fast);
|
||||
if let Some(xmp) = meta.xmp {
|
||||
enc.add_itxt_chunk("XML:com.adobe.xmp".into(), xmp.into()).map_err(e)?;
|
||||
}
|
||||
@@ -179,7 +179,7 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod
|
||||
let comp = match compression {
|
||||
TiffCompression::None => Compression::Uncompressed,
|
||||
TiffCompression::Lzw => Compression::Lzw,
|
||||
TiffCompression::Deflate => Compression::Deflate(DeflateLevel::Balanced),
|
||||
TiffCompression::Deflate => Compression::Deflate(DeflateLevel::Fast),
|
||||
TiffCompression::PackBits => Compression::Packbits,
|
||||
};
|
||||
let float = matches!(img.samples, Samples::F32(_));
|
||||
|
||||
@@ -179,7 +179,7 @@ fn scaled_request(w: u32, h: u32, mw: u32, mh: u32) -> (u16, 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> {
|
||||
pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_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);
|
||||
@@ -202,7 +202,13 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32) -> Option<Thumbnai
|
||||
&& 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) {
|
||||
// Prefer the smallest preview covering `max_edge`; otherwise the largest one ≥ `min_edge`.
|
||||
let better = match &best {
|
||||
None => true,
|
||||
Some(b) if b.0 >= max_edge => long >= max_edge && long < b.0,
|
||||
Some(b) => long > b.0,
|
||||
};
|
||||
if long >= min_edge && aspect_ok && better {
|
||||
best = Some((long, data, src));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,12 +206,35 @@ pub struct Thumbnail {
|
||||
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`.
|
||||
/// Thumbnail request.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ThumbnailOptions {
|
||||
/// Fit the result within `max_edge × max_edge`.
|
||||
pub max_edge: u32,
|
||||
/// Accept an embedded preview whose long edge is at least this (it is then returned at its own,
|
||||
/// smaller size). Defaults to `max_edge`, i.e. only previews that need no upscaling. Lower it
|
||||
/// (e.g. 160 for typical EXIF thumbnails) for an instant placeholder.
|
||||
pub min_embedded_edge: u32,
|
||||
}
|
||||
|
||||
impl ThumbnailOptions {
|
||||
pub fn new(max_edge: u32) -> Self {
|
||||
ThumbnailOptions { max_edge, min_embedded_edge: max_edge }
|
||||
}
|
||||
}
|
||||
|
||||
/// Fast thumbnail: an embedded JPEG preview (EXIF IFD1 thumbnail or MPF preview) 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);
|
||||
decode_thumbnail_with(bytes, &ThumbnailOptions::new(max_edge))
|
||||
}
|
||||
|
||||
/// [`decode_thumbnail`] with options.
|
||||
pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result<Thumbnail> {
|
||||
let max_edge = opts.max_edge.max(1);
|
||||
let min_edge = opts.min_embedded_edge.clamp(1, max_edge);
|
||||
if sniff(bytes) == Some(Format::Jpeg)
|
||||
&& let Some(t) = jpeg::embedded_thumbnail(bytes, max_edge)
|
||||
&& let Ok(Some(t)) = std::panic::catch_unwind(|| jpeg::embedded_thumbnail(bytes, max_edge, min_edge))
|
||||
{
|
||||
return Ok(t);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Benchmark-ish, ignored by default. Run with:
|
||||
//! `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocapture`
|
||||
|
||||
use lightcraft_codecs::*;
|
||||
use std::time::Instant;
|
||||
|
||||
fn synthetic(w: usize, h: usize) -> Vec<u8> {
|
||||
// Smooth gradients + deterministic noise, roughly photo-like entropy.
|
||||
let mut s: u32 = 0x1234_5678;
|
||||
let mut v = Vec::with_capacity(w * h * 3);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
let n = (s & 15) as i32 - 8;
|
||||
let r = (x * 255 / w) as i32 + n;
|
||||
let g = (y * 255 / h) as i32 + n;
|
||||
let b = (((x / 37) ^ (y / 29)) & 0xFF) as i32 / 2 + 64 + n;
|
||||
v.extend_from_slice(&[r.clamp(0, 255) as u8, g.clamp(0, 255) as u8, b.clamp(0, 255) as u8]);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn time<T>(label: &str, mp: f64, reps: usize, mut f: impl FnMut() -> T) -> T {
|
||||
let mut out = f();
|
||||
let t = Instant::now();
|
||||
for _ in 0..reps {
|
||||
out = f();
|
||||
}
|
||||
let dt = t.elapsed().as_secs_f64() / reps as f64;
|
||||
if mp > 0.0 {
|
||||
println!("{label:<48} {:>8.2} ms {:>7.1} MP/s", dt * 1e3, mp / dt);
|
||||
} else {
|
||||
println!("{label:<48} {:>8.2} ms", dt * 1e3);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_24mp_jpeg() {
|
||||
let (w, h) = (6000usize, 4000usize);
|
||||
let mp = (w * h) as f64 / 1e6;
|
||||
let px = synthetic(w, h);
|
||||
let img = EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&px));
|
||||
let plain =
|
||||
time("encode JPEG q90 4:2:0 (jpeg-encoder)", mp, 1, || encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap());
|
||||
println!(" size {:.1} MB", plain.len() as f64 / 1e6);
|
||||
|
||||
time("decode full → linear Rgb32f", mp, 3, || decode(&plain, DecodeOptions::default()).unwrap());
|
||||
time("decode fit 2048 (DCT 1/2 + resample)", mp, 3, || decode(&plain, DecodeOptions::fit(2048, 2048)).unwrap());
|
||||
time("decode_thumbnail 256 (DCT 1/8, no preview)", mp, 5, || decode_thumbnail(&plain, 256).unwrap());
|
||||
|
||||
// Camera-style file: 320×213 EXIF thumbnail covers a 256 px request.
|
||||
let small: Vec<u8> = synthetic(320, 213);
|
||||
let thumb = encode_jpeg(&EncodeImage::new(320, 213, 3, Samples::U8(&small)), 80, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||||
let exif = exif_with_thumbnail(&thumb);
|
||||
let cam = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta { exif: Some(&exif), ..Default::default() }).unwrap();
|
||||
let t = time("decode_thumbnail 256 (EXIF preview)", mp, 20, || decode_thumbnail(&cam, 256).unwrap());
|
||||
assert_eq!(t.source, ThumbnailSource::ExifThumbnail);
|
||||
|
||||
let d = decode(&plain, DecodeOptions::default()).unwrap();
|
||||
time("to_working (sRGB → Rec.2020 matrix)", mp, 3, || to_working(&d));
|
||||
let rgba = d.to_srgb8();
|
||||
time("encode PNG 8-bit", mp, 1, || encode_png(&EncodeImage::rgba8(&rgba), &EncodeMeta::default()).unwrap());
|
||||
time("encode TIFF 8-bit deflate", mp, 1, || encode_tiff(&EncodeImage::rgba8(&rgba), TiffCompression::Deflate, &EncodeMeta::default()).unwrap());
|
||||
}
|
||||
|
||||
fn exif_with_thumbnail(jpeg: &[u8]) -> Vec<u8> {
|
||||
let mut v = b"II*\0".to_vec();
|
||||
v.extend_from_slice(&8u32.to_le_bytes());
|
||||
v.extend_from_slice(&0u16.to_le_bytes());
|
||||
v.extend_from_slice(&14u32.to_le_bytes());
|
||||
// IFD1 at 14: 2 entries, data at 44
|
||||
v.extend_from_slice(&2u16.to_le_bytes());
|
||||
for (tag, val) in [(0x0201u16, 44u32), (0x0202, jpeg.len() as u32)] {
|
||||
v.extend_from_slice(&tag.to_le_bytes());
|
||||
v.extend_from_slice(&4u16.to_le_bytes());
|
||||
v.extend_from_slice(&1u32.to_le_bytes());
|
||||
v.extend_from_slice(&val.to_le_bytes());
|
||||
}
|
||||
v.extend_from_slice(&0u32.to_le_bytes());
|
||||
assert_eq!(v.len(), 44);
|
||||
v.extend_from_slice(jpeg);
|
||||
v
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Corpus tests over `corpus/images/**`, `corpus/pngsuite/**` and `corpus/raw/**` (git-ignored; fetched
|
||||
//! with `cargo xtask corpus --download`). Each test skips cleanly when its directory is absent.
|
||||
//! `LIGHTCRAFT_CORPUS` overrides the corpus root.
|
||||
|
||||
use lightcraft_codecs::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn corpus_root() -> PathBuf {
|
||||
std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
}
|
||||
|
||||
fn files(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_images_decode() {
|
||||
let dir = corpus_root().join("images");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
return;
|
||||
}
|
||||
let (mut ok, mut unsupported) = (0, 0);
|
||||
for p in files(&dir) {
|
||||
let Ok(bytes) = std::fs::read(&p) else { continue };
|
||||
let Some(f) = sniff(&bytes) else { continue };
|
||||
if !f.can_decode() {
|
||||
unsupported += 1;
|
||||
assert!(decode(&bytes, DecodeOptions::default()).is_err());
|
||||
continue;
|
||||
}
|
||||
let d = decode(&bytes, DecodeOptions::default()).unwrap_or_else(|e| panic!("{}: {e}", p.display()));
|
||||
assert!(d.width > 0 && d.height > 0);
|
||||
assert!(d.image.data.iter().all(|px| px.iter().all(|v| v.is_finite())), "{}: non-finite", p.display());
|
||||
let t = decode_thumbnail(&bytes, 256).unwrap_or_else(|e| panic!("{} thumb: {e}", p.display()));
|
||||
assert!(t.image.width <= 256 && t.image.height <= 256);
|
||||
ok += 1;
|
||||
}
|
||||
eprintln!("corpus/images: {ok} decoded, {unsupported} unsupported formats");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_pngsuite() {
|
||||
let dir = corpus_root().join("pngsuite");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
return;
|
||||
}
|
||||
let mut n = 0;
|
||||
for p in files(&dir) {
|
||||
if p.extension().and_then(|e| e.to_str()) != Some("png") {
|
||||
continue;
|
||||
}
|
||||
let bytes = std::fs::read(&p).unwrap();
|
||||
let name = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
let r = decode(&bytes, DecodeOptions::default());
|
||||
if name.starts_with('x') {
|
||||
// Deliberately corrupt files: must fail gracefully (never panic).
|
||||
continue;
|
||||
}
|
||||
let d = r.unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert!(d.width > 0);
|
||||
n += 1;
|
||||
}
|
||||
eprintln!("pngsuite: {n} valid files decoded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_raw_is_routed() {
|
||||
let dir = corpus_root().join("raw");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
return;
|
||||
}
|
||||
for p in files(&dir) {
|
||||
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if matches!(ext.as_str(), "txt" | "md" | "xmp" | "json") {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&p) else { continue };
|
||||
let f = sniff(&bytes);
|
||||
assert!(f.is_some_and(|f| f.is_raw()), "{}: sniffed {f:?}", p.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the OS-provided ICC profiles when present (macOS ColorSync; read-only, nothing copied).
|
||||
#[test]
|
||||
fn system_icc_profiles() {
|
||||
use lightcraft_codecs::icc::{IccColorModel, IccKind, parse};
|
||||
let dir = Path::new("/System/Library/ColorSync/Profiles");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("skip: no system ICC profiles");
|
||||
return;
|
||||
}
|
||||
let expect = [
|
||||
("sRGB Profile.icc", Some(NamedSpace::Srgb)),
|
||||
("Display P3.icc", Some(NamedSpace::DisplayP3)),
|
||||
("AdobeRGB1998.icc", Some(NamedSpace::AdobeRgb)),
|
||||
("ROMM RGB.icc", Some(NamedSpace::ProPhoto)),
|
||||
("ITU-2020.icc", Some(NamedSpace::Rec2020)),
|
||||
];
|
||||
for (name, named) in expect {
|
||||
let Ok(b) = std::fs::read(dir.join(name)) else { continue };
|
||||
let info = parse(&b).unwrap_or_else(|| panic!("{name}"));
|
||||
assert_eq!(info.named, named, "{name}");
|
||||
assert!(matches!(info.kind, IccKind::MatrixTrc { .. }), "{name}");
|
||||
}
|
||||
if let Ok(b) = std::fs::read(dir.join("Generic CMYK Profile.icc")) {
|
||||
let info = parse(&b).unwrap();
|
||||
assert_eq!(info.model, IccColorModel::Cmyk);
|
||||
assert_eq!(info.kind, IccKind::NeedsCms);
|
||||
// CMYK TIFF tagged with it: paper white stays near white, 100% K near black.
|
||||
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::IccProfile, &b[..]).unwrap();
|
||||
im.write_data(&[0u8, 0, 0, 0, 0, 0, 0, 255]).unwrap();
|
||||
}
|
||||
let d = decode(&cur.into_inner(), DecodeOptions::default()).unwrap();
|
||||
assert_eq!(d.space.origin, SpaceOrigin::IccCms);
|
||||
let w = d.image.get(0, 0);
|
||||
let k = d.image.get(1, 0);
|
||||
assert!(w.iter().all(|&v| v > 0.8), "{w:?}");
|
||||
assert!(k.iter().all(|&v| v < 0.1), "{k:?}");
|
||||
}
|
||||
for p in files(dir) {
|
||||
let b = std::fs::read(&p).unwrap();
|
||||
let _ = parse(&b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Decoders must never panic on malformed input: random bytes behind every magic number, and
|
||||
//! mutated/truncated valid files. Uses `decode_unguarded` so panics in our own code are not masked
|
||||
//! by the public entry point's panic guard.
|
||||
|
||||
use lightcraft_codecs::exif::minimal_exif;
|
||||
use lightcraft_codecs::icc::write_named;
|
||||
use lightcraft_codecs::*;
|
||||
use lightcraft_raster::Rgba8;
|
||||
use proptest::prelude::*;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const MAGICS: &[&[u8]] = &[
|
||||
b"\xFF\xD8\xFF",
|
||||
b"\x89PNG\r\n\x1a\n",
|
||||
b"II*\0",
|
||||
b"MM\0*",
|
||||
b"RIFF\0\0\0\0WEBP",
|
||||
b"GIF89a",
|
||||
b"BM\0\0\0\0\0\0\0\0\0\0\0\0\x28\0\0\0",
|
||||
b"8BPS\0\x01",
|
||||
b"\xFF\x0A",
|
||||
b"\0\0\0\x0CJXL \x0D\x0A\x87\x0A",
|
||||
];
|
||||
|
||||
fn small_opts() -> DecodeOptions {
|
||||
DecodeOptions { max_size: None, max_pixels: 1 << 22 }
|
||||
}
|
||||
|
||||
fn try_all(bytes: &[u8]) {
|
||||
if let Some(f) = sniff(bytes) {
|
||||
let _ = decode_unguarded(bytes, f, &small_opts());
|
||||
let _ = decode_unguarded(bytes, f, &DecodeOptions { max_size: Some((8, 8)), max_pixels: 1 << 22 });
|
||||
}
|
||||
let _ = decode(bytes, small_opts());
|
||||
let _ = decode_thumbnail(bytes, 16);
|
||||
let _ = lightcraft_codecs::icc::parse(bytes);
|
||||
}
|
||||
|
||||
fn seeds() -> &'static Vec<Vec<u8>> {
|
||||
static S: OnceLock<Vec<Vec<u8>>> = OnceLock::new();
|
||||
S.get_or_init(|| {
|
||||
let img = Rgba8::from_fn(24, 16, |x, y| [(x * 10) as u8, (y * 15) as u8, ((x ^ y) * 8) as u8, (x * y) as u8]);
|
||||
let icc = write_named(NamedSpace::DisplayP3);
|
||||
let exif = minimal_exif(6);
|
||||
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some("<x/>") };
|
||||
let e = EncodeImage::rgba8(&img);
|
||||
let u16s: Vec<u16> = (0..24 * 16 * 3).map(|i| (i * 331) as u16).collect();
|
||||
let f32s: Vec<f32> = (0..24 * 16 * 3).map(|i| i as f32 * 0.01).collect();
|
||||
vec![
|
||||
encode_jpeg(&e, 80, ChromaSubsampling::S420, &meta).unwrap(),
|
||||
encode_png(&e, &meta).unwrap(),
|
||||
encode_png(&EncodeImage::new(24, 16, 3, Samples::U16(&u16s)), &meta).unwrap(),
|
||||
encode_tiff(&e, TiffCompression::Lzw, &meta).unwrap(),
|
||||
encode_tiff(&EncodeImage::new(24, 16, 3, Samples::F32(&f32s)), TiffCompression::Deflate, &meta).unwrap(),
|
||||
encode_webp_lossless(&e, &meta).unwrap(),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 400, ..ProptestConfig::default() })]
|
||||
|
||||
#[test]
|
||||
fn random_after_magic(m in 0usize..MAGICS.len(), tail in proptest::collection::vec(any::<u8>(), 0..600)) {
|
||||
let mut b = MAGICS[m].to_vec();
|
||||
b.extend_from_slice(&tail);
|
||||
try_all(&b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_valid_files(which in 0usize..6, flips in proptest::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 1..12), cut in any::<prop::sample::Index>()) {
|
||||
let mut b = seeds()[which].clone();
|
||||
for (i, v) in flips {
|
||||
let i = i.index(b.len());
|
||||
b[i] = v;
|
||||
}
|
||||
try_all(&b);
|
||||
let n = cut.index(b.len());
|
||||
try_all(&b[..n]);
|
||||
}
|
||||
}
|
||||
@@ -450,3 +450,30 @@ fn raw_routing() {
|
||||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
assert!(matches!(decode(&b, DecodeOptions::default()), Err(Error::Unsupported(Format::RawTiffLike, _))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_accepts_smaller_preview_when_asked() {
|
||||
let main = Rgba8::filled(800, 600, [200, 30, 30, 255]);
|
||||
let thumb = Rgba8::filled(160, 120, [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, 1);
|
||||
let bytes =
|
||||
encode_jpeg(&EncodeImage::rgba8(&main), 80, ChromaSubsampling::S420, &EncodeMeta { exif: Some(&exif), ..Default::default() }).unwrap();
|
||||
let t = decode_thumbnail(&bytes, 256).unwrap();
|
||||
assert_eq!(t.source, ThumbnailSource::Scaled);
|
||||
let t = decode_thumbnail_with(&bytes, &ThumbnailOptions { max_edge: 256, min_embedded_edge: 160 }).unwrap();
|
||||
assert_eq!(t.source, ThumbnailSource::ExifThumbnail);
|
||||
assert_eq!((t.image.width, t.image.height), (160, 120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_icc_falls_back_to_srgb_with_flag() {
|
||||
let bogus = vec![0x42u8; 300];
|
||||
let data = [128u8, 64, 32];
|
||||
let bytes = encode_png(&EncodeImage::new(1, 1, 3, Samples::U8(&data)), &EncodeMeta { icc: Some(&bogus), ..Default::default() }).unwrap();
|
||||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||||
assert_eq!(d.space.origin, SpaceOrigin::IccUnsupported);
|
||||
assert_eq!(d.space.named, Some(NamedSpace::Srgb));
|
||||
assert_eq!(d.icc.as_deref(), Some(&bogus[..]));
|
||||
assert!((d.image.get(0, 0)[0] - srgb_to_linear(128.0 / 255.0)).abs() < 1e-6);
|
||||
}
|
||||
Reference in new issue
Block a user