//! 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_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); codec_rt!(fits_rgb8, "fits", ColorMode::Rgb, SampleType::U8, false, 0.0); codec_rt!(fits_gray16, "fit", ColorMode::Grayscale, SampleType::U16, false, 0.0); codec_rt!(fits_rgb32, "fits", ColorMode::Rgb, SampleType::F32, false, 0.0); codec_rt!(dpx_gray8, "dpx", ColorMode::Grayscale, SampleType::U8, false, 0.0); codec_rt!(dpx_rgb16, "dpx", ColorMode::Rgb, SampleType::U16, false, 0.0); codec_rt!(dpx_rgba16, "dpx", ColorMode::Rgb, SampleType::U16, true, 0.0); fn indexed_bmp_8() -> Vec { const FILE_HEADER_SIZE: usize = 14; const INFO_HEADER_SIZE: usize = 40; const PALETTE_ENTRY_SIZE: usize = 4; const PALETTE: [[u8; 3]; 2] = [[10, 20, 30], [200, 100, 50]]; const PIXEL_ROW: [u8; 4] = [0, 1, 0, 0]; let pixel_offset = FILE_HEADER_SIZE + INFO_HEADER_SIZE + PALETTE.len() * PALETTE_ENTRY_SIZE; let file_size = pixel_offset + PIXEL_ROW.len(); let mut bytes = Vec::with_capacity(file_size); bytes.extend_from_slice(b"BM"); bytes.extend_from_slice(&(file_size as u32).to_le_bytes()); bytes.extend_from_slice(&[0; 4]); bytes.extend_from_slice(&(pixel_offset as u32).to_le_bytes()); bytes.extend_from_slice(&(INFO_HEADER_SIZE as u32).to_le_bytes()); bytes.extend_from_slice(&2i32.to_le_bytes()); bytes.extend_from_slice(&1i32.to_le_bytes()); bytes.extend_from_slice(&1u16.to_le_bytes()); bytes.extend_from_slice(&8u16.to_le_bytes()); bytes.extend_from_slice(&0u32.to_le_bytes()); bytes.extend_from_slice(&(PIXEL_ROW.len() as u32).to_le_bytes()); bytes.extend_from_slice(&0i32.to_le_bytes()); bytes.extend_from_slice(&0i32.to_le_bytes()); bytes.extend_from_slice(&(PALETTE.len() as u32).to_le_bytes()); bytes.extend_from_slice(&0u32.to_le_bytes()); for [r, g, b] in PALETTE { bytes.extend_from_slice(&[b, g, r, 0]); } bytes.extend_from_slice(&PIXEL_ROW); bytes } #[test] fn indexed_bmp_opens_with_its_color_table() { let doc = import("indexed.bmp", &indexed_bmp_8()).unwrap().document; assert_eq!(doc.mode, ColorMode::Indexed); assert_eq!(doc.depth, SampleType::U8); assert_eq!(doc.color_table.as_ref().unwrap().colors.get(..2), Some(&[[10, 20, 30], [200, 100, 50]][..])); let px = doc.layers[0].surface().unwrap().rgba(1, 0); assert!((px[0] - 200.0 / 255.0).abs() < 1e-6); assert!((px[1] - 100.0 / 255.0).abs() < 1e-6); assert!((px[2] - 50.0 / 255.0).abs() < 1e-6); } /// A palette PNG opens in Indexed Color mode with its whole colour table and its transparent /// entry, as in Photoshop (#4804). #[test] fn indexed_png_opens_with_its_color_table() { let pal = [[10, 20, 30], [200, 100, 50], [7, 8, 9], [0, 255, 0]]; let png = photocraft_codecs::encode_png_indexed(2, 2, &[1, 0, 2, 1], &pal, Some(2), None, None).unwrap(); let doc = import("indexed.png", &png).unwrap().document; assert_eq!(doc.mode, ColorMode::Indexed); assert_eq!(doc.depth, SampleType::U8); let table = doc.color_table.as_ref().unwrap(); assert_eq!(table.colors, pal, "every entry, used or not"); assert_eq!(table.transparent, Some(2)); let s = doc.layers[0].surface().unwrap(); let px = s.rgba(0, 0); assert!((px[0] - 200.0 / 255.0).abs() < 1e-6 && (px[3] - 1.0).abs() < 1e-6); assert_eq!(s.rgba(0, 1)[3], 0.0, "the tRNS entry is transparent"); } /// A 2 × 2 GIF89a of palette indices `idx` over `pal` (padded to 4 entries), with `transparent` /// set through a Graphic Control Extension. Its LZW stream clears before every pixel, so every /// code stays 3 bits: the simplest valid stream any decoder reads. fn indexed_gif(pal: &[[u8; 3]; 4], idx: [u8; 4], transparent: Option) -> Vec { let mut b = b"GIF89a".to_vec(); b.extend([2, 0, 2, 0, 0x81, 0, 0]); // 2 × 2, global table of 4 pal.iter().for_each(|c| b.extend(c)); if let Some(t) = transparent { b.extend([0x21, 0xF9, 4, 0x01, 0, 0, t, 0]); } b.extend([0x2C, 0, 0, 0, 0, 2, 0, 2, 0, 0]); let (clear, end) = (4u32, 5u32); let codes: Vec = idx.iter().flat_map(|&i| [clear, u32::from(i)]).chain([end]).collect(); let (mut bits, mut n, mut data) = (0u32, 0u32, Vec::new()); for c in codes { bits |= c << n; n += 3; while n >= 8 { data.push((bits & 0xFF) as u8); bits >>= 8; n -= 8; } } if n > 0 { data.push(bits as u8); } b.push(2); // LZW minimum code size b.push(data.len() as u8); b.extend(&data); b.extend([0, 0x3B]); b } /// An indexed GIF opens in Indexed Color mode with its colour table and transparent entry, as a /// palette PNG does (#7886); it used to open as RGB. #[test] fn indexed_gif_opens_with_its_color_table() { let pal = [[10, 20, 30], [200, 100, 50], [7, 8, 9], [0, 255, 0]]; let doc = import("indexed.gif", &indexed_gif(&pal, [1, 0, 2, 1], Some(2))).unwrap().document; assert_eq!(doc.mode, ColorMode::Indexed); assert_eq!(doc.depth, SampleType::U8); let table = doc.color_table.as_ref().unwrap(); assert_eq!(table.colors, pal, "every entry, used or not"); assert_eq!(table.transparent, Some(2)); let s = doc.layers[0].surface().unwrap(); let px = s.rgba(0, 0); assert!((px[0] - 200.0 / 255.0).abs() < 1e-6 && (px[3] - 1.0).abs() < 1e-6, "{px:?}"); assert_eq!(s.rgba(0, 1)[3], 0.0, "the transparent entry is transparent"); // Without a transparent entry, the table alone. let doc = import("opaque.gif", &indexed_gif(&pal, [3, 3, 0, 1], None)).unwrap().document; assert_eq!(doc.mode, ColorMode::Indexed); assert_eq!(doc.color_table.as_ref().unwrap().transparent, None); } /// FITS header cards travel as the document's text metadata, so an astro frame keeps its /// OBJECT, EXPTIME and the rest through an edit. #[test] fn fits_header_cards_round_trip_as_text() { let mut d = single(ColorMode::Grayscale, SampleType::U16, false); d.metadata.text = vec![("OBJECT".into(), "NGC 246".into()), ("EXPTIME".into(), "480.0".into())]; let r = export(&d, "a.fits", &ExportOptions::default()).unwrap(); assert!(r.bytes.starts_with(b"SIMPLE =")); let back = import("a.fits", &r.bytes).unwrap().document; for kv in &d.metadata.text { assert!(back.metadata.text.contains(kv), "{kv:?}: {:?}", back.metadata.text); } } /// DPX header fields travel as the document's text metadata and come back on a DPX export at the /// chosen bit depth; the transfer characteristic is reported when the frame is opened (#1049). #[test] fn dpx_header_fields_and_encoding_notes_round_trip_through_the_document() { let mut d = single(ColorMode::Rgb, SampleType::U16, false); d.metadata.text = vec![("DPX:Project".into(), "Reel 7".into()), ("DPX:Transfer".into(), "3".into()), ("DPX:TimeCode".into(), "01:00:00:12".into())]; let mut opts = ExportOptions::default(); opts.encode.dpx_bits = Some(DpxBits::Bits10); let r = export(&d, "a.dpx", &opts).unwrap(); assert!(r.bytes.starts_with(b"SDPX")); assert_eq!(r.bytes[803], 10, "bits per sample"); let back = import("a.dpx", &r.bytes).unwrap(); for kv in &d.metadata.text { assert!(back.document.metadata.text.contains(kv), "{kv:?}: {:?}", back.document.metadata.text); } assert!(back.warnings.iter().any(|w| w.contains("transfer characteristic: logarithmic (3)")), "{:?}", back.warnings); assert_eq!(back.document.depth, SampleType::U16); } /// 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"); } /// An opaque document filled with `#808080`. fn mid_gray(mode: ColorMode, depth: SampleType) -> photocraft_doc::Document { let mut d = photocraft_doc::Document::new("g", photocraft_geom::Size::new(4, 4), mode, depth); let fmt = d.pixel_format(); let mut s = photocraft_raster::Surface::new(fmt); let v = 128.0 / 255.0; s.fill_rect(d.bounds(), &photocraft_raster::from_rgba(&fmt, [v, v, v, 1.0])); d.layers.push(photocraft_doc::Layer::new("Background", photocraft_doc::LayerContent::Raster(s))); d } /// OpenEXR and Radiance HDR store linear sRGB in every mode: an sGray `#808080` is written as /// its linear value, as an RGB `#808080` is, rather than as the display-encoded 0.5 (#2386). #[test] fn gray_exr_and_hdr_store_linear_light() { let linear = photocraft_color::convert::srgb_to_linear(128.0 / 255.0); for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { for (ext, tol) in [("exr", 2e-5), ("hdr", 2e-3)] { for mode in [ColorMode::Grayscale, ColorMode::Rgb] { let r = export(&mid_gray(mode, depth), ext, &ExportOptions::default()).expect("export"); let img = photocraft_codecs::decode(&r.bytes).expect("decode"); let samples = img.to_normalized(); let worst = samples.iter().map(|v| (v - linear).abs()).fold(0.0f32, f32::max); assert!(worst <= tol, "{mode:?} {depth:?} .{ext}: stored samples differ from {linear} by {worst}"); } } } } /// A grayscale OpenEXR holds linear luminance, so it opens as grayscale tagged with a linear gray /// profile, in which its samples are the colours saved. #[test] fn gray_exr_round_trips() { use photocraft_cms::{Builtin, ColorSpace, Intent, Profile, Transform}; for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { let r = export(&mid_gray(ColorMode::Grayscale, depth), "exr", &ExportOptions::default()).expect("export"); let back = import("x.exr", &r.bytes).expect("import").document; assert_eq!(back.mode, ColorMode::Grayscale, "{depth:?}"); let profile = Profile::parse(back.icc_profile.as_deref().expect("tagged")).expect("profile"); assert_eq!(profile.color_space, ColorSpace::Gray, "{depth:?}"); let t = Transform::new(&profile, Builtin::SGray.profile(), Intent::RelativeColorimetric, false).expect("transform"); let mut v = [back.layers[0].surface().expect("raster").pixel(1, 1)[0]]; t.apply(&mut v, 1); assert!((v[0] - 128.0 / 255.0).abs() < 1e-4, "{depth:?}: reopened as {} in sGray", v[0]); } } 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::() / 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("".into()); // DPI and ICC always travel with the file; XMP unless Export As asks for none (#647). let none = ExportOptions { xmp: photocraft_io::XmpEmbed::None, ..ExportOptions::default() }; let r = export(&d, "png", &none).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, None); let r = export(&d, "png", &ExportOptions::default()).unwrap(); let back = import("x.png", &r.bytes).unwrap().document; assert_eq!(back.metadata.xmp, d.metadata.xmp); } fn sample_icc() -> Vec { // 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}"); } } /// Asserts how a flat file opened: the locked Background when opaque, a normal "Layer 0" (as in /// Photoshop) when it has transparency. fn assert_opens_as(what: &str, d: &photocraft_doc::Document, transparent: bool) { let [l] = &d.layers[..] else { panic!("{what}: {} layers", d.layers.len()) }; if transparent { assert_eq!(l.name, "Layer 0", "{what}"); assert!(!l.locks.transparency && !l.locks.position, "{what}: unlocked"); } else { assert_eq!(l.name, "Background", "{what}"); assert!(l.locks.transparency && l.locks.position, "{what}: locked"); } } #[test] fn background_lock_follows_alpha() { let r = export(&single(ColorMode::Rgb, SampleType::U8, false), "png", &ExportOptions::default()).unwrap(); assert_opens_as("opaque PNG", &import("a.png", &r.bytes).unwrap().document, false); let r = export(&single(ColorMode::Rgb, SampleType::U8, true), "png", &ExportOptions::default()).unwrap(); assert_opens_as("RGBA PNG", &import("a.png", &r.bytes).unwrap().document, true); } #[test] fn transparent_flat_images_open_as_layer_0() { use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType as CS}; let (w, h) = (6u32, 4u32); let n = (w * h) as usize; let enc = |layout: ChannelLayout, sample: CS, format: Format| { let ch = layout.channels(); let img = match sample { CS::U8 => Image::from_u8(w, h, layout, (0..n * ch).map(|i| if (i + 1) % ch == 0 && i < ch * 5 { 0 } else { 200 }).collect()), _ => Image::from_u16(w, h, layout, &(0..n * ch).map(|i| if (i + 1) % ch == 0 && i < ch * 5 { 0 } else { 50_000 }).collect::>()), } .unwrap(); photocraft_codecs::encode(&img, format, &EncodeOptions::default()).unwrap() }; let cases: Vec<(&str, Vec, bool)> = vec![ ("a.png", enc(ChannelLayout::Rgba, CS::U8, Format::Png), true), ("a16.png", enc(ChannelLayout::Rgba, CS::U16, Format::Png), true), ("ga.png", enc(ChannelLayout::GrayA, CS::U8, Format::Png), true), ("a.tif", enc(ChannelLayout::Rgba, CS::U8, Format::Tiff), true), ("ga.tif", enc(ChannelLayout::GrayA, CS::U16, Format::Tiff), true), ("pal.png", photocraft_codecs::encode_png_indexed(w, h, &[0, 1].repeat(n / 2), &[[255, 0, 0], [0, 0, 255]], Some(0), None, None).unwrap(), true), ("pal_opaque.png", photocraft_codecs::encode_png_indexed(w, h, &[0, 1].repeat(n / 2), &[[255, 0, 0], [0, 0, 255]], None, None, None).unwrap(), false), ("rgb.png", enc(ChannelLayout::Rgb, CS::U8, Format::Png), false), ("g.png", enc(ChannelLayout::Gray, CS::U16, Format::Png), false), ("rgb.tif", enc(ChannelLayout::Rgb, CS::U8, Format::Tiff), false), ("rgb.jpg", enc(ChannelLayout::Rgb, CS::U8, Format::Jpeg), false), ]; for (name, bytes, transparent) in cases { let d = import(name, &bytes).unwrap_or_else(|e| panic!("{name}: {e}")).document; assert_opens_as(name, &d, transparent); // Photoshop numbers the next new layer after "Layer 0" as "Layer 1". assert_eq!(d.next_layer_name("Layer"), "Layer 1", "{name}"); } } #[test] fn layer_0_saves_back_to_png_and_reopens_as_layer_0() { let r = export(&single(ColorMode::Rgb, SampleType::U8, true), "png", &ExportOptions::default()).unwrap(); let d = import("a.png", &r.bytes).unwrap().document; assert_opens_as("first open", &d, true); let again = export(&d, "b.png", &ExportOptions::default()).unwrap(); assert!(again.warnings.iter().all(|w| !w.contains("flattened")), "a lone Layer 0 is written natively: {:?}", again.warnings); let back = import("b.png", &again.bytes).unwrap().document; assert_opens_as("reopened", &back, true); pixels_eq(&d, &back, 0.0); } #[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> { 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], want: &[Vec], 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> = 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"); } } /// A linear gray document needs the same display encoding as linear RGB when its profile /// cannot be embedded. Check decoded samples against the transfer function independently. #[test] fn linear_gray_exports_without_profiles_use_display_encoding() { for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { let mut d = columns(ColorMode::Grayscale, depth, &[&[0.25, 1.0], &[0.5, 1.0]]); d.icc_profile = Some(photocraft_cms::builtin::linear_gray().to_bytes()); let stored = column_pixels(&d, 2); for ext in ["tga", "bmp", "gif", "qoi"] { let r = export(&d, ext, &ExportOptions::default()).unwrap(); let img = photocraft_codecs::decode(&r.bytes).unwrap(); let values = img.to_normalized(); let stride = img.layout().channels(); for (i, src) in stored.iter().enumerate() { let expected = photocraft_color::convert::linear_to_srgb(src[0]); assert!( (values[16 * i * stride] - expected).abs() <= 1.5 / 255.0, "{depth:?} {ext} pixel {i}: got {}, expected {expected}", values[16 * i * stride] ); } assert!(r.warnings.iter().any(|w| w.contains("converted to sGray")), "{depth:?} {ext}: {:?}", r.warnings); } } } #[test] fn default_gray_exports_without_profiles_keep_samples() { for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { for icc in [None, Some(photocraft_cms::Builtin::SGray.profile().to_bytes())] { let mut d = columns(ColorMode::Grayscale, depth, &[&[0.25, 1.0], &[0.5, 1.0]]); d.icc_profile = icc; let stored = column_pixels(&d, 2); for ext in ["tga", "bmp", "gif", "qoi"] { let r = export(&d, ext, &ExportOptions::default()).unwrap(); let img = photocraft_codecs::decode(&r.bytes).unwrap(); let values = img.to_normalized(); let stride = img.layout().channels(); for (i, src) in stored.iter().enumerate() { assert!((values[16 * i * stride] - src[0]).abs() <= 1.0 / 255.0, "{depth:?} {ext} pixel {i}"); } assert!(!r.warnings.iter().any(|w| w.contains("converted to sGray")), "{depth:?} {ext}: {:?}", r.warnings); } } } } #[test] fn linear_gray_export_preserves_alpha_and_embeddable_profiles() { for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { let mut d = columns(ColorMode::Grayscale, depth, &[&[0.25, 0.5]]); d.icc_profile = Some(photocraft_cms::builtin::linear_gray().to_bytes()); let stored = column_pixels(&d, 1); let r = export(&d, "tga", &ExportOptions::default()).unwrap(); let img = photocraft_codecs::decode(&r.bytes).unwrap(); assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::GrayA); let values = img.to_normalized(); let expected = photocraft_color::convert::linear_to_srgb(stored[0][0]); assert!((values[0] - expected).abs() <= 1.5 / 255.0, "{depth:?}: gray {} vs {expected}", values[0]); assert!((values[1] - stored[0][1]).abs() <= 1.0 / 255.0, "{depth:?}: alpha changed"); let r = export(&d, "png", &ExportOptions::default()).unwrap(); let back = import("gray.png", &r.bytes).unwrap().document; assert_eq!(back.icc_profile, d.icc_profile, "{depth:?}: PNG keeps linear gray ICC"); assert_colors(&column_pixels(&back, 1), &stored, 1.0 / 255.0, "PNG keeps gray samples"); } } /// WebP can embed RGB profiles, but expanding gray to RGB drops a gray profile. Its untagged /// RGB samples must use the sRGB transfer function, not the source gray encoding. #[test] fn linear_gray_webp_exports_use_display_encoding() { use photocraft_codecs::{ChannelLayout, EncodeOptions}; for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { for alpha in [1.0, 0.5] { let mut d = columns(ColorMode::Grayscale, depth, &[&[0.25, alpha], &[0.5, alpha]]); d.icc_profile = Some(photocraft_cms::builtin::linear_gray().to_bytes()); let stored = column_pixels(&d, 2); for lossless in [true, false] { let opts = ExportOptions { encode: EncodeOptions { webp_lossless: lossless, webp_quality: 100, ..Default::default() }, ..Default::default() }; let r = export(&d, "webp", &opts).unwrap(); let img = photocraft_codecs::decode(&r.bytes).unwrap(); let what = format!("{depth:?}, alpha {alpha}, lossless {lossless}"); assert_eq!(img.layout(), if alpha == 1.0 { ChannelLayout::Rgb } else { ChannelLayout::Rgba }, "{what}"); assert_eq!(img.icc, None, "{what}: a gray ICC cannot describe RGB samples"); let values = img.to_normalized(); let stride = img.layout().channels(); let tolerance = if lossless { 1.5 / 255.0 } else { 3.0 / 255.0 }; for (i, src) in stored.iter().enumerate() { let offset = (8 * img.width() as usize + 16 * i + 8) * stride; let expected = photocraft_color::convert::linear_to_srgb(src[0]); for &value in &values[offset..offset + 3] { assert!((value - expected).abs() <= tolerance, "{what}, pixel {i}: got {value}, expected {expected}"); } if img.layout().has_alpha() { assert!((values[offset + 3] - src[1]).abs() <= 1.0 / 255.0, "{what}: alpha changed"); } } assert!(r.warnings.iter().any(|w| w.contains("converted to sGray")), "{what}: {:?}", r.warnings); } } } } #[test] fn default_gray_webp_exports_keep_samples() { for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { for icc in [None, Some(photocraft_cms::Builtin::SGray.profile().to_bytes())] { let mut d = columns(ColorMode::Grayscale, depth, &[&[0.25, 0.5], &[0.5, 1.0]]); d.icc_profile = icc; let stored = column_pixels(&d, 2); let r = export(&d, "webp", &ExportOptions::default()).unwrap(); let img = photocraft_codecs::decode(&r.bytes).unwrap(); assert_eq!(img.icc, None, "{depth:?}: a gray ICC cannot describe RGB samples"); let back = import("gray.webp", &r.bytes).unwrap().document; let want: Vec> = stored.iter().map(|p| vec![p[0], p[0], p[0], p[1]]).collect(); assert_colors(&column_pixels(&back, 2), &want, 1.0 / 255.0, &format!("{depth:?}: sGray samples")); assert!(!r.warnings.iter().any(|w| w.contains("converted to sGray")), "{depth:?}: {:?}", r.warnings); } } } #[test] fn rgb_webp_exports_keep_embeddable_profiles() { for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { let mut d = columns(ColorMode::Rgb, depth, &[&[0.25, 0.5, 0.75, 0.5]]); d.icc_profile = Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes()); let r = export(&d, "webp", &ExportOptions::default()).unwrap(); let back = import("rgb.webp", &r.bytes).unwrap().document; assert_eq!(back.icc_profile, d.icc_profile, "{depth:?}: WebP keeps RGB ICC"); assert_colors(&column_pixels(&back, 1), &column_pixels(&d, 1), 1.0 / 255.0, &format!("{depth:?}: RGB samples")); assert!(!r.warnings.iter().any(|w| w.contains("converted to sRGB")), "{depth:?}: {:?}", r.warnings); } } /// #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::::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); } } /// Guides stored by Photoshop in a JPEG's APP13 resources (resource 1032) open as document guides. #[test] fn jpeg_photoshop_guides_open_as_document_guides() { let full = export(&smooth(ColorMode::Rgb), "jpg", &ExportOptions::default()).unwrap().bytes; assert!(import("x.jpg", &full).unwrap().document.guides.vertical.is_empty()); // Version, grid 576 x 576, two guides in 1/32 px: vertical at 10, horizontal at 5. let mut guides = Vec::new(); for v in [1u32, 576, 576, 2] { guides.extend_from_slice(&v.to_be_bytes()); } guides.extend_from_slice(&(10i32 * 32).to_be_bytes()); guides.push(0); guides.extend_from_slice(&(5i32 * 32).to_be_bytes()); guides.push(1); let mut res = b"8BIM".to_vec(); res.extend_from_slice(&1032u16.to_be_bytes()); res.extend_from_slice(&[0, 0]); res.extend_from_slice(&(guides.len() as u32).to_be_bytes()); res.extend_from_slice(&guides); let mut seg = b"Photoshop 3.0\0".to_vec(); seg.extend_from_slice(&res); let mut jpeg = full[..2].to_vec(); jpeg.extend_from_slice(&[0xFF, 0xED]); jpeg.extend_from_slice(&((seg.len() + 2) as u16).to_be_bytes()); jpeg.extend_from_slice(&seg); jpeg.extend_from_slice(&full[2..]); let g = import("x.jpg", &jpeg).unwrap().document.guides; assert_eq!(g.vertical, vec![10.0]); assert_eq!(g.horizontal, vec![5.0]); } /// A GIF of `frames` identical 1x1 black frames. fn gif_frames(frames: usize) -> Vec { 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::::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); } /// Alpha channel values for the #2124 tests: white (selected) on the left, mid grey, black. fn alpha_value(x: u32) -> f32 { match x { 0..=3 => 1.0, 4 => g(128), _ => 0.0, } } /// `d` with one alpha channel holding [`alpha_value`] (Photoshop's "Alpha 1"). fn with_alpha_channel(mut d: photocraft_doc::Document, name: &str) -> photocraft_doc::Document { let fmt = photocraft_color::PixelFormat::new(ColorMode::Grayscale, d.depth, false); let mut s = photocraft_raster::Surface::new(fmt); let r = d.bounds(); let vals: Vec = (r.y0..r.y1).flat_map(|_| (r.x0..r.x1).map(|x| alpha_value(x as u32))).collect(); s.write_region(r, &vals); d.channels.push(photocraft_doc::AlphaChannel::new(name, s)); d } /// Header and decoded pixels of a TGA file. fn tga(bytes: &[u8]) -> (u8, u8, photocraft_codecs::Image) { (bytes[16], bytes[17] & 0x0f, photocraft_codecs::decode(bytes).expect("decode tga")) } /// #2124: Photoshop writes the document's alpha channel as a 32-bit Targa's alpha (8 alpha bits /// in the descriptor), and the file opens back with that alpha. 16-bit documents are written as /// 8-bit Targa without panicking. #[test] fn tga_writes_the_alpha_channel_as_32_bit_alpha() { for depth in [SampleType::U8, SampleType::U16] { let d = with_alpha_channel(single(ColorMode::Rgb, depth, false), "Alpha 1"); let r = export(&d, "a.tga", &ExportOptions::default()).unwrap(); let (bpp, alpha_bits, img) = tga(&r.bytes); assert_eq!((bpp, alpha_bits), (32, 8), "{depth:?}"); assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Rgba, "{depth:?}"); let (w, _) = img.dimensions(); for (i, px) in img.data().as_chunks::<4>().0.iter().enumerate() { let x = i as u32 % w; assert_eq!(px[3], (alpha_value(x) * 255.0).round() as u8, "{depth:?} pixel {i}"); } // The colours are the document's (opaque Background, untouched by the alpha). let want = single(ColorMode::Rgb, SampleType::U8, false); let back = import("a.tga", &r.bytes).unwrap().document; let (sa, sb) = (want.layers[0].surface().unwrap(), back.layers[0].surface().unwrap()); let (va, vb) = (sa.read_region(want.bounds()), sb.read_region(back.bounds())); for (i, (a, b)) in va.as_chunks::<4>().0.iter().zip(vb.as_chunks::<4>().0.iter()).enumerate() { assert!(a[..3].iter().zip(&b[..3]).all(|(p, q)| (p - q).abs() <= 1.0 / 255.0), "{depth:?} colour {i}: {a:?} {b:?}"); assert_eq!(b[3], 1.0, "{depth:?} the Background stays opaque"); } // Round trip, as in Photoshop: the alpha opens back as the "Alpha 1" channel (#2225). let [ch] = &back.channels[..] else { panic!("{depth:?}: one alpha channel, got {}", back.channels.len()) }; assert_eq!(ch.name, "Alpha 1"); for (i, a) in ch.surface.read_region(back.bounds()).iter().enumerate() { assert!((a - alpha_value(i as u32 % 9)).abs() <= 0.5 / 255.0, "{depth:?} alpha {i}: {a}"); } } } /// A 64×64 uncompressed Targa (bottom-left origin), byte for byte like the files the Photoshop /// measurements in #2225 opened: red everywhere, alpha (when `bpp` is 32) 255 on the left half and /// 0 on the right, with `alpha_bits` in the descriptor. fn tga_file(bpp: u8, alpha_bits: u8) -> Vec { let mut b = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 0, 64, 0, bpp, alpha_bits]; for _y in 0..64 { for x in 0..64 { b.extend_from_slice(&[0, 0, 255]); if bpp == 32 { b.push(if x < 32 { 255 } else { 0 }); } } } b } /// #2225, measured on Photoshop 27.11: a 32-bit Targa opens as an opaque, locked Background plus /// an "Alpha 1" channel holding the fourth byte (also when the descriptor declares 0 alpha bits), /// and the stored colours stay visible where the alpha is 0. A 24-bit Targa has no channel. #[test] fn tga_32_bit_opens_its_alpha_as_a_channel_like_photoshop() { for alpha_bits in [8, 0] { let d = import("a.tga", &tga_file(32, alpha_bits)).unwrap().document; let [bg] = &d.layers[..] else { panic!("one layer") }; assert_eq!(bg.name, "Background", "alpha bits {alpha_bits}"); assert!(bg.locks.transparency && bg.locks.position); let px = bg.surface().unwrap().read_region(d.bounds()); for (i, p) in px.as_chunks::<4>().0.iter().enumerate() { assert_eq!(*p, [1.0, 0.0, 0.0, 1.0], "alpha bits {alpha_bits}: pixel {i} keeps its red, opaque"); } let [ch] = &d.channels[..] else { panic!("alpha bits {alpha_bits}: one channel, got {}", d.channels.len()) }; assert_eq!((ch.name.as_str(), ch.spot), ("Alpha 1", None)); let a = ch.surface.read_region(d.bounds()); assert_eq!((a[64 * 32 + 10], a[64 * 32 + 54]), (1.0, 0.0), "alpha bits {alpha_bits}"); } let d = import("a.tga", &tga_file(24, 0)).unwrap().document; assert!(d.channels.is_empty()); assert_eq!(d.layers[0].name, "Background"); } /// Layer transparency is still written as the Targa's alpha when there is no alpha channel (a /// choice left to the maintainers in #2225); the file opens back as Photoshop opens it, with the /// alpha as "Alpha 1" and the colours of transparent pixels kept. #[test] fn tga_transparency_reopens_as_alpha_1() { let d = single(ColorMode::Rgb, SampleType::U8, true); let r = export(&d, "a.tga", &ExportOptions::default()).unwrap(); let back = import("a.tga", &r.bytes).unwrap().document; let src = d.layers[0].surface().unwrap().read_region(d.bounds()); let got = back.layers[0].surface().unwrap().read_region(back.bounds()); let alpha = back.channels.first().expect("Alpha 1").surface.read_region(back.bounds()); for (i, ((s, g), a)) in src.as_chunks::<4>().0.iter().zip(got.as_chunks::<4>().0.iter()).zip(&alpha).enumerate() { assert_eq!(&g[..3], &s[..3], "colour {i}"); assert_eq!((g[3], *a), (1.0, s[3]), "alpha {i}"); } } /// #2124: as in Photoshop, the alpha channel (not layer transparency) is the alpha; transparent /// pixels are composited over white. Spot channels are not alpha channels. #[test] fn tga_alpha_channel_wins_over_transparency_and_skips_spot_channels() { let mut d = with_alpha_channel(single(ColorMode::Rgb, SampleType::U8, true), "Alpha 2"); let spot = photocraft_raster::Surface::new(photocraft_color::PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false)); let spot = photocraft_doc::AlphaChannel { spot: Some((photocraft_color::Color::rgb(1.0, 0.0, 0.0), 1.0)), ..photocraft_doc::AlphaChannel::new("Spot", spot) }; d.channels.insert(0, spot); let r = export(&d, "a.tga", &ExportOptions::default()).unwrap(); let (bpp, alpha_bits, img) = tga(&r.bytes); assert_eq!((bpp, alpha_bits), (32, 8)); let src = d.layers[0].surface().unwrap().read_region(d.bounds()); for (i, (px, s)) in img.data().as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0.iter()).enumerate() { assert_eq!(px[3], (alpha_value(i as u32 % 9) * 255.0).round() as u8, "alpha {i}"); let over_white = s[0] * s[3] + 1.0 - s[3]; assert!((f32::from(px[0]) / 255.0 - over_white).abs() <= 1.0 / 255.0, "red {i}: {} vs {over_white}", px[0]); } } /// #2124: Photoshop writes no channel as the alpha when there are several (measured again in /// #2482: the file is still 32-bit, fully opaque), and grayscale Targas have no alpha at all. #[test] fn tga_ignores_several_alpha_channels_and_grayscale() { let d = with_alpha_channel(with_alpha_channel(single(ColorMode::Rgb, SampleType::U8, false), "Alpha 1"), "Alpha 2"); let r = export(&d, "a.tga", &ExportOptions::default()).unwrap(); let (bpp, alpha_bits, img) = tga(&r.bytes); assert_eq!((bpp, alpha_bits), (32, 8)); assert!(img.data().as_chunks::<4>().0.iter().all(|p| p[3] == 255), "no channel is the alpha"); assert!(r.warnings.iter().any(|w| w.contains("one alpha channel")), "{:?}", r.warnings); let d = with_alpha_channel(single(ColorMode::Grayscale, SampleType::U8, false), "Alpha 1"); let r = export(&d, "a.tga", &ExportOptions::default()).unwrap(); assert_eq!(tga(&r.bytes).0, 8); // Bits per pixel apply to RGB only: an explicit choice is reported, not applied. let r = export(&d, "a.tga", &tga_opts(TgaBits::Bits32)).unwrap(); assert_eq!(tga(&r.bytes).0, 8); assert!(r.warnings.iter().any(|w| w.contains("grayscale")), "{:?}", r.warnings); } const TGA_W: u32 = 300; const TGA_H: u32 = 270; fn tga_opts(bits: TgaBits) -> ExportOptions { ExportOptions { tga_bits: Some(bits), ..ExportOptions::default() } } /// A `TGA_W`×`TGA_H` 8-bit RGB document (more than one tile each way) whose opaque Background /// reaches every byte value in each channel, and its colours as interleaved RGB bytes. fn tga_rgb_doc() -> (photocraft_doc::Document, Vec) { let mut d = photocraft_doc::Document::new("t", photocraft_geom::Size::new(TGA_W, TGA_H), ColorMode::Rgb, SampleType::U8); let (mut rgb, mut rgba) = (Vec::new(), Vec::new()); for y in 0..TGA_H { for x in 0..TGA_W { let px = [((x + 3 * y) & 255) as u8, ((7 * x + y) & 255) as u8, ((x * y + 11) & 255) as u8]; rgb.extend_from_slice(&px); rgba.extend_from_slice(&px); rgba.push(255); } } let s = photocraft_raster::Surface::from_interleaved(d.pixel_format(), d.bounds(), &rgba); let mut bg = photocraft_doc::Layer::new("Background", photocraft_doc::LayerContent::Raster(s)); bg.locks.transparency = true; bg.locks.position = true; d.layers.push(bg); (d, rgb) } /// `d` with an 8-bit "Alpha 1" channel holding `alpha` (one byte per pixel, row-major). fn with_alpha_bytes(mut d: photocraft_doc::Document, alpha: &[u8]) -> photocraft_doc::Document { let mut s = photocraft_raster::Surface::from_interleaved(photocraft_color::PixelFormat::GRAY8, d.bounds(), alpha); s.prune(); d.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", s)); d } /// A left-to-right gradient through every alpha value, 0 to 255. fn gradient_alpha() -> Vec { (0..TGA_H).flat_map(|_| (0..TGA_W).map(|x| (x * 255 / (TGA_W - 1)) as u8)).collect() } /// A decoded Targa's interleaved RGB and alpha bytes. fn rgb_and_alpha(img: &photocraft_codecs::Image) -> (Vec, Vec) { assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Rgba); let px = img.data().as_chunks::<4>().0; (px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(), px.iter().map(|p| p[3]).collect()) } /// A reopened document's Background colours (interleaved RGB bytes) and its "Alpha 1" bytes. fn reopened(bytes: &[u8]) -> (Vec, Option>) { let d = import("a.tga", bytes).unwrap().document; let [bg] = &d.layers[..] else { panic!("one layer, got {}", d.layers.len()) }; assert_eq!(bg.name, "Background"); let px = bg.surface().unwrap().to_interleaved(d.bounds()); assert!(px.as_chunks::<4>().0.iter().all(|p| p[3] == 255), "the Background is opaque"); let rgb = px.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); let alpha = match &d.channels[..] { [] => None, [ch] => { assert_eq!(ch.name, "Alpha 1"); Some(ch.surface.to_interleaved(d.bounds())) } more => panic!("{} channels", more.len()), }; (rgb, alpha) } /// A 32-bit Targa holds the document's colours and its alpha channel exactly (straight, never /// premultiplied), and reopens as the same Background and "Alpha 1", byte for byte. #[test] fn tga_32_bit_round_trip_keeps_rgb_and_alpha_channel_byte_identical() { let (d, rgb) = tga_rgb_doc(); let alpha = gradient_alpha(); let d = with_alpha_bytes(d, &alpha); let r = export(&d, "a.tga", &tga_opts(TgaBits::Bits32)).unwrap(); let (bpp, alpha_bits, img) = tga(&r.bytes); assert_eq!((bpp, alpha_bits), (32, 8)); let (file_rgb, file_alpha) = rgb_and_alpha(&img); assert!(file_rgb == rgb, "the file's colours are the document's"); assert!(file_alpha == alpha, "the file's alpha is the alpha channel"); let (back_rgb, back_alpha) = reopened(&r.bytes); assert!(back_rgb == rgb, "the reopened colours are the document's"); assert!(back_alpha.as_deref() == Some(alpha.as_slice()), "the reopened Alpha 1 is the alpha channel"); } /// An all-black alpha channel (a texture's empty mask) is written as alpha 0 everywhere, and the /// colours under it are kept exactly: nothing is cleared or premultiplied. #[test] fn tga_32_bit_all_black_alpha_channel_keeps_every_colour() { let (d, rgb) = tga_rgb_doc(); let black = vec![0u8; (TGA_W * TGA_H) as usize]; let d = with_alpha_bytes(d, &black); for opts in [tga_opts(TgaBits::Bits32), ExportOptions::default()] { let r = export(&d, "a.tga", &opts).unwrap(); let (bpp, alpha_bits, img) = tga(&r.bytes); assert_eq!((bpp, alpha_bits), (32, 8), "{:?}", opts.tga_bits); let (file_rgb, file_alpha) = rgb_and_alpha(&img); assert!(file_alpha.iter().all(|&a| a == 0), "alpha 0 everywhere"); assert!(file_rgb == rgb, "{:?}: the colours under alpha 0 are unchanged", opts.tga_bits); let (back_rgb, back_alpha) = reopened(&r.bytes); assert!(back_rgb == rgb, "the reopened colours are unchanged"); assert!(back_alpha.as_deref() == Some(black.as_slice()), "Alpha 1 reopens black"); } } /// A 24-bit Targa has no alpha: the alpha channel is left out (and named in a warning), the /// colours are kept exactly, and the file reopens without a channel. #[test] fn tga_24_bit_writes_no_alpha() { let (d, rgb) = tga_rgb_doc(); let d = with_alpha_bytes(d, &gradient_alpha()); let r = export(&d, "a.tga", &tga_opts(TgaBits::Bits24)).unwrap(); let (bpp, alpha_bits, img) = tga(&r.bytes); assert_eq!((bpp, alpha_bits), (24, 0)); assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Rgb); assert!(img.data() == rgb.as_slice(), "the colours are the document's"); assert!(r.warnings.iter().any(|w| w.contains("24-bit") && w.contains("\"Alpha 1\"")), "{:?}", r.warnings); let (back_rgb, back_alpha) = reopened(&r.bytes); assert!(back_rgb == rgb); assert_eq!(back_alpha, None, "no alpha channel"); } /// A 24-bit Targa of a transparent layer is the layer composited over white. #[test] fn tga_24_bit_composites_transparency_over_white() { let d = single(ColorMode::Rgb, SampleType::U8, true); let r = export(&d, "a.tga", &tga_opts(TgaBits::Bits24)).unwrap(); let (bpp, alpha_bits, img) = tga(&r.bytes); assert_eq!((bpp, alpha_bits), (24, 0)); assert!(r.warnings.iter().any(|w| w.contains("composited over white")), "{:?}", r.warnings); let src = d.layers[0].surface().unwrap().read_region(d.bounds()); for (i, (px, s)) in img.data().as_chunks::<3>().0.iter().zip(src.as_chunks::<4>().0.iter()).enumerate() { let over_white = s[0] * s[3] + 1.0 - s[3]; assert!((f32::from(px[0]) / 255.0 - over_white).abs() <= 1.0 / 255.0, "red {i}: {} vs {over_white}", px[0]); } } /// 32 bits without an alpha channel: the layer's transparency is the alpha (as before Targa /// Options), and an opaque image gets a fully opaque alpha, its colours unchanged. #[test] fn tga_32_bit_without_alpha_channel_writes_transparency_or_opaque() { let (d, rgb) = tga_rgb_doc(); let r = export(&d, "a.tga", &tga_opts(TgaBits::Bits32)).unwrap(); let (bpp, alpha_bits, img) = tga(&r.bytes); assert_eq!((bpp, alpha_bits), (32, 8)); let (file_rgb, file_alpha) = rgb_and_alpha(&img); assert!(file_rgb == rgb); assert!(file_alpha.iter().all(|&a| a == 255)); // Without Targa Options an opaque document stays 24-bit. assert_eq!(tga(&export(&d, "a.tga", &ExportOptions::default()).unwrap().bytes).0, 24); let d = single(ColorMode::Rgb, SampleType::U8, true); let r = export(&d, "a.tga", &tga_opts(TgaBits::Bits32)).unwrap(); let (_, file_alpha) = rgb_and_alpha(&tga(&r.bytes).2); let src = d.layers[0].surface().unwrap().to_interleaved(d.bounds()); let src_alpha: Vec = src.as_chunks::<4>().0.iter().map(|p| p[3]).collect(); assert!(file_alpha == src_alpha, "the transparency is the alpha"); } /// 16-bit documents are written as 8-bit Targas at either depth. #[test] fn tga_bits_apply_to_16_bit_documents() { let d = with_alpha_channel(single(ColorMode::Rgb, SampleType::U16, false), "Alpha 1"); for (bits, want) in [(TgaBits::Bits24, (24, 0)), (TgaBits::Bits32, (32, 8))] { let r = export(&d, "a.tga", &tga_opts(bits)).unwrap(); let (bpp, alpha_bits, _) = tga(&r.bytes); assert_eq!((bpp, alpha_bits), want, "{bits:?}"); } } /// Targa Options starts at 32 bits with an alpha channel (spot channels don't count), else 24. #[test] fn tga_default_bits_follow_the_alpha_channels() { let d = single(ColorMode::Rgb, SampleType::U8, true); assert_eq!(tga_default_bits(&d), TgaBits::Bits24, "transparency alone"); let mut spot = d.clone(); let s = photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8); spot.channels .push(photocraft_doc::AlphaChannel { spot: Some((photocraft_color::Color::rgb(1.0, 0.0, 0.0), 1.0)), ..photocraft_doc::AlphaChannel::new("Spot", s) }); assert_eq!(tga_default_bits(&spot), TgaBits::Bits24, "spot channel"); assert_eq!(tga_default_bits(&with_alpha_channel(d, "Alpha 1")), TgaBits::Bits32); assert_eq!((TgaBits::from_bits(24), TgaBits::from_bits(32), TgaBits::from_bits(16)), (Some(TgaBits::Bits24), Some(TgaBits::Bits32), None)); } #[test] fn a_lab_document_is_written_as_a_lab_tiff_and_opens_as_lab() { // #4936: TIFF stores CIE L*a*b* (CIELab): a Lab document with one layer keeps its pixels. for depth in [SampleType::U8, SampleType::U16] { let d = single(ColorMode::Lab, depth, false); let r = export(&d, "tif", &ExportOptions::default()).unwrap(); assert!(r.warnings.iter().all(|w| !w.contains("sRGB") && !w.contains("flattened")), "{depth:?}: {:?}", r.warnings); let back = import("x.tif", &r.bytes).unwrap().document; assert_eq!((back.mode, back.depth), (ColorMode::Lab, depth)); pixels_eq(&d, &back, if depth == SampleType::U8 { 0.0 } else { 1.5 / 65535.0 }); } // Other formats still go through sRGB. let r = export(&single(ColorMode::Lab, SampleType::U8, false), "png", &ExportOptions::default()).unwrap(); assert!(r.warnings.iter().any(|w| w.contains("sRGB")), "{:?}", r.warnings); } #[test] fn a_lab_document_with_one_layer_is_not_reported_as_flattened() { // A lone layer loses nothing: TIFF keeps it as Lab, the others go through the sRGB composite. let d = single(ColorMode::Lab, SampleType::U8, false); for ext in ["tif", "png", "jpg"] { let r = export(&d, ext, &ExportOptions::default()).unwrap(); assert!(r.warnings.iter().all(|w| !w.contains("flattened")), "{ext}: {:?}", r.warnings); if ext != "tif" { assert!(r.warnings.iter().any(|w| w.contains("Lab composite written as sRGB")), "{ext}: {:?}", r.warnings); } } // Two layers are flattened, and say so. let mut two = single(ColorMode::Lab, SampleType::U8, false); let fmt = two.pixel_format(); two.layers.push(raster("Top", fmt, two.bounds(), 5, true)); let r = export(&two, "tif", &ExportOptions::default()).unwrap(); assert!(r.warnings.iter().any(|w| w.contains("2 layer(s) flattened")), "{:?}", r.warnings); } #[test] fn a_stereo_png_keeps_its_stereo_mark_on_save() { use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, PngStereo, SampleType as CS}; let img = Image::from_raw(4, 2, ChannelLayout::Rgb, CS::U8, vec![40u8; 24]).unwrap(); for view in [PngStereo::Horizontal, PngStereo::Vertical] { let bytes = photocraft_codecs::encode(&img, Format::Png, &EncodeOptions { png_stereo: Some(view), ..Default::default() }).unwrap(); let doc = import("stereo.png", &bytes).unwrap().document; assert_eq!(doc.file_encoding.png_stereo, Some(view.mode())); // What File > Save does: reuse the settings the file was opened with (#6063). let mut opts = ExportOptions::default(); apply_file_encoding(&doc, &mut opts.encode); let r = export(&doc, "png", &opts).unwrap(); assert_eq!(photocraft_codecs::png_stereo(&r.bytes), Some(view), "{view:?}"); } // An ordinary PNG gains no mark. let plain = export(&single(ColorMode::Rgb, SampleType::U8, false), "png", &ExportOptions::default()).unwrap(); assert_eq!(photocraft_codecs::png_stereo(&plain.bytes), None); }