diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index 7bca7f2fc..2e65d5155 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -20,6 +20,15 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_ - Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened. - TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit. +Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through +the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed +that exact profile. Untagged composites use and embed PhotoCraft's synthetic coated CMYK profile; +invalid, non-CMYK or unusable embedded profiles are rejected before compositing. TIFF retains +8/16/32f depth and straight alpha; JPEG reduces to 8-bit with a warning and mattes transparency +over zero-ink white. A plain single raster layer retains its native samples. RGB-only formats use +the RGB composite directly. This RGB composite roundtrip does **not** preserve original ink +separations, black generation or native CMYK blending, and is not a claim of Photoshop parity. + ## Implemented decode limits `DecodeOptions::default()` applies `Limits` before decoded pixel allocation: diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index bcb9422e8..1778e5d06 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -304,6 +304,10 @@ pub(crate) fn csample(s: SampleType) -> CSample { /// Renders the document to a flat codec image (native pixels when possible). pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result { + document_to_image_for(doc, warnings, true) +} + +fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target: bool) -> Result { let (w, h) = (doc.size.width, doc.size.height); let canvas = doc.bounds(); let fmt = doc.pixel_format(); @@ -338,16 +342,42 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result Profile::parse(bytes).map_err(|e| IoError::Unsupported(format!("invalid CMYK composite profile: {e}")))?, + None => Builtin::CoatedCmyk.profile().clone(), + }; + if profile.color_space != ColorSpace::Cmyk { + return Err(IoError::Unsupported("CMYK composite requires a CMYK ICC profile".into())); + } + let srgb = Builtin::Srgb.profile(); + Transform::new(&profile, srgb, Intent::RelativeColorimetric, true).map_err(|e| IoError::Unsupported(e.to_string()))?; + Transform::new(srgb, &profile, Intent::RelativeColorimetric, true).map_err(|e| IoError::Unsupported(e.to_string()))?; + if icc.is_none() { + icc = Some(profile.to_bytes().to_vec()); + } + photocraft_compose::cmyk_space(doc) + } else { + None + }; let gray = fmt.mode == ColorMode::Grayscale; - if fmt.mode == ColorMode::Cmyk || fmt.mode == ColorMode::Lab { - // The compositor renders CMYK/Lab documents in sRGB (CMYK through the built-in - // profile), so the file is tagged sRGB. + if (fmt.mode == ColorMode::Cmyk && !cmyk) || fmt.mode == ColorMode::Lab { + // RGB-only targets use the sRGB composite directly, without reseparating CMYK. warnings.push(format!("{:?} composite written as sRGB RGB (colour-managed conversion)", fmt.mode)); icc = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes().to_vec()); } let cs = csample(fmt.sample); - let colors = if gray { 1 } else { 3 }; + let colors = if cmyk { + 4 + } else if gray { + 1 + } else { + 3 + }; // Rendered and quantised in bands (no full-size float composite), with alpha; the alpha // is dropped afterwards, in place, when every pixel turned out opaque. let mut data = try_buffer(n, (colors + 1) * cs.bytes())?; @@ -355,26 +385,35 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result Result<(), ()> { opaque &= band.px.iter().all(|p| p[3] >= 1.0); let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { - let mut out = Vec::with_capacity(range.len() * (colors + 1) * cs.bytes()); - let mut put = |v: f32| { - let v = if v.is_nan() { 0.0 } else { v }; - match cs { - CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), - CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), - CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), + photocraft_color::convert::with_cmyk_space(space.as_ref(), || { + let mut out = Vec::with_capacity(range.len() * (colors + 1) * cs.bytes()); + let mut put = |v: f32| { + let v = if v.is_nan() { 0.0 } else { v }; + match cs { + CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), + CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), + CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), + } + }; + let mut converted = [0.0; 5]; + for p in &band.px[range] { + if cmyk { + let channels = photocraft_raster::from_rgba_into(&fmt, *p, &mut converted); + for value in converted.iter().take(channels) { + put(*value); + } + continue; + } else if gray { + put(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); + } else { + put(p[0]); + put(p[1]); + put(p[2]); + } + put(p[3]); } - }; - for p in &band.px[range] { - if gray { - put(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); - } else { - put(p[0]); - put(p[1]); - put(p[2]); - } - put(p[3]); - } - out + out + }) }); for part in parts { data.extend_from_slice(&part); @@ -384,7 +423,16 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result Document { + let mut doc = Document::new("CMYK composite", Size::new(8, 8), ColorMode::Cmyk, depth); + doc.icc_profile = Some(Builtin::CoatedCmyk.profile().to_bytes()); + let surface = Surface::with_default(doc.pixel_format(), &[0.3, 0.6, 0.1, 0.2, alpha]); + doc.layers.push(Layer::new("Ink", LayerContent::Raster(surface))); + doc.layers.push(Layer::new("Empty", LayerContent::Raster(Surface::new(doc.pixel_format())))); + doc +} + +fn tag(bytes: &[u8], id: u16) -> Option { + assert_eq!(&bytes[..2], b"II"); + let start = usize::try_from(u32::from_le_bytes(bytes[4..8].try_into().unwrap())).unwrap(); + let count = usize::from(u16::from_le_bytes(bytes[start..start + 2].try_into().unwrap())); + bytes[start + 2..] + .as_chunks::<12>() + .0 + .iter() + .take(count) + .find_map(|entry| (u16::from_le_bytes(entry[..2].try_into().unwrap()) == id).then(|| u16::from_le_bytes(entry[8..10].try_into().unwrap()))) +} + +#[test] +fn tiff_keeps_cmyk_profile_depth_and_straight_alpha_when_composited() { + for (depth, sample) in [(SampleType::U8, CodecSample::U8), (SampleType::U16, CodecSample::U16), (SampleType::F32, CodecSample::F32)] { + for alpha in [1.0, 0.5] { + let doc = document(depth, alpha); + + let result = export(&doc, "tiff", &ExportOptions::default()).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.layout(), if alpha == 1.0 { ChannelLayout::Cmyk } else { ChannelLayout::CmykA }); + assert_eq!(image.sample_type(), sample); + assert_eq!(image.icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + assert_eq!(tag(&result.bytes, 262), Some(5)); + assert_eq!(tag(&result.bytes, 277), Some(if alpha == 1.0 { 4 } else { 5 })); + assert_eq!(tag(&result.bytes, 338), if alpha == 1.0 { None } else { Some(2) }); + if alpha < 1.0 { + assert!(image.to_normalized().as_chunks::<5>().0.iter().all(|px| (px[4] - alpha).abs() < 0.004)); + let opaque = document_to_image(&document(depth, 1.0), &mut Vec::new()).unwrap().to_normalized(); + for (px, color) in image.to_normalized().as_chunks::<5>().0.iter().zip(opaque.as_chunks::<4>().0) { + assert!(px[..4].iter().zip(color).all(|(a, b)| (a - b).abs() < 0.004)); + } + } + } + } +} + +#[test] +fn tiff_keeps_true_zero_alpha_when_composited() { + for (depth, sample) in [(SampleType::U8, CodecSample::U8), (SampleType::U16, CodecSample::U16), (SampleType::F32, CodecSample::F32)] { + let doc = document(depth, 0.0); + + let result = export(&doc, "tiff", &ExportOptions::default()).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.layout(), ChannelLayout::CmykA); + assert_eq!(image.sample_type(), sample); + assert_eq!(image.icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + assert_eq!(tag(&result.bytes, 262), Some(5)); + assert_eq!(tag(&result.bytes, 277), Some(5)); + assert_eq!(tag(&result.bytes, 338), Some(2)); + assert!(image.to_normalized().as_chunks::<5>().0.iter().all(|px| px[4].abs() < f32::EPSILON)); + } +} + +#[test] +fn psd_keeps_cmyk_mode_depth_and_profile_when_composited() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let doc = document(depth, 0.5); + + let result = export(&doc, "psd", &ExportOptions::default()).unwrap(); + + let back = photocraft_io::import("composite.psd", &result.bytes).unwrap().document; + assert_eq!(back.mode, ColorMode::Cmyk); + assert_eq!(back.depth, depth); + assert_eq!(back.icc_profile, doc.icc_profile); + assert_eq!(back.layer_count(), doc.layer_count()); + } +} + +#[test] +fn jpeg_keeps_four_components_profile_and_zero_ink_matte_when_composited() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for alpha in [1.0, 0.5, 0.0] { + let doc = document(depth, alpha); + let mut opts = ExportOptions::default(); + opts.encode.jpeg_quality = 100; + + let result = export(&doc, "jpg", &opts).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.layout(), ChannelLayout::Cmyk); + assert_eq!(image.icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + let adobe = result.bytes.windows(5).position(|bytes| bytes == b"Adobe").unwrap(); + assert_eq!(result.bytes[adobe + 11], 0); + let mut marker = 2; + while result.bytes[marker + 1] != 192 { + let length = usize::from(u16::from_be_bytes(result.bytes[marker + 2..marker + 4].try_into().unwrap())); + marker += length + 2; + } + assert_eq!(result.bytes[marker + 9], 4); + let expected = document_to_image(&doc, &mut Vec::new()).unwrap(); + for (px, source) in image.to_normalized().as_chunks::<4>().0.iter().zip(expected.to_normalized().chunks_exact(expected.layout().channels())) { + for channel in 0..4 { + let ink = source[channel] * if expected.layout().has_alpha() { source[4] } else { 1.0 }; + assert!((px[channel] - ink).abs() < 0.015, "{px:?} vs {source:?}"); + } + } + if depth != SampleType::U8 { + assert!(result.warnings.iter().any(|warning| warning.contains("bit")), "{:?}", result.warnings); + } + } + } +} + +#[test] +fn composite_uses_distinctive_document_profile_on_every_worker() { + let params = photocraft_cms::synth::CmykParams { + description: "Export synthetic uncoated".into(), + tvi: [0.26, 0.26, 0.26, 0.3], + grid_a2b: 5, + grid_b2a: 9, + ..Default::default() + }; + let profile = photocraft_cms::synth::cmyk_profile(¶ms); + let mut doc = document(SampleType::F32, 0.5); + doc.size = Size::new(64, 64); + doc.icc_profile = Some(profile.to_bytes()); + let rgb = photocraft_compose::flatten(&doc); + let transform = Transform::new(Builtin::Srgb.profile(), &profile, Intent::RelativeColorimetric, true).unwrap(); + let fallback = Transform::new(Builtin::Srgb.profile(), Builtin::CoatedCmyk.profile(), Intent::RelativeColorimetric, true).unwrap(); + let mut expected = [0.0; 4]; + transform.eval_fast(&rgb.px[0][..3], &mut expected); + let mut wrong = [0.0; 4]; + fallback.eval_fast(&rgb.px[0][..3], &mut wrong); + assert!(expected.iter().zip(wrong).any(|(a, b)| (a - b).abs() > 0.02)); + assert!(photocraft_io::pixels::bands(rgb.px.len()).len() > 1); + + let image = document_to_image(&doc, &mut Vec::new()).unwrap(); + + assert_eq!(image.layout(), ChannelLayout::CmykA); + for px in image.to_normalized().as_chunks::<5>().0 { + assert!(px[..4].iter().zip(expected).all(|(a, b)| (a - b).abs() < 0.00001), "{px:?} vs {expected:?}"); + } + for extension in ["tiff", "jpg"] { + let encoded = export(&doc, extension, &ExportOptions::default()).unwrap(); + assert_eq!(decode(&encoded.bytes).unwrap().icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + } +} + +#[test] +fn cmyk_composite_rejects_invalid_non_cmyk_and_unusable_profiles() { + let without_tags = |prefix: &[u8]| { + let mut bytes = Builtin::CoatedCmyk.profile().to_bytes().to_vec(); + let count = usize::try_from(u32::from_be_bytes(bytes[128..132].try_into().unwrap())).unwrap(); + for entry in bytes[132..].as_chunks_mut::<12>().0.iter_mut().take(count) { + if &entry[..3] == prefix { + entry[..4].copy_from_slice(b"test"); + } + } + std::sync::Arc::new(bytes) + }; + for bytes in [std::sync::Arc::new(vec![1, 2, 3]), Builtin::Srgb.profile().to_bytes(), without_tags(b"B2A"), without_tags(b"A2B")] { + let mut doc = document(SampleType::U8, 1.0); + doc.icc_profile = Some(bytes); + + for extension in ["tiff", "jpg"] { + assert!(export(&doc, extension, &ExportOptions::default()).is_err(), "accepted invalid profile for {extension}"); + } + // RGB-only targets never write CMYK, so they still export (through the built-in profile). + assert!(export(&doc, "png", &ExportOptions::default()).is_ok(), "rejected invalid profile for png"); + } +} + +#[test] +fn untagged_composite_embeds_coated_cmyk() { + let mut doc = document(SampleType::U8, 1.0); + doc.icc_profile = None; + + let image = document_to_image(&doc, &mut Vec::new()).unwrap(); + + assert_eq!(image.layout(), ChannelLayout::Cmyk); + assert_eq!(image.icc.unwrap(), Builtin::CoatedCmyk.profile().to_bytes().as_slice()); +} + +#[test] +fn native_samples_are_preserved_without_composite_roundtrip() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = document(depth, 0.5); + doc.layers.pop(); + let LayerContent::Raster(surface) = &doc.layers[0].content else { panic!() }; + let expected = surface.to_interleaved(doc.bounds()); + + let image = document_to_image(&doc, &mut Vec::new()).unwrap(); + + assert_eq!(image.data(), expected); + } +} + +#[test] +fn rgb_only_export_uses_direct_composite_not_reseparated_cmyk() { + let doc = document(SampleType::U16, 0.5); + let rgb = photocraft_compose::flatten(&doc); + let expected = + photocraft_codecs::Image::from_normalized(8, 8, ChannelLayout::Rgba, CodecSample::U16, &rgb.px.into_iter().flatten().collect::>()).unwrap(); + + let result = export(&doc, "png", &ExportOptions::default()).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.data(), expected.data()); + assert_eq!(Profile::parse(image.icc.as_ref().unwrap()).unwrap().color_space, photocraft_cms::ColorSpace::Rgb); +} + +#[test] +fn layered_tiff_keeps_its_existing_cmyk_psd_composite_path() { + let doc = document(SampleType::U16, 0.5); + let opts = ExportOptions { tiff_layers: true, ..Default::default() }; + + let result = export(&doc, "tiff", &opts).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.layout(), ChannelLayout::CmykA); + assert_eq!(image.icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + assert!(image.meta.photoshop_layers.is_some()); + assert_eq!(tag(&result.bytes, 338), Some(2)); + assert!(image.to_normalized().as_chunks::<5>().0.iter().all(|px| (px[4] - 0.5).abs() < 0.0001)); +}