Files
photocraft/crates/engine/src/multichannel_cmds.rs
T

256 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Vec<f32>> {
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<f32> = 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<f32>)> {
let planes = flattened_planes(doc);
let inv = |p: &Vec<f32>| p.iter().map(|v| 1.0 - v).collect::<Vec<f32>>();
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<Value> {
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<AlphaChannel> = 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::<Vec<_>>())
})?;
// The alpha channels moved behind the inks.
let map: Vec<Option<usize>> = (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<bool>), map: &[Option<usize>]) {
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<Value> {
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::<Vec<_>>(), (st.channel_view.target, st.channel_view.alpha_visible.clone())))
else {
return Err(EngineError::NoDocument);
};
let map: Vec<Option<usize>> = 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<AlphaChannel>, Vec<AlphaChannel>) = 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<String> {
doc.channels.iter().filter(|c| c.spot.is_some()).map(|c| c.name.clone()).collect()
}
pub fn specs() -> Vec<CommandSpec> {
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;