From 6259bf0b5741f43bbad14b7957e94f7d1413b05e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 23:00:40 +0200 Subject: [PATCH] #1132: a damaged PSD layer section keeps the merged image instead of failing the document Psd::parse drops the layers, records Psd::warnings and keeps the header and merged image; the importer then imports the flattened image as one layer and reports 'Layers could not be read'. Header damage and a missing merged image stay hard errors. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/psd_import.rs | 37 +++++++++++++++- crates/engine/src/tests_vector_import.rs | 27 ++++++++++++ crates/psd/src/lib.rs | 21 ++++++++- crates/psd/src/tests.rs | 55 ++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs index b025a7a5..6741ed75 100644 --- a/crates/engine/src/psd_import.rs +++ b/crates/engine/src/psd_import.rs @@ -111,10 +111,45 @@ struct Ctx<'a> { pub fn import(proj: &mut Project, psd: &Psd, path: &str, name: &str, retain: bool, rate: FrameRate, duration: Tick) -> PsdImport { let folder = proj.add_item(&format!("{name} Layers"), Label::Yellow, None, ItemKind::Folder); let mut cx = Ctx { psd, path, retain, folder, rate, duration, items: vec![], warnings: vec![] }; - let comp = build_comp(proj, &mut cx, name, &psd.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, &psd.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 e33a38b0..bbacb1bf 100644 --- a/crates/engine/src/tests_vector_import.rs +++ b/crates/engine/src/tests_vector_import.rs @@ -283,3 +283,30 @@ fn paste_svg_rejects_text_that_is_not_usable_svg() { assert!(s.execute_checked("edit.pasteSvg", json!({})).is_err()); assert_eq!(s.active_comp().unwrap().layers.len(), 1, "nothing pasted"); } + +#[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 1c0fd3d4..202a40cc 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -339,6 +339,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, } @@ -387,6 +390,7 @@ impl Psd { merged_alpha: false, palette, linked: vec![], + warnings: vec![], data: data.clone(), merged_offset: 0, }; @@ -395,8 +399,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 { @@ -422,6 +435,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 235163b6..bb366595 100644 --- a/crates/psd/src/tests.rs +++ b/crates/psd/src/tests.rs @@ -252,3 +252,58 @@ fn huge_layer_bounds_error_instead_of_allocating() { let p = Psd::parse(bytes).unwrap(); assert!(matches!(p.layer_pixels(0, false), Err(Error::Unsupported(_)))); } + +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()); +}