diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs index 2ff363b4..cc0cfa2c 100644 --- a/crates/engine/src/psd_import.rs +++ b/crates/engine/src/psd_import.rs @@ -115,10 +115,45 @@ pub fn import(proj: &mut Project, psd: &Psd, path: &str, name: &str, retain: boo if flattened > 0 { cx.warnings.push(format!("{flattened} group(s) nested deeper than {} levels were flattened into their parent", effectcraft_psd::MAX_GROUP_DEPTH)); } - let comp = build_comp(proj, &mut cx, name, &tree, None); + cx.warnings.extend(psd.warnings.iter().cloned()); + let comp = if psd.layers.is_empty() && !psd.warnings.is_empty() { + flattened_comp(proj, &mut cx, name) + } else { + build_comp(proj, &mut cx, name, &tree, None) + }; PsdImport { comp, folder, items: cx.items, warnings: cx.warnings } } +/// The layers could not be read: the merged image as the composition's only layer. +fn flattened_comp(proj: &mut Project, cx: &mut Ctx, name: &str) -> ItemId { + let cid = proj.add_item(name, Label::Sandstone, None, ItemKind::Comp(Arc::new(new_comp(cx)))); + cx.items.push(cid); + let mut comp = new_comp(cx); + let file = std::path::Path::new(cx.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let f = Footage { + path: cx.path.to_string(), + kind: FootageKind::Still, + width: cx.psd.width, + height: cx.psd.height, + pixel_aspect: 1.0, + frame_rate: cx.rate, + has_video: true, + alpha: if cx.psd.merged_alpha { AlphaMode::Straight } else { AlphaMode::Ignore }, + loop_count: 1, + codec: "PSD".into(), + layer: None, + ..Default::default() + }; + let id = proj.add_item(&file, Label::Lavender, Some(cx.folder), ItemKind::Footage(f)); + cx.items.push(id); + let l = build::layer(proj, &comp, name, LayerSource::Footage { item: id }, (cx.psd.width, cx.psd.height), None); + comp.layers.push(l); + if let Some(item) = proj.item_mut(cid) { + item.kind = ItemKind::Comp(Arc::new(comp)); + } + cid +} + fn new_comp(cx: &Ctx) -> Comp { // A PSB can be 300,000 px a side; a composition is at most 30,000 (#788). let size = |v: u32| effectcraft_project::limits::size(v.into()); diff --git a/crates/engine/src/tests_vector_import.rs b/crates/engine/src/tests_vector_import.rs index 8656dfaa..63513b91 100644 --- a/crates/engine/src/tests_vector_import.rs +++ b/crates/engine/src/tests_vector_import.rs @@ -310,3 +310,30 @@ fn psd_with_thousands_of_nested_groups_imports_on_a_small_stack() { assert_eq!(comps, 65); assert!(warned); } + +#[test] +fn psd_with_damaged_layer_section_imports_the_flattened_image() { + let mut d = WDoc::new(30, 20); + d.layers = vec![ + WLayer::solid("Back", Rect::new(0, 0, 30, 20), [0.0, 0.0, 1.0, 1.0]), + WLayer::solid("Box", Rect::new(2, 2, 8, 8), [1.0, 0.0, 0.0, 1.0]).with_blend(b"mul "), + ]; + let mut bytes = write(&d); + let at = bytes.windows(8).position(|w| w == b"8BIMmul ").unwrap(); + bytes[at] = b'X'; + let path = tmp("damaged.psd"); + std::fs::write(&path, bytes).unwrap(); + let mut s = Session::default(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); + // Photoshop import warnings are reported with the import's `errors` (as for adjustment layers). + let warnings = r["errors"].as_array().unwrap(); + assert_eq!(warnings.len(), 1, "{r}"); + assert!(warnings[0].as_str().unwrap().contains("Layers could not be read"), "{r}"); + let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); + let c = s.project.comp(cid).unwrap().clone(); + assert_eq!((c.width, c.height), (30, 20)); + assert_eq!(c.layers.len(), 1); + let LayerSource::Footage { item } = c.layers[0].source else { panic!("footage") }; + let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() }; + assert!(f.layer.is_none(), "the merged image, not a Photoshop layer"); +} diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index d99b558b..2fcd76e3 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -343,6 +343,9 @@ pub struct Psd { pub palette: Vec, /// Files embedded for smart objects. pub linked: Vec, + /// Repairs made while reading: damage that was skipped instead of failing the document + /// (for example an unreadable layer section, in which case only the merged image is kept). + pub warnings: Vec, data: Arc<[u8]>, merged_offset: usize, } @@ -391,6 +394,7 @@ impl Psd { merged_alpha: false, palette, linked: vec![], + warnings: vec![], data: data.clone(), merged_offset: 0, }; @@ -399,8 +403,17 @@ impl Psd { if lm_len > 0 { let li_len = r.length(psb)?; let li_end = r.pos.checked_add(li_len).filter(|&e| e <= lm_end).ok_or(Error::Truncated)?; - if li_len > 0 { - psd.layer_info(&data, r.pos, li_end)?; + if li_len > 0 + && let Err(e) = psd.layer_info(&data, r.pos, li_end) + { + // A damaged layer section must not discard the document: keep the header and the + // merged image, drop the layers and say so. Without a merged image there is + // nothing to fall back to, so the original error stands. + if lm_end.checked_add(2).is_none_or(|n| n > data.len()) { + return Err(e); + } + psd.layers.clear(); + psd.warnings.push(format!("Layers could not be read ({e}); only the flattened image is available")); } r.pos = li_end; if r.pos + 4 <= lm_end { @@ -426,6 +439,10 @@ impl Psd { r.pos = end; } } + if !psd.layers.is_empty() { + // A 16/32-bit layer block recovered the layers. + psd.warnings.clear(); + } psd.merged_offset = lm_end; Ok(psd) } diff --git a/crates/psd/src/tests.rs b/crates/psd/src/tests.rs index a0931383..9252808e 100644 --- a/crates/psd/src/tests.rs +++ b/crates/psd/src/tests.rs @@ -434,3 +434,58 @@ fn rle_composite_matches_the_raw_one() { assert_eq!(rle.layer_pixels(2, true).unwrap().data, raw.layer_pixels(2, true).unwrap().data, "{depth} bit"); } } + +fn find(bytes: &[u8], needle: &[u8]) -> usize { + bytes.windows(needle.len()).position(|w| w == needle).expect("needle present") +} + +/// The second layer record's signature overwritten. +fn bad_signature() -> Vec { + let mut bytes = write(&doc_with_layers(8, ColorMode::Rgb, true)); + let at = find(&bytes, b"8BIMmul "); + bytes[at] = b'X'; + bytes +} + +/// The first channel of the first layer claims more data than the file holds. +fn bad_channel_length() -> Vec { + let mut bytes = write(&doc_with_layers(8, ColorMode::Rgb, true)); + let at = find(&bytes, b"8BIMnorm"); + let first_len = at - 4 * 6 + 2; + bytes[first_len..first_len + 4].copy_from_slice(&0x7fff_fff0u32.to_be_bytes()); + bytes +} + +#[test] +fn damaged_layer_section_keeps_the_merged_image() { + let good = Psd::parse(write(&doc_with_layers(8, ColorMode::Rgb, true))).unwrap(); + assert!(good.warnings.is_empty() && good.layers.len() == 3); + let want = good.composite().unwrap(); + for bytes in [bad_signature(), bad_channel_length()] { + let p = Psd::parse(bytes).unwrap(); + assert!(p.layers.is_empty()); + assert_eq!(p.warnings.len(), 1, "{:?}", p.warnings); + assert!(p.warnings[0].contains("Layers could not be read")); + assert_eq!((p.width, p.height), (40, 30)); + let got = p.composite().unwrap(); + assert_eq!(got.data, want.data); + } +} + +#[test] +fn header_damage_and_missing_merged_image_still_fail() { + let good = write(&doc_with_layers(8, ColorMode::Rgb, true)); + // Zero width. + let mut b = good.clone(); + b[18..22].copy_from_slice(&0u32.to_be_bytes()); + assert!(Psd::parse(b).is_err()); + // Unknown colour mode. + let mut b = good.clone(); + b[24..26].copy_from_slice(&99u16.to_be_bytes()); + assert!(Psd::parse(b).is_err()); + // Damaged layers and no merged image at all: nothing to fall back to. + let end = Psd::parse(good).unwrap().merged_offset; + let mut b = bad_signature(); + b.truncate(end); + assert!(Psd::parse(b).is_err()); +}