mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-13 14:59:06 +00:00
A CMYK document with several layers, masks or effects is flattened through the RGB compositor. Its TIFF and JPEG exports were written as sRGB RGB, losing the document's mode and ICC profile. The composite is now converted back through the document's CMYK profile (relative colorimetric with black point compensation) inside each parallel worker, written as four ink channels plus straight alpha at the document depth, and tagged with that profile (the built-in coated CMYK profile when the document is untagged). Invalid, non-CMYK or unusable profiles are rejected. RGB-only formats keep the direct sRGB composite, and a single plain raster layer keeps its native samples. This fixes the output mode and tagging only: the RGB roundtrip does not preserve original ink separations, black generation or CMYK-native blending. Claude-Session: https://claude.ai/code/session_01BSB7fMQUccqFQekksDGWpo Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
3 files changed
+319
-26
No files matched your search
@@ -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:
|
||||
|
||||
+75
-26
@@ -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<String>) -> Result<Image, IoError> {
|
||||
document_to_image_for(doc, warnings, true)
|
||||
}
|
||||
|
||||
fn document_to_image_for(doc: &Document, warnings: &mut Vec<String>, cmyk_target: bool) -> Result<Image, IoError> {
|
||||
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<String>) -> Result<I
|
||||
} else {
|
||||
let count = doc.layer_count();
|
||||
warnings.push(format!("{count} layer(s) flattened; layers, masks and blend modes are not kept"));
|
||||
// The compositor works in RGB; write RGB/gray.
|
||||
let cmyk = fmt.mode == ColorMode::Cmyk && cmyk_target;
|
||||
// Only a CMYK target needs the document's profile; RGB-only targets keep the compositor's
|
||||
// own handling (built-in coated CMYK when the embedded profile is unusable).
|
||||
let space = if cmyk {
|
||||
use photocraft_cms::{Builtin, ColorSpace, Intent, Profile, Transform};
|
||||
let profile = match doc.icc_profile.as_ref() {
|
||||
Some(bytes) => 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<String>) -> Result<I
|
||||
let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> 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<String>) -> Result<I
|
||||
if opaque {
|
||||
drop_alpha(&mut data, colors * cs.bytes(), cs.bytes());
|
||||
}
|
||||
let layout = layout_for(if gray { ColorMode::Grayscale } else { ColorMode::Rgb }, !opaque);
|
||||
let layout = layout_for(
|
||||
if cmyk {
|
||||
ColorMode::Cmyk
|
||||
} else if gray {
|
||||
ColorMode::Grayscale
|
||||
} else {
|
||||
ColorMode::Rgb
|
||||
},
|
||||
!opaque,
|
||||
);
|
||||
Image::from_raw(w, h, layout, cs, data)?
|
||||
};
|
||||
let meta = codecs::Metadata {
|
||||
@@ -461,7 +509,8 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti
|
||||
return Ok(r);
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
let mut img = document_to_image(doc, &mut warnings)?;
|
||||
let cmyk_target = format.caps().layouts.iter().any(|layout| layout.is_cmyk());
|
||||
let mut img = document_to_image_for(doc, &mut warnings, cmyk_target)?;
|
||||
ctl.check().map_err(|_| IoError::Cancelled)?;
|
||||
ctl.progress(composite_share(format));
|
||||
if opts.xmp == XmpEmbed::None {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
use photocraft_cms::{Builtin, Intent, Profile, Transform};
|
||||
use photocraft_codecs::{ChannelLayout, SampleType as CodecSample, decode};
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerContent};
|
||||
use photocraft_geom::Size;
|
||||
use photocraft_io::{ExportOptions, document_to_image, export};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
fn document(depth: SampleType, alpha: f32) -> 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<u16> {
|
||||
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::<Vec<_>>()).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));
|
||||
}
|
||||
Reference in new issue
Block a user