diff --git a/crates/io/src/duotone_map.rs b/crates/io/src/duotone_map.rs index 565af591a4..693264adec 100644 --- a/crates/io/src/duotone_map.rs +++ b/crates/io/src/duotone_map.rs @@ -14,9 +14,13 @@ //! | 414 | 11 × 10 | overprint colours, space 0xFFFF = computed | //! //! A curve's 13 values are the ink's density in tenths of a percent at gray densities of 0, 5, -//! 10, 20, …, 90, 95 and 100 %; −1 leaves a point out (Photoshop's empty dialog fields). +//! 10, 20, …, 90, 95 and 100 %; −1 leaves a point out (Photoshop's empty dialog fields), except +//! at 0 and 100 %: Photoshop refuses to open a file with −1 there. +//! +//! A colour-book ink (PANTONE, …) is stored by its book key, not a colour. Photoshop also writes +//! every ink's Lab equivalent in image resource 1066 (Alternate Duotone Colors): version 1, a +//! count, then one 10-byte colour per ink in the same order. -use photocraft_color::convert::{cmyk_to_rgb_naive, lab_to_srgb}; use photocraft_doc::adjust::CurvePoint; use photocraft_doc::{Duotone, DuotoneInk}; use photocraft_psd::aco::AcoColor; @@ -29,27 +33,27 @@ const NO_COLOR: [u8; 10] = [0xFF, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0]; const NAMES_AT: usize = 44; const CURVES_AT: usize = 300; const DOT_GAIN_AT: usize = 412; +/// Image resource: the inks' Lab equivalents. +pub(crate) const ALTERNATE_DUOTONE_COLORS: u16 = 1066; fn u16_at(b: &[u8], at: usize) -> Option { b.get(at..at + 2).map(|s| u16::from_be_bytes([s[0], s[1]])) } -/// The sRGB display colour of an ink colour; `None` for a colour book or another space we can't -/// show. -fn display(c: AcoColor) -> Option<[f32; 3]> { - let f = |v: u16| f32::from(v) / 65535.0; - Some(match c { - AcoColor::Rgb(v) => v.map(f), - AcoColor::Cmyk(v) => cmyk_to_rgb_naive(v.map(f)), - AcoColor::Lab { l, a, b } => lab_to_srgb([f32::from(l.min(10000)) / 100.0, f32::from(a) / 100.0, f32::from(b) / 100.0]).map(|v| v.clamp(0.0, 1.0)), - AcoColor::Gray(g) => [1.0 - f32::from(g.min(10000)) / 10000.0; 3], - _ => return None, - }) +/// The sRGB display colour of the 10-byte colour structure at `at`; `None` for a colour book or +/// another space we can't show. +fn display(b: &[u8], at: usize) -> Option<[f32; 3]> { + let space = u16_at(b, at)?; + let values = [u16_at(b, at + 2)?, u16_at(b, at + 4)?, u16_at(b, at + 6)?, u16_at(b, at + 8)?]; + let c = crate::channel_map::decode_known_color(space, values)?; + Some(c.to_rgb().map(|v| v.clamp(0.0, 1.0))) } /// The inks of a Duotone block, or `None` when it is not one this reads (the caller keeps it raw). -/// `unknown` is set when an ink's colour can't be shown (a colour book): it is shown as black. -pub(crate) fn parse(b: &[u8], unknown: &mut bool) -> Option> { +/// `alternate` is resource 1066, which gives a colour-book ink its colour. `unknown` is set when +/// an ink's colour can't be shown even so; it is then black here, and the caller shows the +/// grey plate instead. +pub(crate) fn parse(b: &[u8], alternate: Option<&[u8]>, unknown: &mut bool) -> Option> { if b.len() < DOT_GAIN_AT || u16_at(b, 0)? != 1 { return None; } @@ -59,10 +63,8 @@ pub(crate) fn parse(b: &[u8], unknown: &mut bool) -> Option> { } (0..n) .map(|i| { - let at = 4 + 10 * i; - let space = u16_at(b, at)?; - let values = [u16_at(b, at + 2)?, u16_at(b, at + 4)?, u16_at(b, at + 6)?, u16_at(b, at + 8)?]; - let color = display(AcoColor::from_record(space, values)).unwrap_or_else(|| { + let alt = alternate.filter(|a| u16_at(a, 0) == Some(1) && u16_at(a, 2).is_some_and(|k| usize::from(k) > i)); + let color = display(b, 4 + 10 * i).or_else(|| display(alt?, 4 + 10 * i)).unwrap_or_else(|| { *unknown = true; [0.0; 3] }); @@ -140,15 +142,21 @@ fn curve_values(curve: Option<&[CurvePoint]>) -> [i16; 13] { } let pts = pts.unwrap_or_default(); let slot = |x: f32| CURVE_AT.iter().position(|a| (f32::from(*a) / 100.0 - x).abs() < 1e-4); + let ink = DuotoneInk { name: String::new(), color: [0.0; 3], curve: pts.to_vec() }; if pts.iter().all(|p| slot(p.input).is_some()) { for p in pts { if let Some(k) = slot(p.input) { out[k] = per_mille(p.output); } } + // Photoshop needs both ends. + for k in [0, 12] { + if out[k] < 0 { + out[k] = per_mille(ink.density(f32::from(CURVE_AT[k]) / 100.0)); + } + } return out; } - let ink = DuotoneInk { name: String::new(), color: [0.0; 3], curve: pts.to_vec() }; for (k, x) in CURVE_AT.iter().enumerate() { out[k] = per_mille(ink.density(f32::from(*x) / 100.0)); } @@ -197,7 +205,7 @@ mod tests { psd_raw: None, }; let mut unknown = false; - let back = parse(&write(&d), &mut unknown).unwrap(); + let back = parse(&write(&d), None, &mut unknown).unwrap(); assert!(!unknown); assert_eq!(back.len(), 3); for (a, b) in d.inks.iter().zip(&back) { @@ -218,26 +226,68 @@ mod tests { assert_eq!((v[0], v[6], v[12]), (0, 746, 1000), "{v:?}"); } + /// Black (CMYK) and PANTONE 327 CVC as Photoshop writes them (psd-tools' + /// `colormodes/4x4_8bit_duotone.psd`): the book ink is stored by its key, " 327C". + fn black_and_pantone() -> Vec { + let mut b = write(&Duotone { inks: vec![ink("Black", [0.0; 3], &[]), ink("PANTONE 327 CVC", [0.0; 3], &[])], psd_raw: None }); + b[4..14].copy_from_slice(&[0, 2, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0]); + b[14..24].copy_from_slice(&[0, 3, 0x20, 0x20, 0x33, 0x32, 0x37, 0x43, 0, 0]); + b + } + #[test] - fn a_colour_book_ink_reads_as_black_and_says_so() { - let mut b = write(&Duotone { inks: vec![ink("PANTONE 300 C", [0.0, 0.3, 0.7], &[])], psd_raw: None }); - b[4..6].copy_from_slice(&3u16.to_be_bytes()); + fn a_colour_book_ink_without_its_lab_equivalent_is_unknown() { let mut unknown = false; - let inks = parse(&b, &mut unknown).unwrap(); + let inks = parse(&black_and_pantone(), None, &mut unknown).unwrap(); assert!(unknown); - assert_eq!((inks[0].name.as_str(), inks[0].color), ("PANTONE 300 C", [0.0; 3])); + assert_eq!((inks[1].name.as_str(), inks[1].color), ("PANTONE 327 CVC", [0.0; 3])); + assert!(inks[0].color.iter().all(|&c| c < 0.2), "CMYK black: {:?}", inks[0].color); + } + + #[test] + fn a_colour_book_ink_takes_its_colour_from_resource_1066() { + // Photoshop's resource 1066 of the same file: Lab (9.53, 4.98, 4.25) and (45.49, -66, -5). + let alternate = [0, 1, 0, 2, 0, 7, 0x03, 0xB9, 0x01, 0xF2, 0x01, 0xA9, 0, 0, 0, 7, 0x11, 0xC5, 0xE6, 0x38, 0xFE, 0x0C, 0, 0, 1, 0]; + let mut unknown = false; + let inks = parse(&black_and_pantone(), Some(&alternate), &mut unknown).unwrap(); + assert!(!unknown); + let [r, g, b] = inks[1].color; + assert!(r < 0.1 && g > 0.4 && b > 0.35 && g > b, "PANTONE 327 is teal: {:?}", inks[1].color); + // A short or foreign resource gives nothing. + let mut unknown = false; + parse(&black_and_pantone(), Some(&alternate[..14]), &mut unknown).unwrap(); + assert!(unknown); + } + + #[test] + fn an_hsb_ink_is_shown() { + let mut b = write(&Duotone { inks: vec![ink("Green", [0.0; 3], &[])], psd_raw: None }); + // Hue 120°, full saturation and brightness. + b[4..14].copy_from_slice(&[0, 1, 0x55, 0x55, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0]); + let mut unknown = false; + let inks = parse(&b, None, &mut unknown).unwrap(); + assert!(!unknown); + assert!(inks[0].color[1] > 0.99 && inks[0].color[0] < 0.01 && inks[0].color[2] < 0.01, "{:?}", inks[0].color); + } + + #[test] + fn a_curve_always_has_both_ends() { + // On the grid, but without a 0 % or a 100 % point: Photoshop won't open −1 there. + let curve = [CurvePoint { input: 0.2, output: 0.1 }, CurvePoint { input: 0.5, output: 0.7 }]; + let v = curve_values(Some(&curve)); + assert_eq!(v, [100, -1, -1, 100, -1, -1, 700, -1, -1, -1, -1, -1, 700]); } #[test] fn malformed_blocks_are_not_read() { let mut unknown = false; let good = write(&Duotone { inks: vec![ink("Black", [0.0; 3], &[])], psd_raw: None }); - assert!(parse(&good[..100], &mut unknown).is_none()); + assert!(parse(&good[..100], None, &mut unknown).is_none()); let mut v = good.clone(); v[1] = 2; - assert!(parse(&v, &mut unknown).is_none(), "version"); + assert!(parse(&v, None, &mut unknown).is_none(), "version"); let mut n = good; n[3] = 5; - assert!(parse(&n, &mut unknown).is_none(), "ink count"); + assert!(parse(&n, None, &mut unknown).is_none(), "ink count"); } } diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index b8d2507737..453afc540f 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1537,11 +1537,11 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c let fmt = doc.pixel_format(); // A Duotone is written over its grey plate with its inks: the block it was imported with, // verbatim, while the inks are unedited, else one made from them (`duotone_map`). Photoshop - // has no 32-bit Duotone. + // has only 8-bit Duotone: it refuses to open a 16-bit one. let duotone_inks: Option> = doc .duotone .as_ref() - .filter(|d| !d.inks.is_empty() && doc.mode == ColorMode::Duotone && fmt.mode == ColorMode::Grayscale && fmt.sample != SampleType::F32) + .filter(|d| !d.inks.is_empty() && doc.mode == ColorMode::Duotone && fmt.mode == ColorMode::Grayscale && fmt.sample == SampleType::U8) .map(|d| d.psd_raw.clone().unwrap_or_else(|| crate::duotone_map::write(d))); let sample = fmt.sample; let cc = fmt.mode.color_channels(); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 71d06cc4e7..7ef05feb99 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -1008,13 +1008,13 @@ pub(crate) fn psd_to_document_decoded( doc.color_table = Some(photocraft_doc::ColorTable { colors, transparent }); } else if h.color_mode == PsdMode::Duotone && !file.color_mode_data.is_empty() { // The inks (`duotone_map`); the block is also kept raw, written back while they stay - // unedited. + // unedited. An ink whose colour is unknown (a colour book without its Lab equivalent) + // would show black and darken the image: the grey plate is shown instead, as before. let mut unknown = false; - let inks = crate::duotone_map::parse(&file.color_mode_data, &mut unknown); + let alternate = file.resource(crate::duotone_map::ALTERNATE_DUOTONE_COLORS).map(|r| r.data.as_slice()); + let inks = crate::duotone_map::parse(&file.color_mode_data, alternate, &mut unknown).filter(|_| !unknown); if inks.is_none() { cx.warn("duotone inks are not interpreted (shown as grayscale)"); - } else if unknown { - cx.warn("a duotone ink from a colour book is shown as black"); } let inks = inks.unwrap_or_else(|| vec![photocraft_doc::DuotoneInk::new("Black", [0.0; 3])]); doc.duotone = Some(photocraft_doc::Duotone { inks, psd_raw: Some(file.color_mode_data.clone()) }); diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 91011c6eb1..6caf335768 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -223,6 +223,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let plain; let doc = if matches!(doc.mode, ColorMode::Indexed | ColorMode::Bitmap | ColorMode::Duotone) { plain = Document { mode: doc.pixel_format().mode, ..doc.clone() }; + warnings.push(format!("{:?} document written as {:?}", doc.mode, plain.mode)); &plain } else { doc diff --git a/crates/io/tests/psd_structure.rs b/crates/io/tests/psd_structure.rs index 357b0fc01b..da665d245e 100644 --- a/crates/io/tests/psd_structure.rs +++ b/crates/io/tests/psd_structure.rs @@ -222,6 +222,62 @@ fn a_duotone_made_in_photocraft_is_written_with_its_inks() { assert_eq!(again.color_mode_data, f.color_mode_data); } +fn duotone_doc(sample: photocraft_color::SampleType) -> photocraft_doc::Document { + use photocraft_doc::{Duotone, DuotoneInk}; + let mut d = photocraft_doc::Document::with_background( + "d", + photocraft_doc::Size::new(4, 2), + photocraft_color::ColorMode::Grayscale, + sample, + photocraft_color::Color::rgba(0.5, 0.5, 0.5, 1.0), + ); + d.mode = photocraft_color::ColorMode::Duotone; + d.duotone = Some(Duotone { inks: vec![DuotoneInk::new("Black", [0.0; 3]), DuotoneInk::new("Orange", [1.0, 0.5, 0.0])], psd_raw: None }); + d +} + +#[test] +fn a_16_bit_duotone_is_written_as_grayscale() { + // Photoshop has only 8-bit Duotone and refuses to open a 16-bit Duotone PSD. + let out = export(&duotone_doc(photocraft_color::SampleType::U16), "x.psd", &ExportOptions::default()).unwrap(); + let f = PsdFile::from_bytes(&out.bytes).unwrap(); + assert_eq!((f.header.color_mode, f.header.depth), (ColorMode::Grayscale, 16)); + assert!(out.warnings.iter().any(|w| w == "Duotone document written as Grayscale"), "{:?}", out.warnings); +} + +#[test] +fn a_colour_book_ink_shows_its_lab_equivalent_or_the_grey_plate() { + let out = export(&duotone_doc(photocraft_color::SampleType::U8), "x.psd", &ExportOptions::default()).unwrap(); + let mut f = PsdFile::from_bytes(&out.bytes).unwrap(); + // The second ink becomes PANTONE 327 CVC, stored by its book key as Photoshop does. + f.color_mode_data[14..24].copy_from_slice(&[0, 3, 0x20, 0x20, 0x33, 0x32, 0x37, 0x43, 0, 0]); + // Without resource 1066 its colour is unknown: the grey plate, not a black ink. + let (d, w) = psd_to_document(&f); + let inks = &d.duotone.as_ref().unwrap().inks; + assert_eq!((inks.len(), inks[0].color), (1, [0.0; 3]), "{inks:?}"); + assert!(w.iter().any(|w| w.contains("shown as grayscale")), "{w:?}"); + // With Photoshop's Lab equivalents (black, then the PANTONE teal) it shows in colour. + let lab = vec![0, 1, 0, 2, 0, 7, 0x03, 0xB9, 0x01, 0xF2, 0x01, 0xA9, 0, 0, 0, 7, 0x11, 0xC5, 0xE6, 0x38, 0xFE, 0x0C, 0, 0]; + f.resources.push(photocraft_psd::ImageResource::new(1066, lab)); + let (d, w) = psd_to_document(&f); + assert!(w.iter().all(|w| !w.contains("duotone")), "{w:?}"); + let duo = d.duotone.as_ref().unwrap(); + assert_eq!(duo.inks.len(), 2); + let [r, g, b] = duo.inks[1].color; + assert!(r < 0.1 && g > 0.4 && b > 0.35, "{:?}", duo.inks[1].color); + assert_eq!(duo.psd_raw.as_ref(), Some(&f.color_mode_data), "the block is kept raw"); +} + +#[test] +fn a_layered_duotone_tiff_says_its_inks_are_dropped() { + // TIFF has no Duotone: a layered one is written in its grey plate, and says so. + let mut d = duotone_doc(photocraft_color::SampleType::U8); + let adj = d.duotone.as_ref().unwrap().display_adjustment(); + d.layers.push(photocraft_doc::Layer::new("Map", LayerContent::Adjustment(adj))); + let out = export(&d, "x.tif", &ExportOptions { tiff_layers: true, ..Default::default() }).unwrap(); + assert!(out.warnings.iter().any(|w| w == "Duotone document written as Grayscale"), "{:?}", out.warnings); +} + #[test] fn indexed_psd_keeps_the_table_length_and_transparent_entry() { use photocraft_doc::ColorTable;