mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
fix(engine): preserve copied smart object source records
Carry only the linked-source records referenced by copied layers through Copy/Paste and save/reopen. Preserve raw records, avoid duplicate payloads on same-document paste, and reject ambiguous or incomplete selected identities before replacing the clipboard. Co-authored-by: skylar taylor-barrick <[email protected]>
This commit is contained in:
1 parent
cda46050f7
commit
9ccd4aa557
3 files changed
+644
-1
No files matched your search
@@ -55,6 +55,46 @@ fn scratch_doc(doc: &Document) -> Document {
|
||||
copies
|
||||
}
|
||||
|
||||
/// Scope linked records to the copied layer tree: a PSD block can also hold files belonging
|
||||
/// to unselected or hidden layers, which must not follow this copy into a native save.
|
||||
fn copy_linked_sources(source: &Document, copies: &mut Document) -> Result<()> {
|
||||
fn collect<'a>(layers: &'a [Layer], depth: usize, paths: &mut std::collections::HashSet<&'a str>) -> Result<()> {
|
||||
if depth > photocraft_doc::MAX_GROUP_DEPTH {
|
||||
return Err(EngineError::Other("can't copy linked smart objects from layers nested too deeply".into()));
|
||||
}
|
||||
for layer in layers {
|
||||
if let LayerContent::Smart(sm) = &layer.content
|
||||
&& let photocraft_doc::SmartSource::Linked { path } = &sm.source
|
||||
{
|
||||
paths.try_reserve(1).map_err(|_| EngineError::Other("not enough memory to copy smart-object source IDs".into()))?;
|
||||
paths.insert(path);
|
||||
}
|
||||
if let Some(children) = layer.children().filter(|children| !children.is_empty()) {
|
||||
collect(children, depth + 1, paths)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut paths = std::collections::HashSet::new();
|
||||
collect(&copies.layers, 0, &mut paths)?;
|
||||
if paths.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for (signature, key, data) in &source.metadata.psd_global_blocks {
|
||||
if !matches!(key, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE") {
|
||||
continue;
|
||||
}
|
||||
let selected = photocraft_io::linked::select_items(data, &|uuid, _| paths.contains(uuid))
|
||||
.map_err(|e| EngineError::Other(format!("Could not copy smart-object contents: {e}; the clipboard was not changed")))?;
|
||||
let selected = selected.map(std::sync::Arc::new).unwrap_or_else(|| data.clone());
|
||||
if !selected.is_empty() {
|
||||
copies.metadata.psd_global_blocks.try_reserve(1).map_err(|_| EngineError::Other("not enough memory to copy smart-object source records".into()))?;
|
||||
copies.metadata.psd_global_blocks.push((*signature, *key, selected));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The layer Copy takes whole ([`takes_whole`]): a scratch document in the source's colour
|
||||
/// holding it.
|
||||
fn whole_layer(d: &crate::DocState, id: LayerId) -> Option<Box<Document>> {
|
||||
@@ -206,7 +246,10 @@ fn copy(s: &mut Session, merged: bool) -> Result<Value> {
|
||||
} else {
|
||||
let id = d.active_layer.ok_or(EngineError::Other("no active layer".into()))?;
|
||||
let several = multi_copy_ids(d);
|
||||
let layers = if several.is_empty() { whole_layer(d, id) } else { whole_layers(d, &several) };
|
||||
let mut layers = if several.is_empty() { whole_layer(d, id) } else { whole_layers(d, &several) };
|
||||
if let Some(copies) = &mut layers {
|
||||
copy_linked_sources(&d.doc, copies)?;
|
||||
}
|
||||
match (d.doc.layer(id).and_then(Layer::surface).filter(|_| several.is_empty()), &layers) {
|
||||
(Some(surf), _) => Clip { layers, ..lift(surf, d.doc.selection.as_ref(), canvas)? },
|
||||
// A group has no pixels of its own: the clipboard pixels are what it shows.
|
||||
@@ -345,8 +388,27 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result<
|
||||
crate::layer_copy_cmds::adapt_copies(s, &mut copies, &d.doc)?;
|
||||
crate::layer_copy_cmds::translate_copies(&mut copies, dx, dy);
|
||||
let patterns: Vec<_> = copies.patterns.iter().filter(|pat| !d.doc.patterns.iter().any(|q| q.id == pat.id)).cloned().collect();
|
||||
// A copied smart object may name a PSD-embedded linked file; land its `lnk` block too (#3584).
|
||||
let mut linked = Vec::new();
|
||||
for (signature, key, data) in copies.metadata.psd_global_blocks.iter().filter(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE")) {
|
||||
if d.doc.metadata.psd_global_blocks.iter().any(|(_, dk, dd)| dk == key && std::sync::Arc::ptr_eq(dd, data)) {
|
||||
continue;
|
||||
}
|
||||
// The clipboard may contain a subset of a destination block. Compare complete records,
|
||||
// so copying one layer in the same document does not duplicate large embedded files.
|
||||
let missing = photocraft_io::linked::select_items(data, &|_, raw| {
|
||||
!d.doc.metadata.psd_global_blocks.iter().any(|(_, dk, dd)| dk == key && photocraft_io::linked::contains_item(dd, raw))
|
||||
})
|
||||
.map_err(|e| EngineError::Other(format!("Could not paste smart-object contents: {e}; the document was not changed")))?;
|
||||
let missing = missing.map(std::sync::Arc::new).unwrap_or_else(|| data.clone());
|
||||
if !missing.is_empty() {
|
||||
linked.try_reserve(1).map_err(|_| EngineError::Other("not enough memory to paste smart-object source records".into()))?;
|
||||
linked.push((*signature, *key, missing));
|
||||
}
|
||||
}
|
||||
let id = s.edit("Paste", |doc, active| {
|
||||
doc.patterns.extend(patterns);
|
||||
doc.metadata.psd_global_blocks.extend(linked);
|
||||
for l in copies.layers {
|
||||
*active = Some(doc.insert_above(*active, l));
|
||||
}
|
||||
@@ -1656,6 +1718,34 @@ mod tests {
|
||||
assert_eq!(active_bounds(&s), Rect::new(10, 10, 50, 30), "on the canvas: in place");
|
||||
}
|
||||
|
||||
/// #3584: a PSD-embedded linked smart object (its bytes in the document's `lnk2` block, its
|
||||
/// source a uuid) can still be opened after Copy + Paste into another document.
|
||||
#[test]
|
||||
fn paste_keeps_a_psd_embedded_smart_object_openable() {
|
||||
use std::sync::Arc;
|
||||
let mut s = session();
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
let outer = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("embedded linked source", |doc, _| {
|
||||
let LayerContent::Smart(sm) = &mut doc.layer_mut(outer).ok_or(EngineError::NoDocument)?.content else {
|
||||
return Err(EngineError::Other("not a smart object".into()));
|
||||
};
|
||||
sm.source = photocraft_doc::SmartSource::Linked { path: "uuid-1".into() };
|
||||
let linked = photocraft_io::linked::LinkedFile { uuid: "uuid-1".into(), file_name: "in.png".into(), bytes: vec![1, 2, 3, 4] };
|
||||
doc.metadata.psd_global_blocks.push((*b"8BIM", *b"lnk2", Arc::new(photocraft_io::linked::encode_linked_file(&linked))));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
let LayerContent::Smart(sm) = active_content(&s) else { panic!("the paste is a smart object") };
|
||||
assert!(
|
||||
crate::smart_cmds::source_bytes(&s.active().unwrap().doc.metadata, &sm.source).is_some(),
|
||||
"the pasted PSD-embedded linked source must resolve (#3584)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Type and shape layers copy their rendered pixels too; groups and adjustments have none.
|
||||
#[test]
|
||||
fn copy_reads_any_layer_that_shows_pixels() {
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Clipboard copies carry only the linked smart-object records their layers reference.
|
||||
use photocraft_doc::{Document, LayerContent, LayerId, SmartSource};
|
||||
use photocraft_engine::{Session, smart_cmds::source_bytes};
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn fixture(depth: u64) -> Document {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 6, "depth": depth})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.edit("synthetic color", |doc, active| {
|
||||
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 8, 6), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
let bytes = photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap().bytes;
|
||||
let doc = photocraft_io::import("synthetic.psd", &bytes).unwrap().document;
|
||||
assert!(doc.walk().iter().any(|(_, _, l)| matches!(l.content, LayerContent::Smart(_))));
|
||||
doc
|
||||
}
|
||||
fn smart_id(doc: &Document) -> LayerId {
|
||||
doc.walk().into_iter().find_map(|(_, _, l)| matches!(l.content, LayerContent::Smart(_)).then_some(l.id)).unwrap()
|
||||
}
|
||||
fn bytes(doc: &Document, id: LayerId) -> Arc<Vec<u8>> {
|
||||
let LayerContent::Smart(sm) = &doc.layer(id).unwrap().content else { panic!("expected smart object") };
|
||||
source_bytes(&doc.metadata, &sm.source).unwrap().1
|
||||
}
|
||||
fn hash(bytes: &[u8]) -> (usize, String) {
|
||||
(bytes.len(), blake3::hash(bytes).to_hex().to_string())
|
||||
}
|
||||
fn source_session(doc: Document) -> (Session, LayerId) {
|
||||
let mut s = Session::new();
|
||||
let id = smart_id(&doc);
|
||||
s.add_document(doc, None);
|
||||
s.select_layer(id).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
fn roundtrip(doc: &Document, format: &str) -> Document {
|
||||
if format == "pcraft" {
|
||||
photocraft_format::load_from_bytes(&photocraft_format::save_to_bytes(doc, &Default::default()).unwrap()).unwrap()
|
||||
} else {
|
||||
let out = photocraft_io::export(doc, format, &Default::default()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{format}: {:?}", out.warnings);
|
||||
photocraft_io::import(&format!("clipboard.{format}"), &out.bytes).unwrap().document
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_valid_psd_smart_sources_open_reopen_and_undo() {
|
||||
for depth in [8, 16, 32] {
|
||||
for key in [*b"lnk2", *b"lnk3", *b"lnkD"] {
|
||||
let mut doc = fixture(depth);
|
||||
for (_, k, _) in &mut doc.metadata.psd_global_blocks {
|
||||
if matches!(&*k, b"lnk2" | b"lnk3" | b"lnkD") {
|
||||
*k = key;
|
||||
}
|
||||
}
|
||||
let (mut s, original) = source_session(doc);
|
||||
let expected = bytes(&s.active().unwrap().doc, original);
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("file.new", json!({"width": 8, "height": 6, "depth": depth})).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let pasted = LayerId(s.execute("edit.paste", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
assert_eq!(hash(&bytes(&s.active().unwrap().doc, pasted)), hash(&expected), "depth {depth}, key {key:?}");
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc, after);
|
||||
s.execute("layer.smartObjects.editContents", json!({"layer": pasted.0})).unwrap();
|
||||
assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0]);
|
||||
s.set_active(1);
|
||||
for format in ["pcraft", "psd", "psb"] {
|
||||
let back = roundtrip(&s.active().unwrap().doc, format);
|
||||
let (mut reopened, id) = source_session(back);
|
||||
reopened.execute("layer.smartObjects.editContents", json!({"layer": id.0})).unwrap();
|
||||
assert_eq!(photocraft_compose::flatten(&reopened.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0], "depth {depth}, {format}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_batch_and_same_document_duplicates_keep_sources() {
|
||||
for depth in [8, 16, 32] {
|
||||
let (mut s, original) = source_session(fixture(depth));
|
||||
let duplicate = s.execute("layer.duplicate", json!({"layer": original.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layer": original.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": duplicate, "mode": "add"})).unwrap();
|
||||
let expected = bytes(&s.active().unwrap().doc, original);
|
||||
let count = s.active().unwrap().doc.metadata.psd_global_blocks.len();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let copied = s.clipboard.as_ref().unwrap().layers.as_ref().unwrap();
|
||||
assert!(!copied.metadata.psd_global_blocks.is_empty());
|
||||
assert!(
|
||||
copied.metadata.psd_global_blocks.iter().all(|(_, key, data)| s
|
||||
.active()
|
||||
.unwrap()
|
||||
.doc
|
||||
.metadata
|
||||
.psd_global_blocks
|
||||
.iter()
|
||||
.any(|(_, dk, dd)| { dk == key && Arc::ptr_eq(dd, data) })),
|
||||
"an exclusive linked block should stay shared, including large PSBs"
|
||||
);
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.metadata.psd_global_blocks.len(), count, "same-document paste shouldn't duplicate blocks");
|
||||
for (_, _, l) in s.active().unwrap().doc.walk() {
|
||||
if matches!(l.content, LayerContent::Smart(_)) {
|
||||
assert_eq!(hash(&bytes(&s.active().unwrap().doc, l.id)), hash(&expected));
|
||||
}
|
||||
}
|
||||
s.execute("file.new", json!({"width": 8, "height": 6, "depth": depth})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
let ids: Vec<_> =
|
||||
s.active().unwrap().doc.walk().into_iter().filter_map(|(_, _, l)| matches!(l.content, LayerContent::Smart(_)).then_some(l.id)).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
for id in ids {
|
||||
assert_eq!(hash(&bytes(&s.active().unwrap().doc, id)), hash(&expected));
|
||||
s.execute("layer.smartObjects.editContents", json!({"layer": id.0})).unwrap();
|
||||
assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0]);
|
||||
s.set_active(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_converts_parent_without_losing_contents_and_disk_reopen() {
|
||||
for mode in ["rgb", "grayscale", "cmyk", "lab"] {
|
||||
for depth in [8, 16, 32] {
|
||||
let (mut s, id) = source_session(fixture(16));
|
||||
let expected = bytes(&s.active().unwrap().doc, id);
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("file.new", json!({"width": 8, "height": 6, "depth": depth, "mode": mode})).unwrap();
|
||||
let pasted = LayerId(s.execute("edit.paste", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
assert_eq!(hash(&bytes(&s.active().unwrap().doc, pasted)), hash(&expected), "{mode}, depth {depth}");
|
||||
s.execute("layer.smartObjects.editContents", json!({"layer": pasted.0})).unwrap();
|
||||
assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0]);
|
||||
s.set_active(1);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("pasted.pcraft");
|
||||
s.execute("file.saveACopy", json!({"path": path})).unwrap();
|
||||
let mut reopened = Session::new();
|
||||
reopened.execute("file.openAs", json!({"path": path})).unwrap();
|
||||
let reopened_id = smart_id(&reopened.active().unwrap().doc);
|
||||
assert_eq!(hash(&bytes(&reopened.active().unwrap().doc, reopened_id)), hash(&expected));
|
||||
reopened.execute("layer.smartObjects.editContents", json!({"layer": reopened_id.0})).unwrap();
|
||||
assert_eq!(photocraft_compose::flatten(&reopened.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_group_keeps_nested_linked_sources() {
|
||||
let mut doc = fixture(16);
|
||||
let id = smart_id(&doc);
|
||||
let expected = bytes(&doc, id);
|
||||
let child = doc.remove(id).unwrap();
|
||||
let group = photocraft_doc::Layer::group("synthetic group", vec![child]);
|
||||
let group_id = group.id;
|
||||
doc.layers.push(group);
|
||||
let (mut s, _) = source_session(doc);
|
||||
s.select_layer(group_id).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("file.new", json!({"width": 8, "height": 6, "depth": 16})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
let pasted = smart_id(&s.active().unwrap().doc);
|
||||
assert_eq!(hash(&bytes(&s.active().unwrap().doc, pasted)), hash(&expected));
|
||||
s.execute("layer.smartObjects.editContents", json!({"layer": pasted.0})).unwrap();
|
||||
assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_excludes_unselected_linked_records_from_native_save() {
|
||||
let mut doc = fixture(8);
|
||||
let original = smart_id(&doc);
|
||||
let expected = bytes(&doc, original);
|
||||
let mut unselected = doc.layer(original).unwrap().duplicate();
|
||||
unselected.name = "Unselected private synthetic document".into();
|
||||
unselected.visible = false;
|
||||
let LayerContent::Smart(sm) = &mut unselected.content else { panic!("smart") };
|
||||
sm.source = SmartSource::Linked { path: "unselected-private-source".into() };
|
||||
doc.layers.push(unselected);
|
||||
let mut private_child = photocraft_engine::smart_cmds::decode_source("source.psb", &expected).unwrap();
|
||||
for layer in &mut private_child.layers {
|
||||
if let Some(surface) = layer.surface_mut() {
|
||||
surface.fill_rect(Rect::new(0, 0, 8, 6), &[0.0, 0.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
let private_bytes = photocraft_io::export(&private_child, "psb", &Default::default()).unwrap().bytes;
|
||||
assert_ne!(hash(&expected), hash(&private_bytes));
|
||||
let private_source =
|
||||
photocraft_io::linked::LinkedFile { uuid: "unselected-private-source".into(), file_name: "private-synthetic.psb".into(), bytes: private_bytes.clone() };
|
||||
// Files may put both selected and unselected entries in one global linked-layer block.
|
||||
let (_, _, block) = doc.metadata.psd_global_blocks.iter_mut().find(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD")).unwrap();
|
||||
let selected_record = block.clone();
|
||||
let mut combined = block.to_vec();
|
||||
combined.extend(photocraft_io::linked::encode_linked_file(&private_source));
|
||||
combined.extend([0, 0, 0, 0, 0, 0, 1, 0, 9]); // unrelated unreadable tail
|
||||
*block = Arc::new(combined);
|
||||
let (mut s, _) = source_session(doc);
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("file.new", json!({"width": 8, "height": 6})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
let back = roundtrip(&s.active().unwrap().doc, "pcraft");
|
||||
let retained = photocraft_io::linked::find_linked_file(&back.metadata, "unselected-private-source");
|
||||
if let Some(file) = &retained {
|
||||
assert_eq!(hash(&file.bytes), hash(&private_bytes));
|
||||
}
|
||||
assert!(retained.is_none(), "copying one selected layer must not retain another unselected smart object's embedded file in saved destination .pcraft");
|
||||
let linked: Vec<_> = back.metadata.psd_global_blocks.iter().filter(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE")).collect();
|
||||
assert_eq!(linked.len(), 1);
|
||||
assert_eq!(hash(&linked[0].2), hash(&selected_record), "unidentified tail bytes must not follow the selected record");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_keeps_selected_external_alias_and_unknown_records_without_reading_their_source() {
|
||||
for key in [*b"lnk2", *b"lnk3", *b"lnkD", *b"lnkE"] {
|
||||
for kind in [*b"liFE", *b"liFA", *b"next"] {
|
||||
let mut doc = fixture(8);
|
||||
let id = smart_id(&doc);
|
||||
let LayerContent::Smart(sm) = &doc.layer(id).unwrap().content else { panic!("smart") };
|
||||
let source = sm.source.clone();
|
||||
let (_, _, raw) = doc.metadata.psd_global_blocks.iter().find(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD")).unwrap();
|
||||
let mut selected = raw.to_vec();
|
||||
selected[8..12].copy_from_slice(&kind);
|
||||
selected[12..16].copy_from_slice(&42u32.to_be_bytes());
|
||||
let mut data = photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile {
|
||||
uuid: "unselected-external".into(),
|
||||
file_name: "unselected.psb".into(),
|
||||
bytes: vec![9; 8],
|
||||
});
|
||||
data.extend(&selected);
|
||||
doc.metadata.psd_global_blocks.retain(|(_, k, _)| !matches!(k, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE"));
|
||||
doc.metadata.psd_global_blocks.push((*b"8BIM", key, Arc::new(data)));
|
||||
let (mut s, _) = source_session(doc);
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("file.new", json!({"width": 8, "height": 6})).unwrap();
|
||||
let pasted = LayerId(s.execute("edit.paste", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
let LayerContent::Smart(sm) = &s.active().unwrap().doc.layer(pasted).unwrap().content else { panic!("smart") };
|
||||
assert_eq!(sm.source, source, "source identity is retained; missing external contents aren't read during Copy");
|
||||
let back = roundtrip(&s.active().unwrap().doc, "pcraft");
|
||||
let linked: Vec<_> = back.metadata.psd_global_blocks.iter().filter(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE")).collect();
|
||||
assert_eq!(linked.len(), 1);
|
||||
assert_eq!(linked[0].1, key);
|
||||
assert_eq!(hash(&linked[0].2), hash(&selected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_refuses_a_truncated_selected_record_without_changing_document_or_clipboard() {
|
||||
let (mut s, _) = source_session(fixture(8));
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let before_clip = s.clipboard.clone().unwrap();
|
||||
s.edit("truncate selected linked record", |doc, _| {
|
||||
let (_, _, block) = doc.metadata.psd_global_blocks.iter_mut().find(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD")).unwrap();
|
||||
let mut truncated = block.to_vec();
|
||||
truncated[..8].copy_from_slice(&u64::MAX.to_be_bytes());
|
||||
*block = Arc::new(truncated);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before_doc = s.active().unwrap().doc.clone();
|
||||
let error = s.execute("edit.copy", json!({})).unwrap_err();
|
||||
assert!(error.to_string().contains("truncated selected"), "{error}");
|
||||
assert_eq!(s.active().unwrap().doc, before_doc);
|
||||
let after_clip = s.clipboard.as_ref().unwrap();
|
||||
assert_eq!(after_clip.bounds, before_clip.bounds);
|
||||
assert_eq!(after_clip.layers, before_clip.layers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_same_document_subset_pastes_do_not_duplicate_embedded_files() {
|
||||
let mut doc = fixture(16);
|
||||
let original = smart_id(&doc);
|
||||
let expected = bytes(&doc, original);
|
||||
let (_, _, block) = doc.metadata.psd_global_blocks.iter_mut().find(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD")).unwrap();
|
||||
let selected_record = block.clone();
|
||||
let mut combined = block.to_vec();
|
||||
combined.extend(photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile {
|
||||
uuid: "unselected".into(),
|
||||
file_name: "another.psb".into(),
|
||||
bytes: expected.to_vec(),
|
||||
}));
|
||||
*block = Arc::new(combined);
|
||||
let (mut s, _) = source_session(doc);
|
||||
let metadata = s.active().unwrap().doc.metadata.psd_global_blocks.clone();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let copied = s.clipboard.as_ref().unwrap().layers.as_ref().unwrap();
|
||||
assert_eq!(copied.metadata.psd_global_blocks.len(), 1);
|
||||
assert_eq!(hash(&copied.metadata.psd_global_blocks[0].2), hash(&selected_record));
|
||||
for _ in 0..3 {
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let pasted = LayerId(s.execute("edit.paste", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
assert_eq!(hash(&bytes(&s.active().unwrap().doc, pasted)), hash(&expected));
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
assert_eq!(after.metadata.psd_global_blocks.len(), metadata.len(), "subsets should not append a second linked block");
|
||||
for ((signature, key, bytes), (expected_signature, expected_key, expected_bytes)) in after.metadata.psd_global_blocks.iter().zip(&metadata) {
|
||||
assert_eq!(signature, expected_signature);
|
||||
assert_eq!(key, expected_key);
|
||||
assert_eq!(hash(bytes), hash(expected_bytes));
|
||||
}
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc, after);
|
||||
}
|
||||
let back = roundtrip(&s.active().unwrap().doc, "pcraft");
|
||||
let ids: Vec<_> = back.metadata.psd_global_blocks.iter().flat_map(|(_, _, data)| photocraft_io::linked::block_uuids(data)).collect();
|
||||
assert_eq!(ids.len(), 2, "one record per original file after native save/reopen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_source_ids_do_not_carry_unidentified_global_files() {
|
||||
let mut private = Session::new();
|
||||
private.execute("file.new", json!({"width":8,"height":6,"background":"black"})).unwrap();
|
||||
let bytes = photocraft_format::save_to_bytes(&private.active().unwrap().doc, &Default::default()).unwrap();
|
||||
let mut source = Session::new();
|
||||
source.execute("file.new", json!({"width":8,"height":6})).unwrap();
|
||||
source.execute("layer.new.layer", json!({})).unwrap();
|
||||
source.execute("edit.fill", json!({"contents":"foreground"})).unwrap();
|
||||
source.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
source
|
||||
.edit("malformed empty source", |doc, active| {
|
||||
let LayerContent::Smart(sm) = &mut doc.layer_mut(active.unwrap()).unwrap().content else { panic!("smart") };
|
||||
sm.source = SmartSource::Linked { path: String::new() };
|
||||
doc.metadata.psd_global_blocks.push((
|
||||
*b"8BIM",
|
||||
*b"lnk2",
|
||||
Arc::new(photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile {
|
||||
uuid: String::new(),
|
||||
file_name: "unidentified-private.pcraft".into(),
|
||||
bytes: bytes.clone(),
|
||||
})),
|
||||
));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let state = source.active().unwrap();
|
||||
let LayerContent::Smart(sm) = &state.doc.layer(state.active_layer.unwrap()).unwrap().content else { panic!("smart") };
|
||||
assert!(photocraft_engine::smart_cmds::source_bytes(&state.doc.metadata, &sm.source).is_none(), "an empty UUID cannot reference this record");
|
||||
source.execute("edit.copy", json!({})).unwrap();
|
||||
source.execute("file.new", json!({"width":8,"height":6})).unwrap();
|
||||
source.execute("edit.paste", json!({})).unwrap();
|
||||
let saved = photocraft_format::save_to_bytes(&source.active().unwrap().doc, &Default::default()).unwrap();
|
||||
let back = photocraft_format::load_from_bytes(&saved).unwrap();
|
||||
assert!(back.metadata.psd_global_blocks.is_empty(), "unidentified empty-ID global bytes must not enter the copied document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_invalid_utf8_ids_do_not_replace_the_clipboard() {
|
||||
let mut private = Session::new();
|
||||
private.execute("file.new", json!({"width":8,"height":6,"background":"black"})).unwrap();
|
||||
let private_bytes = photocraft_format::save_to_bytes(&private.active().unwrap().doc, &Default::default()).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":8,"height":6})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents":"foreground"})).unwrap();
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let before_clip = s.clipboard.clone().unwrap();
|
||||
s.edit("ambiguous source IDs", |doc, active| {
|
||||
let LayerContent::Smart(sm) = &mut doc.layer_mut(active.unwrap()).unwrap().content else { panic!("smart") };
|
||||
let SmartSource::Embedded { bytes, .. } = &sm.source else { panic!("embedded") };
|
||||
let mut first = photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile {
|
||||
uuid: "x".into(),
|
||||
file_name: "selected.pcraft".into(),
|
||||
bytes: bytes.to_vec(),
|
||||
});
|
||||
let mut second = photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile {
|
||||
uuid: "x".into(),
|
||||
file_name: "unrelated.pcraft".into(),
|
||||
bytes: private_bytes.clone(),
|
||||
});
|
||||
first[17] = 0xff;
|
||||
second[17] = 0xfe;
|
||||
first.extend(second);
|
||||
sm.source = SmartSource::Linked { path: "\u{fffd}".into() };
|
||||
doc.metadata.psd_global_blocks.push((*b"8BIM", *b"lnk2", Arc::new(first)));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before_doc = s.active().unwrap().doc.clone();
|
||||
let copied = s.execute("edit.copy", json!({}));
|
||||
if copied.is_ok() {
|
||||
let data = &s.clipboard.as_ref().unwrap().layers.as_ref().unwrap().metadata.psd_global_blocks[0].2;
|
||||
let files = photocraft_io::linked::parse_linked_files(data);
|
||||
assert_eq!(files.len(), 2);
|
||||
assert_eq!(files[0].uuid, files[1].uuid);
|
||||
eprintln!("two distinct malformed raw IDs were retained under one lossy source label");
|
||||
}
|
||||
let error = copied.expect_err("ambiguous source identity must fail before replacing the clipboard");
|
||||
assert!(error.to_string().contains("invalid UTF-8"), "{error}");
|
||||
assert_eq!(s.active().unwrap().doc, before_doc);
|
||||
assert_eq!(s.clipboard.as_ref().unwrap().layers, before_clip.layers);
|
||||
}
|
||||
@@ -275,6 +275,85 @@ pub fn block_uuids(data: &[u8]) -> Vec<String> {
|
||||
split_items(data).into_iter().filter_map(|(u, _)| u).collect()
|
||||
}
|
||||
|
||||
/// Copies only records accepted by `keep` (UUID and complete raw record including padding).
|
||||
/// Unlike [`rebuild_block`], unidentified records and malformed tails are discarded: clipboard
|
||||
/// copies must not carry unrelated embedded files. Empty UUIDs cannot identify source bytes.
|
||||
/// Selected identity must be provable without lossy UTF-8 decoding; an ambiguous or truncated
|
||||
/// selected ID is an error instead. No payload decoding or filesystem access is performed.
|
||||
/// `None` means the block already contains only selected complete records and can be shared.
|
||||
pub fn select_items(data: &[u8], keep: &dyn Fn(&str, &[u8]) -> bool) -> Result<Option<Vec<u8>>, crate::IoError> {
|
||||
let error = || crate::IoError::Unsupported("can't copy a truncated selected PSD linked-layer record".into());
|
||||
let mut selected = None;
|
||||
let mut at = 0usize;
|
||||
while let Some(rest) = data.get(at..).filter(|r| !r.is_empty()) {
|
||||
let Some((item, raw)) = framed_item(rest) else {
|
||||
if let Some(item) = rest.get(8..)
|
||||
&& selection_uuid(item, rest, keep)?.is_some_and(|uuid| keep(uuid, rest))
|
||||
{
|
||||
return Err(error());
|
||||
}
|
||||
if selected.is_none() {
|
||||
let mut prefix = Vec::new();
|
||||
append_selected(&mut prefix, data.get(..at).ok_or_else(error)?)?;
|
||||
selected = Some(prefix);
|
||||
}
|
||||
break;
|
||||
};
|
||||
if selection_uuid(item, raw, keep)?.is_some_and(|uuid| keep(uuid, raw)) {
|
||||
if let Some(out) = &mut selected {
|
||||
append_selected(out, raw)?;
|
||||
}
|
||||
} else if selected.is_none() {
|
||||
let mut prefix = Vec::new();
|
||||
append_selected(&mut prefix, data.get(..at).ok_or_else(error)?)?;
|
||||
selected = Some(prefix);
|
||||
}
|
||||
at = at.checked_add(raw.len()).ok_or_else(error)?;
|
||||
}
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
// Legacy PSD readers retain lossy IDs for file fidelity. Selection needs exact identity;
|
||||
// a lossy label is used only to refuse an ambiguous transfer, never to retain a record.
|
||||
fn selection_uuid<'a>(item: &'a [u8], raw: &[u8], keep: &dyn Fn(&str, &[u8]) -> bool) -> Result<Option<&'a str>, crate::IoError> {
|
||||
let Some(n) = item.get(8).map(|n| usize::from(*n)) else { return Ok(None) };
|
||||
let Some(bytes) = 9usize.checked_add(n).and_then(|end| item.get(9..end)).filter(|bytes| !bytes.is_empty()) else { return Ok(None) };
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(uuid) => Ok(Some(uuid)),
|
||||
Err(_) if keep(&String::from_utf8_lossy(bytes), raw) => {
|
||||
Err(crate::IoError::Unsupported("can't verify a PSD linked-layer source ID with invalid UTF-8".into()))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a complete raw record already occurs in a block, without decoding its file bytes.
|
||||
pub fn contains_item(data: &[u8], record: &[u8]) -> bool {
|
||||
let mut remaining = data;
|
||||
while let Some((_, raw)) = framed_item(remaining) {
|
||||
if raw == record {
|
||||
return true;
|
||||
}
|
||||
let Some(next) = remaining.get(raw.len()..) else { return false };
|
||||
remaining = next;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn framed_item(data: &[u8]) -> Option<(&[u8], &[u8])> {
|
||||
let len = usize::try_from(u64::from_be_bytes(data.get(..8)?.try_into().ok()?)).ok()?;
|
||||
let end = len.checked_add(8)?;
|
||||
let item = data.get(8..end)?;
|
||||
let padded = end.checked_add(3).map(|n| n & !3)?.min(data.len());
|
||||
Some((item, data.get(..padded)?))
|
||||
}
|
||||
|
||||
fn append_selected(out: &mut Vec<u8>, bytes: &[u8]) -> Result<(), crate::IoError> {
|
||||
out.try_reserve(bytes.len()).map_err(|_| crate::IoError::Unsupported("not enough memory to copy PSD linked-layer records".into()))?;
|
||||
out.extend_from_slice(bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copies one linked item under a new UUID, preserving its embedded, external or alias payload.
|
||||
pub(crate) fn copy_item(data: &[u8], uuid: &str, new_uuid: &str) -> Option<Vec<u8>> {
|
||||
let (_, raw) = split_items(data).into_iter().find(|(u, _)| u.as_deref() == Some(uuid))?;
|
||||
@@ -392,4 +471,79 @@ mod tests {
|
||||
assert_eq!(©[17 + "independent-copy".len()..8 + copied_len], &raw[20..8 + original_len]);
|
||||
assert!(copy_item(&raw, "missing", "new").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn selected_records_exclude_other_files_and_unidentified_pieces() {
|
||||
let a = encode_linked_file(&LinkedFile { uuid: "a".into(), file_name: "a.psb".into(), bytes: vec![1; 3] });
|
||||
let b = encode_linked_file(&LinkedFile { uuid: "b".into(), file_name: "b.psb".into(), bytes: vec![2; 4] });
|
||||
assert!(select_items(&a, &|uuid, _| uuid == "a").unwrap().is_none(), "an exclusive block can keep its original Arc");
|
||||
let mut data = a.clone();
|
||||
data.extend(&b);
|
||||
data.extend([0, 0, 0, 0, 0, 0, 1, 0, 9]);
|
||||
assert_eq!(select_items(&data, &|uuid, _| uuid == "a").unwrap(), Some(a.clone()));
|
||||
assert_eq!(select_items(&data, &|uuid, _| uuid == "b").unwrap(), Some(b));
|
||||
assert_eq!(select_items(&data, &|_, _| false).unwrap(), Some(Vec::new()));
|
||||
// A complete but unidentified item can be skipped without losing a later selected item.
|
||||
let mut unreadable = 1u64.to_be_bytes().to_vec();
|
||||
unreadable.extend([7, 0, 0, 0]);
|
||||
unreadable.extend(&a);
|
||||
assert_eq!(select_items(&unreadable, &|uuid, _| uuid == "a").unwrap(), Some(a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_external_and_alias_records_keep_unknown_payloads_verbatim() {
|
||||
for kind in [*b"liFE", *b"liFA", *b"next"] {
|
||||
let mut raw = encode_linked_file(&LinkedFile { uuid: "selected".into(), file_name: "missing.psb".into(), bytes: vec![7; 5] });
|
||||
raw[8..12].copy_from_slice(&kind);
|
||||
raw[12..16].copy_from_slice(&42u32.to_be_bytes()); // an unknown future version
|
||||
let mut data = encode_linked_file(&LinkedFile { uuid: "unselected".into(), file_name: "other.psb".into(), bytes: vec![9; 3] });
|
||||
data.extend(&raw);
|
||||
assert_eq!(select_items(&data, &|uuid, _| uuid == "selected").unwrap(), Some(raw));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_selected_records_fail_instead_of_becoming_unavailable() {
|
||||
let mut raw = encode_linked_file(&LinkedFile { uuid: "selected".into(), file_name: "missing.psb".into(), bytes: vec![7; 5] });
|
||||
raw[..8].copy_from_slice(&u64::MAX.to_be_bytes());
|
||||
assert!(select_items(&raw, &|uuid, _| uuid == "selected").unwrap_err().to_string().contains("truncated selected"));
|
||||
assert_eq!(select_items(&raw, &|uuid, _| uuid == "other").unwrap(), Some(Vec::new()));
|
||||
}
|
||||
#[test]
|
||||
fn existing_records_are_matched_individually_without_matching_other_payloads() {
|
||||
let a = encode_linked_file(&LinkedFile { uuid: "a".into(), file_name: "a.psb".into(), bytes: vec![1; 3] });
|
||||
let b = encode_linked_file(&LinkedFile { uuid: "b".into(), file_name: "b.psb".into(), bytes: vec![2; 4] });
|
||||
let different = encode_linked_file(&LinkedFile { uuid: "a".into(), file_name: "a.psb".into(), bytes: vec![9; 3] });
|
||||
let mut combined = a.clone();
|
||||
combined.extend(&b);
|
||||
assert!(contains_item(&combined, &a));
|
||||
assert!(contains_item(&combined, &b));
|
||||
assert!(!contains_item(&combined, &different));
|
||||
assert_eq!(select_items(&a, &|_, raw| !contains_item(&combined, raw)).unwrap(), Some(Vec::new()));
|
||||
assert!(select_items(&different, &|_, raw| !contains_item(&combined, raw)).unwrap().is_none());
|
||||
}
|
||||
#[test]
|
||||
fn empty_uuids_are_unidentified_even_when_requested() {
|
||||
let raw = encode_linked_file(&LinkedFile { uuid: String::new(), file_name: "unidentified.psb".into(), bytes: vec![7; 5] });
|
||||
assert_eq!(select_items(&raw, &|_, _| true).unwrap(), Some(Vec::new()));
|
||||
let mut truncated = raw;
|
||||
truncated[..8].copy_from_slice(&u64::MAX.to_be_bytes());
|
||||
assert_eq!(select_items(&truncated, &|_, _| true).unwrap(), Some(Vec::new()));
|
||||
}
|
||||
#[test]
|
||||
fn selection_never_uses_lossy_uuid_aliases_as_record_identity() {
|
||||
let mut a = encode_linked_file(&LinkedFile { uuid: "x".into(), file_name: "a.psb".into(), bytes: vec![1; 3] });
|
||||
let mut b = encode_linked_file(&LinkedFile { uuid: "x".into(), file_name: "b.psb".into(), bytes: vec![2; 4] });
|
||||
a[17] = 0xff;
|
||||
b[17] = 0xfe;
|
||||
let mut data = a;
|
||||
data.extend(b);
|
||||
assert_eq!(block_uuids(&data), vec!["\u{fffd}", "\u{fffd}"], "legacy lossy parsing remains unchanged");
|
||||
assert!(select_items(&data, &|uuid, _| uuid == "\u{fffd}").unwrap_err().to_string().contains("invalid UTF-8"));
|
||||
let valid = encode_linked_file(&LinkedFile { uuid: "valid".into(), file_name: "valid.psb".into(), bytes: vec![3; 5] });
|
||||
data.extend(&valid);
|
||||
assert_eq!(select_items(&data, &|uuid, _| uuid == "valid").unwrap(), Some(valid));
|
||||
// A literal replacement character can itself be a valid UTF-8 identity.
|
||||
let valid_unicode = encode_linked_file(&LinkedFile { uuid: "\u{fffd}".into(), file_name: "unicode.psb".into(), bytes: vec![4; 2] });
|
||||
assert!(select_items(&valid_unicode, &|uuid, _| uuid == "\u{fffd}").unwrap().is_none());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user