Files
photocraft/crates/io/tests/flat.rs
T
BFlatandClaude Opus 5.5 8a8371395b Import warns when it keeps only the first frame or page, or a JPEG ends early (#518, #523) (#563)
Decoders now report what the decoded image doesn't show in a new
`Image::warnings` (`photocraft_codecs::DecodeWarning`), and `image_to_document`
turns them into import warnings, so they reach `photocraft-cli info` /
`convert` stderr, `doc.open` over MCP and serve, and the app's open notice
through the existing import-warnings path.

* #523: APNG (acTL frames, plus a default image outside the animation),
  animated GIF (image descriptors counted by walking the blocks), animated
  WebP and multi-page TIFF (IFDs walked without decoding; reduced-resolution
  copies and transparency masks are not pages) say "only the first of N
  frames/pages was imported". Single images stay quiet; the kept frame's
  pixels are unchanged.
* #518: a JPEG whose data runs out before its end-of-image marker (marker
  segments skipped by length, scan data by byte stuffing) still decodes
  leniently but warns "JPEG data ends early (the file is truncated or
  damaged); part of the image is missing". A JPEG that ends before any scan
  data is an error.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:20:02 +07:00

369 lines
17 KiB
Rust

//! Flat formats through photocraft-codecs, and export warnings.
mod common;
use common::*;
use photocraft_color::{ColorMode, SampleType};
use photocraft_io::*;
fn single(mode: ColorMode, depth: SampleType, alpha: bool) -> photocraft_doc::Document {
let mut d = photocraft_doc::Document::new("s", photocraft_geom::Size::new(9, 6), mode, depth);
let fmt = d.pixel_format();
d.layers.push(raster("Background", fmt, d.bounds(), 3, alpha));
d
}
fn pixels_eq(a: &photocraft_doc::Document, b: &photocraft_doc::Document, tol: f32) {
let sa = a.layers[0].surface().unwrap();
let sb = b.layers[0].surface().unwrap();
let r = a.bounds();
let (va, vb) = (sa.read_region(r), sb.read_region(r));
assert_eq!(va.len(), vb.len());
let m = va.iter().zip(&vb).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max);
assert!(m <= tol, "max diff {m}");
}
macro_rules! codec_rt {
($name:ident, $ext:expr, $mode:expr, $depth:expr, $alpha:expr, $tol:expr) => {
#[test]
fn $name() {
let d = single($mode, $depth, $alpha);
let r = export(&d, $ext, &ExportOptions::default()).expect("export");
let back = import(concat!("x.", $ext), &r.bytes).expect("import").document;
assert_eq!(back.size, d.size);
assert_eq!(back.mode, d.mode, "mode");
assert_eq!(back.depth, d.depth, "depth");
pixels_eq(&d, &back, $tol);
}
};
}
codec_rt!(png_rgba8, "png", ColorMode::Rgb, SampleType::U8, true, 0.0);
codec_rt!(png_rgb8_opaque, "png", ColorMode::Rgb, SampleType::U8, false, 0.0);
codec_rt!(png_rgba16, "png", ColorMode::Rgb, SampleType::U16, true, 0.0);
codec_rt!(png_gray8, "png", ColorMode::Grayscale, SampleType::U8, false, 0.0);
codec_rt!(png_graya16, "png", ColorMode::Grayscale, SampleType::U16, true, 0.0);
codec_rt!(tga_rgba8, "tga", ColorMode::Rgb, SampleType::U8, true, 0.0);
codec_rt!(tga_rgb8_opaque, "tga", ColorMode::Rgb, SampleType::U8, false, 0.0);
codec_rt!(tga_gray8, "tga", ColorMode::Grayscale, SampleType::U8, false, 0.0);
codec_rt!(tiff_rgba8, "tiff", ColorMode::Rgb, SampleType::U8, true, 0.0);
codec_rt!(tiff_rgb16, "tif", ColorMode::Rgb, SampleType::U16, false, 0.0);
codec_rt!(tiff_cmyk8, "tiff", ColorMode::Cmyk, SampleType::U8, false, 0.0);
codec_rt!(tiff_cmyka16, "tiff", ColorMode::Cmyk, SampleType::U16, true, 0.0);
codec_rt!(tiff_gray8, "tiff", ColorMode::Grayscale, SampleType::U8, false, 0.0);
/// EXR stores linear light: a linear document round-trips exactly; an sRGB (untagged) one is
/// linearised on export and comes back tagged linear sRGB with the same colours.
#[test]
fn exr_rgba32() {
let mut d = single(ColorMode::Rgb, SampleType::F32, true);
d.icc_profile = Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes());
let r = export(&d, "exr", &ExportOptions::default()).expect("export");
let back = import("x.exr", &r.bytes).expect("import").document;
assert_eq!((back.size, back.mode, back.depth), (d.size, d.mode, d.depth));
assert_eq!(back.icc_profile.as_deref(), d.icc_profile.as_deref(), "tagged linear sRGB");
pixels_eq(&d, &back, 0.0);
// Untagged (sRGB) → linearised.
let srgb = single(ColorMode::Rgb, SampleType::F32, true);
let r = export(&srgb, "exr", &ExportOptions::default()).expect("export");
let back = import("x.exr", &r.bytes).expect("import").document;
let (a, b) = (srgb.layers[0].surface().unwrap().pixel(2, 3), back.layers[0].surface().unwrap().pixel(2, 3));
for c in 0..3 {
assert!((photocraft_color::convert::srgb_to_linear(a[c]) - b[c]).abs() < 1e-4, "{a:?} -> {b:?}");
}
assert!((a[3] - b[3]).abs() < 1e-6, "alpha kept");
}
fn smooth(mode: ColorMode) -> photocraft_doc::Document {
let mut d = photocraft_doc::Document::new("s", photocraft_geom::Size::new(32, 16), mode, SampleType::U8);
let fmt = d.pixel_format();
let mut s = photocraft_raster::Surface::new(fmt);
let n = fmt.channels();
let mut v = Vec::new();
for y in 0..16 {
for x in 0..32 {
for c in 0..n - 1 {
v.push(((x * 4 + y * 2 + c * 20) as f32 / 255.0).min(1.0));
}
v.push(1.0);
}
}
s.write_region(d.bounds(), &v);
d.layers.push(photocraft_doc::Layer::new("Background", photocraft_doc::LayerContent::Raster(s)));
d
}
fn jpeg_rt(mode: ColorMode, ext: &str) {
let d = smooth(mode);
let r = export(&d, ext, &ExportOptions::default()).unwrap();
assert!(r.warnings.iter().any(|w| w.to_lowercase().contains("lossy")), "{:?}", r.warnings);
let back = import("x.jpg", &r.bytes).unwrap().document;
assert_eq!(back.mode, mode);
let (a, b) = (d.layers[0].surface().unwrap().read_region(d.bounds()), back.layers[0].surface().unwrap().read_region(d.bounds()));
let mean = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).sum::<f32>() / a.len() as f32;
assert!(mean < 0.02, "mean error {mean}");
}
#[test]
fn jpeg_rgb8() {
jpeg_rt(ColorMode::Rgb, "jpg");
}
#[test]
fn jpeg_gray8() {
jpeg_rt(ColorMode::Grayscale, "jpeg");
}
#[test]
fn metadata_roundtrip_png() {
let mut d = single(ColorMode::Rgb, SampleType::U8, false);
d.resolution_dpi = 300.0;
d.icc_profile = Some(std::sync::Arc::new(sample_icc()));
d.metadata.xmp = Some("<x:xmpmeta xmlns:x='adobe:ns:meta/'/>".into());
let r = export(&d, "png", &ExportOptions::default()).unwrap();
let back = import("x.png", &r.bytes).unwrap().document;
assert!((back.resolution_dpi - 300.0).abs() < 1.0);
assert_eq!(back.icc_profile, d.icc_profile);
assert_eq!(back.metadata.xmp, d.metadata.xmp);
}
fn sample_icc() -> Vec<u8> {
// Arbitrary bytes are fine for PNG iCCP (the codec does not validate).
let mut v = vec![0u8; 128];
v[36..40].copy_from_slice(b"acsp");
v
}
#[test]
fn layered_to_png_warns_flatten() {
let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS);
let r = export(&d, "out.png", &ExportOptions::default()).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("flattened")), "{:?}", r.warnings);
let back = import("out.png", &r.bytes).unwrap().document;
// Composite equals our flatten.
let flat = photocraft_compose::flatten(&d).px;
let s = back.layers[0].surface().unwrap();
let px: Vec<[f32; 4]> = s.read_region(back.bounds()).as_chunks::<4>().0.iter().map(|p| [p[0], p[1], p[2], p[3]]).collect();
assert!(max_diff(&px, &flat) <= 1.0 / 255.0 + 1e-4);
}
#[test]
fn sixteen_bit_to_jpeg_warns_depth() {
let d = single(ColorMode::Rgb, SampleType::U16, false);
let r = export(&d, "a.jpg", &ExportOptions::default()).unwrap();
assert!(r.warnings.iter().any(|w| w.to_lowercase().contains("bit")), "{:?}", r.warnings);
}
#[test]
fn alpha_to_jpeg_warns() {
let d = single(ColorMode::Rgb, SampleType::U8, true);
let r = export(&d, "a.jpg", &ExportOptions::default()).unwrap();
assert!(r.warnings.len() >= 2, "{:?}", r.warnings);
}
#[test]
fn cmyk_layered_to_png_warns_conversion() {
let d = gen_doc(ColorMode::Cmyk, SampleType::U8, Features::PIXELS);
let r = export(&d, "a.png", &ExportOptions::default()).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("RGB")), "{:?}", r.warnings);
}
#[test]
fn effects_are_written_to_psd_and_read_back() {
let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS);
d.layers[1].effects.items.push(photocraft_doc::Effect::ColorOverlay {
common: photocraft_doc::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
color: photocraft_color::Color::BLACK,
});
d.layers[1].effects.items.push(photocraft_doc::Effect::default_drop_shadow());
let r = export(&d, "a.psd", &ExportOptions::default()).unwrap();
assert!(!r.warnings.iter().any(|w| w.contains("effects")), "{:?}", r.warnings);
let back = import("a.psd", &r.bytes).unwrap().document;
assert_eq!(back.layers[1].effects.items.len(), 2);
assert!(back.layers[1].effects.psd_raw.is_some());
}
#[test]
fn unknown_extension_errors() {
let d = single(ColorMode::Rgb, SampleType::U8, false);
assert!(matches!(export(&d, "a.xyz", &ExportOptions::default()), Err(IoError::UnknownFormat(_))));
}
#[test]
fn garbage_import_errors() {
assert!(import("x", b"definitely not an image").is_err());
assert!(matches!(import("x.psd", b"8BPS\0\x01"), Err(IoError::Psd(_))));
}
#[test]
fn extension_forms() {
let d = single(ColorMode::Rgb, SampleType::U8, false);
for e in ["png", ".png", "a/b/c.PNG", "C:\\x\\y.png"] {
let r = export(&d, e, &ExportOptions::default()).unwrap();
assert!(r.bytes.starts_with(b"\x89PNG"), "{e}");
}
}
#[test]
fn background_lock_follows_alpha() {
let r = export(&single(ColorMode::Rgb, SampleType::U8, false), "png", &ExportOptions::default()).unwrap();
let d = import("a.png", &r.bytes).unwrap().document;
assert!(d.layers[0].locks.transparency);
let r = export(&single(ColorMode::Rgb, SampleType::U8, true), "png", &ExportOptions::default()).unwrap();
let d = import("a.png", &r.bytes).unwrap().document;
assert!(!d.layers[0].locks.transparency);
}
#[test]
fn psd_to_png_to_psd_chain() {
let d = gen_doc(ColorMode::Rgb, SampleType::U16, Features::ALL);
let psd = export(&d, "a.psd", &ExportOptions::default()).unwrap();
let d2 = import("a.psd", &psd.bytes).unwrap().document;
let png = export(&d2, "a.png", &ExportOptions::default()).unwrap();
let d3 = import("a.png", &png.bytes).unwrap().document;
assert_eq!(d3.depth, SampleType::U16);
let psd2 = export(&d3, "b.psd", &ExportOptions::default()).unwrap();
assert!(photocraft_psd::PsdFile::from_bytes(&psd2.bytes).is_ok());
}
/// A one-layer document of 16-pixel-wide columns, each one straight colour (colour channels, then alpha).
fn columns(mode: ColorMode, depth: SampleType, cols: &[&[f32]]) -> photocraft_doc::Document {
let mut d = photocraft_doc::Document::new("c", photocraft_geom::Size::new(16 * cols.len() as u32, 16), mode, depth);
let mut s = photocraft_raster::Surface::new(d.pixel_format());
for (i, px) in cols.iter().enumerate() {
let x = 16 * i as i32;
s.fill_rect(photocraft_geom::Rect::new(x, 0, x + 16, 16), px);
}
d.layers.push(photocraft_doc::Layer::new("Layer", photocraft_doc::LayerContent::Raster(s)));
d
}
/// The pixel at the centre of each column of `doc`.
fn column_pixels(doc: &photocraft_doc::Document, n: usize) -> Vec<Vec<f32>> {
let s = doc.layers[0].surface().unwrap();
(0..n).map(|i| s.pixel(16 * i as i32 + 8, 8)).collect()
}
/// Asserts the leading (colour) channels of `got` are within `tol` of `want`.
fn assert_colors(got: &[Vec<f32>], want: &[Vec<f32>], tol: f32, what: &str) {
for (g, w) in got.iter().zip(want) {
assert!(w.iter().zip(g).all(|(a, b)| (a - b).abs() <= tol), "{what}: got {got:?}, want {want:?}");
}
}
/// Formats without alpha get the document composited over white, as flattening does, instead of
/// its alpha dropped (which shows the colours stored under transparent pixels).
#[test]
fn transparency_is_composited_over_white_for_formats_without_alpha() {
let rgb: [&[f32]; 3] = [&[0.0, 0.0, 0.0, 0.0], &[0.0, 0.0, 1.0, 0.5], &[0.0, 0.63, 0.0, 1.0]];
let over_white = vec![vec![1.0, 1.0, 1.0], vec![0.5, 0.5, 1.0], vec![0.0, 0.63, 0.0]];
let gray: [&[f32]; 2] = [&[0.0, 0.0], &[0.0, 0.5]];
// CMYK white is no ink.
let cmyk: [&[f32]; 2] = [&[1.0, 1.0, 1.0, 1.0, 0.0], &[0.0, 0.0, 0.0, 1.0, 0.5]];
let cases = [
(ColorMode::Rgb, SampleType::U8, &rgb[..], over_white.clone()),
(ColorMode::Rgb, SampleType::U16, &rgb[..], over_white.clone()),
(ColorMode::Rgb, SampleType::F32, &rgb[..], over_white),
(ColorMode::Grayscale, SampleType::U8, &gray[..], vec![vec![1.0], vec![0.5]]),
(ColorMode::Cmyk, SampleType::U8, &cmyk[..], vec![vec![0.0; 4], vec![0.0, 0.0, 0.0, 0.5]]),
];
for (mode, depth, cols, want) in cases {
let what = format!("{mode:?} {depth:?}");
let d = columns(mode, depth, cols);
let r = export(&d, "a.jpg", &ExportOptions::default()).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("composited over white")), "{what}: {:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("alpha will be discarded")), "{what}: {:?}", r.warnings);
let back = import("a.jpg", &r.bytes).unwrap().document;
assert_eq!(back.mode, mode, "{what}");
assert_colors(&column_pixels(&back, cols.len()), &want, 0.03, &what);
// PNG keeps the transparency itself.
let r = export(&d, "a.png", &ExportOptions::default()).unwrap();
assert!(!r.warnings.iter().any(|w| w.contains("composited")), "{what}: {:?}", r.warnings);
let back = import("a.png", &r.bytes).unwrap().document;
let alpha = column_pixels(&back, cols.len())[1].last().copied().unwrap();
assert!((alpha - 0.5).abs() <= 1.0 / 255.0, "{what}: alpha {alpha}");
}
// Opaque documents are written as before.
let r = export(&columns(ColorMode::Rgb, SampleType::U8, &[&[0.2, 0.4, 0.6, 1.0]]), "a.jpg", &ExportOptions::default()).unwrap();
assert!(!r.warnings.iter().any(|w| w.contains("composited")), "{:?}", r.warnings);
}
/// Formats that can't embed a profile get sRGB values (what an untagged file means), converted
/// through the colour engine, instead of values that only mean something under the dropped profile.
#[test]
fn non_srgb_rgb_is_converted_to_srgb_for_formats_without_a_profile() {
use photocraft_cms::{Builtin, Intent, Transform};
let cols: [&[f32]; 3] = [&[0.0, 0.05, 0.0, 1.0], &[0.2, 0.5, 0.8, 1.0], &[1.0, 0.25, 0.0, 1.0]];
for (profile, depth) in [(Builtin::LinearSrgb, SampleType::F32), (Builtin::LinearSrgb, SampleType::U16), (Builtin::DisplayP3, SampleType::U8)] {
let mut d = columns(ColorMode::Rgb, depth, &cols);
d.icc_profile = Some(profile.profile().to_bytes());
let stored = column_pixels(&d, cols.len());
let t = Transform::new(profile.profile(), Builtin::Srgb.profile(), Intent::Perceptual, true).unwrap();
let want: Vec<Vec<f32>> = stored
.iter()
.map(|p| {
let mut v = p.clone();
t.apply(&mut v, 4);
// The formats hold 8-bit sRGB: out-of-gamut colours clip.
v.iter().map(|c| c.clamp(0.0, 1.0)).collect()
})
.collect();
for ext in ["bmp", "gif", "qoi", "tga"] {
let what = format!("{profile:?} {depth:?} {ext}");
let r = export(&d, ext, &ExportOptions::default()).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("converted to sRGB")), "{what}: {:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("ICC profile")), "{what}: {:?}", r.warnings);
let back = import(&format!("a.{ext}"), &r.bytes).unwrap().document;
assert_eq!(back.icc_profile, None, "{what}");
assert_colors(&column_pixels(&back, cols.len()), &want, 1.5 / 255.0, &what);
}
// Formats that embed the profile keep it and the values.
let r = export(&d, "a.png", &ExportOptions::default()).unwrap();
let back = import("a.png", &r.bytes).unwrap().document;
assert_eq!(back.icc_profile, d.icc_profile, "{profile:?} {depth:?}");
assert_colors(&column_pixels(&back, cols.len()), &stored, 1e-4, &format!("{profile:?} {depth:?} png"));
}
// sRGB (tagged or not) is written unchanged.
for icc in [None, Some(Builtin::Srgb.profile().to_bytes())] {
let mut d = columns(ColorMode::Rgb, SampleType::U8, &cols);
d.icc_profile = icc;
let r = export(&d, "a.bmp", &ExportOptions::default()).unwrap();
assert!(!r.warnings.iter().any(|w| w.contains("converted to sRGB")), "{:?}", r.warnings);
let back = import("a.bmp", &r.bytes).unwrap().document;
assert_colors(&column_pixels(&back, cols.len()), &column_pixels(&d, cols.len()), 0.0, "sRGB bmp");
}
}
/// #518: a JPEG cut off inside its image data opens with a warning, never silently.
#[test]
fn truncated_jpeg_imports_with_a_warning() {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
let full = export(&smooth(mode), "jpg", &ExportOptions::default()).unwrap().bytes;
assert_eq!(import("x.jpg", &full).unwrap().warnings, Vec::<String>::new(), "{mode:?}");
// Cut inside the scan data (most of this small file is headers).
let r = import("x.jpg", &full[..full.len() - 4]).unwrap();
assert_eq!(r.document.size, photocraft_geom::Size::new(32, 16));
assert!(r.warnings.first().is_some_and(|w| w.starts_with("JPEG data ends early")), "{mode:?}: {:?}", r.warnings);
}
}
/// A GIF of `frames` identical 1x1 black frames.
fn gif_frames(frames: usize) -> Vec<u8> {
let mut b = b"GIF89a\x01\0\x01\0\x80\0\0\0\0\0\xFF\xFF\xFF".to_vec();
for _ in 0..frames {
b.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
}
b.push(0x3B);
b
}
/// #523: an animation opens its first frame, unchanged, with a warning that the rest was left out.
#[test]
fn animation_imports_the_first_frame_with_a_warning() {
let one = import("a.gif", &gif_frames(1)).unwrap();
assert_eq!(one.warnings, Vec::<String>::new());
let three = import("a.gif", &gif_frames(3)).unwrap();
assert_eq!(three.warnings, ["only the first of 3 frames was imported"]);
pixels_eq(&one.document, &three.document, 0.0);
}