mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-25 10:27:07 -06:00
* fix(layers): remap link groups when copying across documents (#3045) * cargo fmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
10 KiB
Rust
228 lines
10 KiB
Rust
//! Copy layers into another open document (#589): what Photoshop does when layers are dragged from
|
|
//! the Layers panel, or with the Move tool from the canvas, onto another document's tab or window.
|
|
//! Photoshop has no menu item for it; `layer.copyToDocument` is the command the drag dispatches and
|
|
//! agents call.
|
|
//!
|
|
//! The copies keep their names, content, masks, styles and blending; they land above the
|
|
//! destination's active layer as one history step there, and the destination becomes the active
|
|
//! document. Pixels are converted to the destination's colour profile (Color Settings' intent and
|
|
//! black point compensation) and bit depth, as Photoshop converts dragged layers.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
use photocraft_color::ColorMode;
|
|
use photocraft_doc::{Document, LayerContent, LayerId, SmartContentsId};
|
|
use photocraft_geom::Rect;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::commands::CommandSpec;
|
|
use crate::{EngineError, Result, Session};
|
|
|
|
const CMD: &str = "layer.copyToDocument";
|
|
|
|
fn bad(msg: impl Into<String>) -> EngineError {
|
|
EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }
|
|
}
|
|
|
|
fn enabled(s: &Session) -> std::result::Result<(), String> {
|
|
let d = s.active().ok_or("no document open")?;
|
|
d.active_layer.ok_or("no active layer")?;
|
|
if s.documents().len() < 2 { Err("no other document is open".into()) } else { Ok(()) }
|
|
}
|
|
|
|
/// A document index param (`None` when absent), checked against the open documents.
|
|
fn doc_index(s: &Session, p: &Value, key: &str) -> Result<Option<usize>> {
|
|
let Some(v) = p.get(key) else { return Ok(None) };
|
|
let i = v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(format!("`{key}` must be a document index")))?;
|
|
if i >= s.documents().len() {
|
|
return Err(bad(format!("no document {i} (there are {})", s.documents().len())));
|
|
}
|
|
Ok(Some(i))
|
|
}
|
|
|
|
/// An `[x, y]` param of finite numbers within ±10⁷ pixels.
|
|
fn point(p: &Value, key: &str) -> Result<Option<[f64; 2]>> {
|
|
let Some(v) = p.get(key) else { return Ok(None) };
|
|
let xy = v.as_array().filter(|a| a.len() == 2).and_then(|a| Some([a[0].as_f64()?, a[1].as_f64()?]));
|
|
match xy {
|
|
Some(xy) if xy.iter().all(|c| c.is_finite() && c.abs() <= 1e7) => Ok(Some(xy)),
|
|
_ => Err(bad(format!("`{key}` must be [x, y] (pixels)"))),
|
|
}
|
|
}
|
|
|
|
/// Modes whose layers convert through the colour engine.
|
|
pub(crate) fn layered(mode: ColorMode) -> bool {
|
|
matches!(mode, ColorMode::Rgb | ColorMode::Grayscale | ColorMode::Cmyk | ColorMode::Lab)
|
|
}
|
|
|
|
fn center(r: Rect) -> [f64; 2] {
|
|
[(f64::from(r.x0) + f64::from(r.x1)) / 2.0, (f64::from(r.y0) + f64::from(r.y1)) / 2.0]
|
|
}
|
|
|
|
/// Readies `copies` (a scratch document of the source's colour holding duplicated layers) for
|
|
/// landing in `dest`: smart objects get fresh contents ids, keeping only the sharing within the
|
|
/// batch (contents ids belong to a document, so the copies never join objects already in `dest`,
|
|
/// including another opening of the same file), and the pixels convert to `dest`'s profile and
|
|
/// bit depth.
|
|
pub(crate) fn adapt_copies(s: &Session, copies: &mut Document, dest: &Document) -> Result<()> {
|
|
// Link-group IDs are scoped to a document. Two independently linked pairs can both
|
|
// use group 1; copying one must not link it to the other's existing layers (#3045).
|
|
// Remap each imported group once so links within the copied batch stay intact.
|
|
let mut used: HashSet<u64> = dest.walk().into_iter().filter_map(|(_, _, l)| l.link_group).collect();
|
|
let mut next_group = dest.unused_link_group().ok_or_else(|| EngineError::Other("no free linked-layer group id".into()))?;
|
|
let groups: Vec<_> = copies.walk().into_iter().filter_map(|(_, _, l)| l.link_group.map(|group| (l.id, group))).collect();
|
|
let mut remapped = HashMap::new();
|
|
for (_, group) in &groups {
|
|
if remapped.contains_key(group) {
|
|
continue;
|
|
}
|
|
while used.contains(&next_group) {
|
|
next_group = next_group.checked_add(1).unwrap_or(1);
|
|
}
|
|
remapped.insert(*group, next_group);
|
|
used.insert(next_group);
|
|
next_group = next_group.checked_add(1).unwrap_or(1);
|
|
}
|
|
for (id, group) in groups {
|
|
if let Some(layer) = copies.layer_mut(id) {
|
|
layer.link_group = remapped.get(&group).copied();
|
|
}
|
|
}
|
|
let mut contents = HashMap::new();
|
|
let smart_ids: Vec<_> = copies.walk().into_iter().filter_map(|(_, _, l)| matches!(l.content, LayerContent::Smart(_)).then_some(l.id)).collect();
|
|
for id in smart_ids {
|
|
if let Some(l) = copies.layer_mut(id)
|
|
&& let LayerContent::Smart(sm) = &mut l.content
|
|
{
|
|
sm.contents_id = *contents.entry(sm.contents_id).or_insert_with(SmartContentsId::fresh);
|
|
}
|
|
}
|
|
if (copies.mode, &copies.icc_profile) != (dest.mode, &dest.icc_profile) {
|
|
let profile = crate::color_cmds::document_profile(dest);
|
|
crate::color_cmds::convert_document(copies, &profile, s.color.settings.intent(), s.color.settings.bpc)?;
|
|
}
|
|
if copies.depth != dest.depth {
|
|
crate::image_cmds::for_each_surface(&mut copies.layers, true, &mut |surf, _| {
|
|
let f = surf.format().with_sample(dest.depth);
|
|
*surf = surf.convert(f);
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Moves every layer of `copies` by whole pixels (content, masks, vectors and smart placement).
|
|
pub(crate) fn translate_copies(copies: &mut Document, dx: i32, dy: i32) {
|
|
if dx != 0 || dy != 0 {
|
|
let snapshot = copies.clone();
|
|
for l in &mut copies.layers {
|
|
crate::commands::translate_layer(&snapshot, l, dx, dy);
|
|
crate::vector_cmds::translate_vectors(&snapshot, l, f64::from(dx), f64::from(dy));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `layer.copyToDocument`.
|
|
fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let dest = doc_index(s, p, "document")?.ok_or_else(|| bad("missing `document` (the destination's index)"))?;
|
|
let src = match doc_index(s, p, "source")? {
|
|
Some(i) => i,
|
|
None => s.active_index().ok_or(EngineError::NoDocument)?,
|
|
};
|
|
if src == dest {
|
|
return Err(bad("the destination is the source document (use layer.duplicate)"));
|
|
}
|
|
let (from, to) = (&s.documents()[src], &s.documents()[dest]);
|
|
let (sdoc, ddoc) = (from.doc.clone(), to.doc.clone());
|
|
let ids: Vec<LayerId> = match p.get("layers") {
|
|
None => from.selected_layers(),
|
|
Some(v) => v
|
|
.as_array()
|
|
.map(|a| a.iter().map(|id| id.as_u64().map(LayerId)).collect::<Option<Vec<_>>>())
|
|
.and_then(|ids| ids)
|
|
.ok_or_else(|| bad("`layers` must be an array of layer ids"))?,
|
|
};
|
|
if let Some(missing) = ids.iter().find(|id| sdoc.layer(**id).is_none()) {
|
|
return Err(EngineError::NoLayer(*missing));
|
|
}
|
|
let ids = crate::layer_multi_cmds::top_level(&sdoc, &ids);
|
|
if ids.is_empty() {
|
|
return Err(bad("no layers to copy"));
|
|
}
|
|
for (doc, role) in [(&sdoc, "source"), (&ddoc, "destination")] {
|
|
if !layered(doc.mode) {
|
|
return Err(EngineError::Other(format!("layers can't be copied with a {:?} {role} document", doc.mode)));
|
|
}
|
|
}
|
|
// The copies, bottom to top, in a scratch document of the source's colour so they convert
|
|
// the way a whole document does.
|
|
let mut copies = Document::new("", sdoc.size, sdoc.mode, sdoc.depth);
|
|
copies.icc_profile = sdoc.icc_profile.clone();
|
|
// Type re-lays out at the source's resolution, so it keeps its size in pixels.
|
|
copies.resolution_dpi = sdoc.resolution_dpi;
|
|
for id in &ids {
|
|
let src_layer = sdoc.layer(*id).ok_or(EngineError::NoLayer(*id))?;
|
|
let mut l = src_layer.duplicate();
|
|
// A dragged Background arrives as an ordinary layer (the destination keeps its own).
|
|
if crate::extra_cmds::is_background(src_layer) && sdoc.layers.first().is_some_and(|b| b.id == *id) {
|
|
l.name = ddoc.next_layer_name("Layer");
|
|
l.locks = Default::default();
|
|
}
|
|
copies.layers.push(l);
|
|
}
|
|
adapt_copies(s, &mut copies, &ddoc)?;
|
|
// Placement: centred on the destination's canvas, centred on a point, or offset from where
|
|
// the layers are in the source (default: the same canvas position).
|
|
let bounds = copies.layers.iter().filter_map(crate::layer_multi_cmds::layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b));
|
|
let content = if bounds.is_empty() { sdoc.bounds() } else { bounds };
|
|
let (dx, dy) = if p.get("center").and_then(Value::as_bool).unwrap_or(false) {
|
|
let (c, d) = (center(content), center(ddoc.bounds()));
|
|
(d[0] - c[0], d[1] - c[1])
|
|
} else if let Some(at) = point(p, "at")? {
|
|
let c = center(content);
|
|
(at[0] - c[0], at[1] - c[1])
|
|
} else {
|
|
point(p, "offset")?.map_or((0.0, 0.0), |o| (o[0], o[1]))
|
|
};
|
|
let (dx, dy) = (dx.round() as i32, dy.round() as i32);
|
|
translate_copies(&mut copies, dx, dy);
|
|
let patterns: Vec<_> = sdoc.patterns.iter().filter(|pat| !ddoc.patterns.iter().any(|d| d.id == pat.id)).cloned().collect();
|
|
s.set_active(dest);
|
|
let label = if ids.len() == 1 { "Duplicate Layer" } else { "Duplicate Layers" };
|
|
let edited = s.edit(label, |doc, active| {
|
|
doc.patterns.extend(patterns);
|
|
let mut above = *active;
|
|
let mut new = Vec::with_capacity(copies.layers.len());
|
|
for l in copies.layers {
|
|
let id = doc.insert_above(above, l);
|
|
above = Some(id);
|
|
new.push(id);
|
|
}
|
|
*active = above;
|
|
Ok(new)
|
|
});
|
|
// Nothing changed: the source stays the active document.
|
|
if edited.is_err() {
|
|
s.set_active(src);
|
|
}
|
|
let new = edited?;
|
|
let last = new.last().copied();
|
|
crate::layer_multi_cmds::reselect(s, new.clone(), last);
|
|
Ok(json!({"document": dest, "layers": new.iter().map(|l| l.0).collect::<Vec<_>>(), "offset": [dx, dy]}))
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![CommandSpec {
|
|
id: CMD,
|
|
label: "Copy Layers to Document",
|
|
menu: &[],
|
|
shortcut: None,
|
|
params: r##"{"document":index (destination),"layers":[id,…]?=the source's selected layers,"source":index?=active,"center":bool=false (centre on the destination's canvas),"at":[x,y]? (centre the copies on this point),"offset":[dx,dy]?=[0,0] (from their place in the source)}"##,
|
|
enabled,
|
|
run: copy_to_document,
|
|
journal: true,
|
|
}]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|