Merge pull request #1135 from vangel76/fix-psd-lenient-layer-section

# Conflicts:
#	crates/engine/src/psd_import.rs
#	crates/engine/src/tests_vector_import.rs
#	crates/psd/src/tests.rs
This commit is contained in:
Brandon Thomas committed 2026-10-13 04:28:40 -04:00
commit b438cdd702
4 files changed
+137 -3

No files matched your search

+36 -1
View File
@@ -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());
+27
View File
@@ -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");
}
+19 -2
View File
@@ -343,6 +343,9 @@ pub struct Psd {
pub palette: Vec<u8>,
/// Files embedded for smart objects.
pub linked: Vec<LinkedFile>,
/// 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<String>,
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)
}
+55
View File
@@ -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<u8> {
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<u8> {
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());
}