PSD import: bound group nesting at 64 so thousands of nested groups cannot overflow the stack (#1128)

Psd::tree flattens groups nested deeper than MAX_GROUP_DEPTH into their depth-64 parent, keeping every layer, so the importer recursion and the tree's Drop/Clone/PartialEq are bounded. The import result reports how many groups were flattened.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-12 22:56:56 +02:00
co-authored by Claude Sonnet 5.5
parent 470ea7a3af
commit 4f9a04fcf5
4 changed files with 132 additions and 4 deletions
+5 -1
View File
@@ -111,7 +111,11 @@ 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);
let (tree, flattened) = psd.tree_report();
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);
PsdImport { comp, folder, items: cx.items, warnings: cx.warnings }
}
+27
View File
@@ -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_thousands_of_nested_groups_imports_on_a_small_stack() {
let mut d = WDoc::new(8, 8);
let n = 5000;
d.layers = (0..n).map(|_| WLayer::group_end()).collect();
for i in 0..n {
d.layers.push(WLayer::solid(&format!("L{i}"), Rect::new(0, 0, 2, 2), [1.0; 4]));
d.layers.push(WLayer::group(&format!("G{i}"), true, *b"norm"));
}
let path = tmp("nested.psd");
std::fs::write(&path, effectcraft_psd::write::write(&d)).unwrap();
let h = std::thread::Builder::new()
.stack_size(2 << 20)
.spawn(move || {
let mut s = Session::default();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
let warned = r["errors"].as_array().is_some_and(|e| e.len() == 1 && e[0].as_str().is_some_and(|m| m.contains("flattened")));
// 64 nested group comps plus the root comp.
let comps = s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_))).count();
(warned, comps)
})
.unwrap();
let (warned, comps) = h.join().unwrap();
assert_eq!(comps, 65);
assert!(warned);
}