//! Image › Mode › Multichannel, and conversions out of it. //! //! A Multichannel document has no layers: its image is a set of ink channels, kept as spot //! channels in [`Document::channels`] (value = ink density, 1 = solid; the compositor prints them //! over white, see `photocraft_compose::multichannel`). Like Photoshop the conversion flattens and //! turns each colour channel into an ink: //! //! * RGB → Cyan, Magenta, Yellow (the red, green and blue data read as ink: density = 1 − value); //! * CMYK → Cyan, Magenta, Yellow, Black (the plates as they are); //! * Lab → Alpha 1, Alpha 2, Alpha 3 (lightness, a, b data, printed in black); //! * Grayscale → Black; Duotone → one channel per ink (its curve applied). //! //! Existing alpha channels stay alpha channels. Converting back reinterprets the channel data when //! the count matches the target (3 → RGB / Lab, 4 → CMYK, 1 → Grayscale), the inverse of the //! above; otherwise the printed look is converted. use photocraft_color::{Color, ColorMode, PixelFormat}; use photocraft_doc::{AlphaChannel, Document, Layer, LayerContent}; use photocraft_raster::Surface; use serde_json::{Value, json}; use crate::channel_cmds::ChannelTarget; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; /// Display colours of the process inks (sRGB). const CYAN: Color = Color::rgb(0.0, 0.682, 0.937); const MAGENTA: Color = Color::rgb(0.925, 0.0, 0.549); const YELLOW: Color = Color::rgb(1.0, 0.949, 0.0); const BLACK: Color = Color::rgb(0.137, 0.122, 0.125); /// Modes Photoshop converts to Multichannel. const SOURCES: [ColorMode; 5] = [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Grayscale, ColorMode::Duotone]; fn enabled(s: &Session) -> std::result::Result<(), String> { let d = s.active().ok_or("no document open")?; if SOURCES.contains(&d.doc.mode) { Ok(()) } else { Err(format!("Multichannel needs an RGB, CMYK, Lab, Grayscale or Duotone image (the document is {:?})", d.doc.mode)) } } /// Native colour planes of the flattened image (opaque over white): one `Vec` per colour /// channel of the document's pixel format. A lone plain layer is read exactly; anything else /// goes through the compositor. fn flattened_planes(doc: &Document) -> Vec> { let fmt = doc.pixel_format(); let cc = fmt.mode.color_channels(); let canvas = doc.bounds(); let n = canvas.width() as usize * canvas.height() as usize; let white = photocraft_raster::from_rgba(&fmt, [1.0, 1.0, 1.0, 1.0]); let mut planes = vec![Vec::with_capacity(n); cc]; let plain = match doc.layers.as_slice() { [l] if l.visible && l.opacity >= 1.0 && l.fill_opacity >= 1.0 && l.mask.is_none() && l.vector_mask.is_none() && !photocraft_compose::effects::has_effects(l) => { match &l.content { LayerContent::Raster(s) if s.format().mode == fmt.mode => Some(s), _ => None, } } _ => None, }; if let Some(s) = plain { let ch = s.channels(); let alpha = s.format().alpha; for px in s.read_region(canvas).chunks_exact(ch) { let a = if alpha { px[ch - 1] } else { 1.0 }; for (c, plane) in planes.iter_mut().enumerate() { plane.push(px[c] * a + white[c] * (1.0 - a)); } } } else { let buf = photocraft_compose::flatten(doc).over_background([1.0, 1.0, 1.0]); for p in &buf.px { let v = photocraft_raster::from_rgba(&fmt, *p); for (c, plane) in planes.iter_mut().enumerate() { plane.push(v[c]); } } } planes } fn channel_surface(doc: &Document, vals: &[f32]) -> Surface { let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, doc.depth, false)); let clamped: Vec = vals.iter().map(|v| v.clamp(0.0, 1.0)).collect(); s.write_region(doc.bounds(), &clamped); s.prune(); s } /// The ink channels (name, display colour, densities) of the flattened document. fn inks_of(doc: &Document) -> Vec<(String, Color, Vec)> { let planes = flattened_planes(doc); let inv = |p: &Vec| p.iter().map(|v| 1.0 - v).collect::>(); match doc.mode { ColorMode::Rgb => { [("Cyan", CYAN), ("Magenta", MAGENTA), ("Yellow", YELLOW)].into_iter().zip(&planes).map(|((n, c), p)| (n.to_string(), c, inv(p))).collect() } ColorMode::Cmyk => [("Cyan", CYAN), ("Magenta", MAGENTA), ("Yellow", YELLOW), ("Black", BLACK)] .into_iter() .zip(planes) .map(|((n, c), p)| (n.to_string(), c, p)) .collect(), ColorMode::Lab => planes.iter().enumerate().map(|(k, p)| (format!("Alpha {}", k + 1), Color::BLACK, inv(p))).collect(), ColorMode::Duotone if doc.duotone.as_ref().is_some_and(|d| !d.inks.is_empty()) => doc .duotone .iter() .flat_map(|d| d.inks.iter()) .map(|ink| (ink.name.clone(), Color::rgb(ink.color[0], ink.color[1], ink.color[2]), planes[0].iter().map(|g| ink.density(1.0 - g)).collect())) .collect(), _ => vec![("Black".to_string(), Color::BLACK, inv(&planes[0]))], } } fn to_multichannel(s: &mut Session, _p: &Value) -> Result { let Some((old, view)) = s.active().map(|st| (st.doc.channels.len(), (st.channel_view.target, st.channel_view.alpha_visible.clone()))) else { return Err(EngineError::NoDocument); }; let names = s.edit("Multichannel", |doc, active| { let inks = inks_of(doc); let mut channels: Vec = inks .iter() .map(|(name, ink, vals)| AlphaChannel { spot: Some((*ink, 0.0)), ..AlphaChannel::new(name.clone(), channel_surface(doc, vals)) }) .collect(); // Existing alpha / spot channels follow the inks, converted to the document depth. let fmt = PixelFormat::new(ColorMode::Grayscale, doc.depth, false); for mut ch in std::mem::take(&mut doc.channels) { if ch.surface.format() != fmt { ch.surface = ch.surface.convert(fmt); } channels.push(ch); } doc.channels = channels; doc.layers.clear(); *active = None; doc.mode = ColorMode::Multichannel; doc.quick_mask = None; doc.color_table = None; doc.duotone = None; doc.icc_profile = None; Ok(inks.into_iter().map(|(n, _, _)| n).collect::>()) })?; // The alpha channels moved behind the inks. let map: Vec> = (0..old).map(|i| Some(i + names.len())).collect(); remap_view(s, view, &map); Ok(json!({ "mode": "multichannel", "channels": names })) } /// Keep the Channels target and eyes on the same channels after a conversion moved them: /// `view` is the target and alpha eyes before the conversion, `map[i]` the new index of old /// channel `i`, `None` when it left (a targeted ink becomes the composite). fn remap_view(s: &mut Session, (target, eyes): (ChannelTarget, Vec), map: &[Option]) { let Some(st) = s.active_mut() else { return }; let mut visible = vec![false; st.doc.channels.len()]; for (&on, to) in eyes.iter().zip(map) { if let (true, Some(slot)) = (on, to.and_then(|j| visible.get_mut(j))) { *slot = true; } } let v = &mut st.channel_view; v.alpha_visible = visible; if let ChannelTarget::Alpha(t) = target { v.target = map.get(t).copied().flatten().map_or(ChannelTarget::Composite, ChannelTarget::Alpha); } crate::channel_cmds::fix_view(st); } /// Image › Mode › RGB / CMYK / Lab / Grayscale from a Multichannel document (called by /// `color_cmds::convert_mode`). Profiles are not involved: Multichannel has none. pub(crate) fn convert_from(s: &mut Session, mode: ColorMode, _p: &Value) -> Result { if !matches!(mode, ColorMode::Rgb | ColorMode::Cmyk | ColorMode::Lab | ColorMode::Grayscale) { return Err(EngineError::Other(format!("a Multichannel document can't be converted to {mode:?}"))); } // The inks leave; the other channels close up. let Some((spots, view)) = s .active() .map(|st| (st.doc.channels.iter().map(|c| c.spot.is_some()).collect::>(), (st.channel_view.target, st.channel_view.alpha_visible.clone()))) else { return Err(EngineError::NoDocument); }; let map: Vec> = spots.iter().enumerate().map(|(i, &spot)| (!spot).then(|| spots.iter().take(i).filter(|&&sp| !sp).count())).collect(); let how = s.edit("Mode Change", |doc, active| { let (inks, others): (Vec, Vec) = std::mem::take(&mut doc.channels).into_iter().partition(|c| c.spot.is_some()); let canvas = doc.bounds(); let n = canvas.width() as usize * canvas.height() as usize; let reinterpret = matches!((mode, inks.len()), (ColorMode::Rgb | ColorMode::Lab, 3) | (ColorMode::Cmyk, 4) | (ColorMode::Grayscale, 1)); // The printed look, rendered before the inks leave the document. let shown = (!reinterpret).then(|| { let mut d = doc.clone(); d.channels = inks.clone(); photocraft_compose::flatten(&d) }); doc.mode = mode; let fmt = doc.pixel_format(); let cc = fmt.mode.color_channels(); let mut data = vec![0.0f32; n * (cc + 1)]; if let Some(buf) = &shown { for (px, p) in data.chunks_exact_mut(cc + 1).zip(&buf.px) { px.copy_from_slice(&photocraft_raster::from_rgba(&fmt, [p[0], p[1], p[2], 1.0])[..cc + 1]); } } else { for (k, ch) in inks.iter().enumerate() { let vals = ch.surface.read_region(canvas); let step = ch.surface.channels(); for (i, v) in vals.chunks_exact(step).enumerate() { // CMYK plates are ink amounts already; other modes store light. data[i * (cc + 1) + k] = if mode == ColorMode::Cmyk { v[0] } else { 1.0 - v[0] }; } } for i in 0..n { data[i * (cc + 1) + cc] = 1.0; } } let mut surf = Surface::new(fmt); surf.write_region(canvas, &data); surf.prune(); let mut bg = Layer::new("Background", LayerContent::Raster(surf)); bg.locks.transparency = true; bg.locks.position = true; *active = Some(bg.id); doc.layers = vec![bg]; doc.channels = others; Ok(if reinterpret { "channels" } else { "appearance" }) })?; remap_view(s, view, &map); Ok(json!({ "mode": format!("{mode:?}").to_lowercase(), "from": "multichannel", "converted": how })) } /// Names of the ink (spot) channels that make up a Multichannel image. pub fn ink_names(doc: &Document) -> Vec { doc.channels.iter().filter(|c| c.spot.is_some()).map(|c| c.name.clone()).collect() } pub fn specs() -> Vec { vec![CommandSpec { id: "image.mode.multichannel", label: "Multichannel", menu: &["Image", "Mode"], shortcut: None, params: r##"{} (flattens; RGB → Cyan/Magenta/Yellow, CMYK → Cyan/Magenta/Yellow/Black, Lab → Alpha 1–3, Grayscale → Black, Duotone → its inks)"##, enabled, run: to_multichannel, journal: true, }] } #[cfg(test)] mod tests;