diff --git a/Cargo.lock b/Cargo.lock index 3f31c2bbe4..02a98f91dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3594,6 +3594,8 @@ dependencies = [ name = "photocraft-io" version = "0.2.0" dependencies = [ + "blake3", + "photocraft-algo", "photocraft-cms", "photocraft-codecs", "photocraft-color", @@ -3608,6 +3610,7 @@ dependencies = [ "photocraft-text", "photocraft-vector", "proptest", + "serde_json", "thiserror 2.0.21", ] diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index 013c21f9bd..bcb86798bd 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -26,6 +26,26 @@ const CELL: f64 = 4.0; /// Most cells along one axis (bounds memory and time on huge layers). const MAX_CELLS: usize = 512; +/// Places a smart object's source image (`src`, its pixels in `src_rect`) in the document: +/// through `warp` (source space) and then the affine `t` (source → document) in one resampling +/// pass, an exact shift for whole-pixel translations (so conversions and re-renders are +/// lossless), bicubic otherwise. Shared by the engine's re-render and PSD export's filter cache. +pub fn place_source(src: &Surface, src_rect: Rect, t: &photocraft_geom::Affine, warp: Option<&photocraft_geom::warp::Warp>) -> Surface { + let [a, b, c, d, e, f] = t.m; + if let Some(w) = warp.filter(|w| !w.is_identity()) { + let map = |x: f64, y: f64| { + let (u, v) = w.map(x, y); + (a * u + c * v + e, b * u + d * v + f) + }; + return warp_mesh_surface(src, src_rect, &map, Interp::Bicubic); + } + let near = |x: f64, y: f64| (x - y).abs() < 1e-9; + if near(a, 1.0) && near(b, 0.0) && near(c, 0.0) && near(d, 1.0) && near(e, e.round()) && near(f, f.round()) { + return crate::resample::translate_surface(src, e.round() as i32, f.round() as i32); + } + crate::transform::warp_surface(src, src_rect, &crate::transform::Homography([a, c, e, b, d, f, 0.0, 0.0, 1.0]), Interp::Bicubic) +} + /// Warps the content of `src` inside `src_rect` through the forward map `f` (source document /// coordinates → destination document coordinates). Output has `src`'s format with alpha /// added; pixels outside the warped area are transparent. diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index 08780c0621..bbc98a8636 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -204,9 +204,30 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { // ---------- commands ---------- +/// Image › Adjustments run as a smart filter (`image.adjustments.` recorded on a smart +/// object): the adjustment applied to a copy of `surf`. `None` for kinds or params it can't run. +pub(crate) fn adjust_as_filter(kind: &str, p: &Value, surf: &Surface) -> Option { + let mut out = surf.clone(); + if kind == "shadowsHighlights" { + let sh = shadows_highlights_params(p); + process_surface(&mut out, None, &mut |px, r| tone::shadows_highlights(px, r.width() as usize, r.height() as usize, &sh)); + } else { + let mode = surf.format().mode; + let adj = crate::adjust_params::from_params(kind, p, None, mode).ok()?; + crate::pixels::adjust_surface(&mut out, &adj, None, mode); + } + out.prune(); + Some(out) +} + fn shadows_highlights(s: &mut Session, p: &Value) -> Result { + let sh = shadows_highlights_params(p); + rgba_edit(s, "Shadows/Highlights", p, |px, r| tone::shadows_highlights(px, r.width() as usize, r.height() as usize, &sh)) +} + +fn shadows_highlights_params(p: &Value) -> ShadowsHighlights { let d = ShadowsHighlights::default(); - let sh = ShadowsHighlights { + ShadowsHighlights { shadow_amount: num(p, "shadowAmount", d.shadow_amount).clamp(0.0, 100.0), shadow_tone: num(p, "shadowTone", d.shadow_tone).clamp(0.0, 100.0), shadow_radius: num(p, "shadowRadius", d.shadow_radius).clamp(0.0, 2500.0), @@ -217,8 +238,7 @@ fn shadows_highlights(s: &mut Session, p: &Value) -> Result { midtone: num(p, "midtone", d.midtone).clamp(-100.0, 100.0), black_clip: num(p, "blackClip", d.black_clip).clamp(0.0, 50.0), white_clip: num(p, "whiteClip", d.white_clip).clamp(0.0, 50.0), - }; - rgba_edit(s, "Shadows/Highlights", p, |px, r| tone::shadows_highlights(px, r.width() as usize, r.height() as usize, &sh)) + } } fn replace_color(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index de14243218..2d1c05fec5 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -149,6 +149,10 @@ pub fn apply_filter_to_surface( if let Some(out) = crate::lens_cmds::apply_to_surface(id, params, surf, canvas) { return Some(out); } + // Image › Adjustments recorded as smart filters (Levels, Curves, Shadows/Highlights… from PSD). + if let Some(kind) = id.strip_prefix("image.adjustments.") { + return crate::adjust_cmds::adjust_as_filter(kind, params, surf); + } // WebAssembly plug-in smart filters (plugin_cmds). if id == crate::plugin_cmds::RUN { return crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas); diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index a09184081e..a912478e00 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -16,12 +16,13 @@ //! Converting a layer embeds it as an in-memory `.pcraft` bundle of a nested document (exactly //! lossless, layered, any depth/model). PSD placed layers keep their source in the preserved //! global `lnk2` block (found by uuid), and keep Photoshop's rendering until something changes, -//! so an unedited PSD still round-trips byte for byte. +//! so an unedited PSD still round-trips byte for byte. PSD export (`photocraft_io::smart_map`) +//! writes every smart object back as one: its source embedded in `lnk2` (a `.pcraft` source as a +//! PSB), its smart filters in `filterFX` and its filter mask in `FEid`. use std::sync::{Arc, Mutex}; use photocraft_algo::resample::translate_surface; -use photocraft_algo::transform::{Homography, Interp, warp_surface}; use photocraft_color::{BlendMode, PixelFormat}; use photocraft_doc::{DocId, Document, Layer, LayerContent, LayerId, LayerMask, Metadata, SmartObject, SmartSource}; use photocraft_geom::{Affine, Rect, Size}; @@ -42,6 +43,9 @@ pub struct SmartLink { /// PSD placed-layer keys that describe the smart object's source; stale once we change it. const PLACED_KEYS: [&[u8; 4]; 3] = [b"SoLd", b"PlLd", b"SoLE"]; +/// Command id of a Photoshop smart filter PhotoCraft does not implement (kept verbatim from PSD). +pub use photocraft_io::smart_map::UNSUPPORTED_FILTER; + fn other(msg: impl Into) -> EngineError { EngineError::Other(msg.into()) } @@ -210,28 +214,6 @@ pub fn stack_image(file_name: &str, bytes: &[u8], fmt: PixelFormat, mode: photoc // ---------- rendering ---------- -/// Places the source image in the document: an exact shift for whole-pixel translations -/// (conversion and re-render are then lossless), a bicubic warp otherwise. -fn place(img: &SourceImage, t: &Affine) -> Surface { - let [a, b, c, d, e, f] = t.m; - let near = |x: f64, y: f64| (x - y).abs() < 1e-9; - if near(a, 1.0) && near(b, 0.0) && near(c, 0.0) && near(d, 1.0) && near(e, e.round()) && near(f, f.round()) { - return translate_surface(&img.surface, e.round() as i32, f.round() as i32); - } - warp_surface(&img.surface, img.bounds, &Homography([a, c, e, b, d, f, 0.0, 0.0, 1.0]), Interp::Bicubic) -} - -/// Places the source image through its warp (source space) and then its transform, in one -/// resampling pass (Edit › Transform › Warp on a smart object stays lossless). -fn place_warped(img: &SourceImage, w: &photocraft_geom::warp::Warp, t: &Affine) -> Surface { - let [a, b, c, d, e, f] = t.m; - let map = |x: f64, y: f64| { - let (u, v) = w.map(x, y); - (a * u + c * v + e, b * u + d * v + f) - }; - photocraft_algo::warp::warp_mesh_surface(&img.surface, img.bounds, &map, Interp::Bicubic) -} - /// `top` blended over `base` with `mode` at `opacity` (a smart filter's blending options). fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32) -> Surface { let fmt = base.format(); @@ -313,10 +295,8 @@ pub fn render(doc: &Document, sm: &SmartObject) -> Result> { Some(mode) => stack_image(&name, &bytes, doc.pixel_format(), mode)?, None => source_image(&name, &bytes, doc.pixel_format())?, }; - let placed = match sm.warp.as_ref().filter(|w| !w.is_identity()) { - Some(w) => place_warped(&img, w, &sm.transform), - None => place(&img, &sm.transform), - }; + // Through the warp (source space) and the transform in one pass; whole-pixel moves are exact. + let placed = photocraft_algo::warp::place_source(&img.surface, img.bounds, &sm.transform, sm.warp.as_ref()); Ok(Some(apply_smart_filters(&placed, sm, doc.bounds()))) } @@ -410,8 +390,8 @@ pub(crate) fn shift_smart(sm: &mut SmartObject, dx: i32, dy: i32) { } } -/// Forget PSD placed-layer data that no longer describes the smart object (PSD export then -/// writes the rendered pixels). +/// Forget PSD placed-layer data that no longer describes the smart object (its source changed): +/// PSD export then writes fresh placed-layer blocks and embeds the new source. fn detach_psd(l: &mut Layer) { if let LayerContent::Smart(sm) = &mut l.content { sm.psd_raw = None; @@ -729,6 +709,9 @@ fn set_filter_params(s: &mut Session, p: &Value) -> Result { edit_filters(s, p, "Edit Smart Filter", |sm| { let i = filter_index(CMD, p, sm)?; let f = &mut sm.smart_filters[i]; + if f.command == photocraft_io::smart_map::UNSUPPORTED_FILTER { + return Err(bad(CMD, "this Photoshop filter isn't implemented in PhotoCraft: it is kept as is (it can be hidden, moved or deleted)")); + } match (&mut f.params, new) { (Value::Object(old), Value::Object(n)) => old.extend(n), (slot, n) => *slot = n, diff --git a/crates/engine/tests/photoshop_oracles.rs b/crates/engine/tests/photoshop_oracles.rs index bf45ce4c8b..2edd13b2ba 100644 --- a/crates/engine/tests/photoshop_oracles.rs +++ b/crates/engine/tests/photoshop_oracles.rs @@ -20,8 +20,8 @@ use photocraft_psd::PsdFile; /// Within two 8-bit steps of Photoshop (same tolerance as the io corpus). const PASS_TOL: f32 = 2.0 / 255.0; -/// Re-rendered files that match Photoshop's merged composite (of 84 with smart objects or type). -const PASS_FLOOR: usize = 1; +/// Re-rendered files that match Photoshop's merged composite (of 86 with smart objects or type). +const PASS_FLOOR: usize = 5; fn collect(dir: &Path, out: &mut Vec) { let Ok(rd) = std::fs::read_dir(dir) else { return }; diff --git a/crates/engine/tests/smart_psd.rs b/crates/engine/tests/smart_psd.rs new file mode 100644 index 0000000000..3480c12eef --- /dev/null +++ b/crates/engine/tests/smart_psd.rs @@ -0,0 +1,280 @@ +//! Smart objects and smart filters made in PhotoCraft survive a PSD save and open (#215): the +//! layer comes back as a live smart object (not pixels) with the same nested document (embedded +//! in `lnk2` as a PSB), transform, filter list and filter mask, and re-renders like before. +//! +//! Set `PHOTOCRAFT_SMART_PSD_OUT=` to keep the exported files (for opening in Photoshop). + +use photocraft_doc::{Document, LayerContent, SmartObject, SmartSource}; +use photocraft_engine::Session; +use photocraft_engine::smart_cmds::{decode_source, refresh_layer, source_bytes}; +use photocraft_geom::Rect; +use serde_json::json; + +const W: i32 = 96; +const H: i32 = 64; + +fn session(depth: u64) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let fmt = surf.format(); + let r = Rect::new(8, 6, 80, 58); + let mut data = Vec::new(); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let v = ((x * 7 + y * 13) % 23) as f32 / 22.0; + data.extend(photocraft_raster::from_rgba(&fmt, [v, 1.0 - v, ((x * y) % 7) as f32 / 6.0, 1.0])); + } + } + surf.write_region(r, &data); + Ok(()) + }) + .unwrap(); + s +} + +fn smart(doc: &Document) -> (u64, SmartObject) { + doc.walk() + .into_iter() + .find_map(|(_, _, l)| match &l.content { + LayerContent::Smart(sm) => Some((l.id.0, sm.clone())), + _ => None, + }) + .expect("a smart object") +} + +fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 { + assert_eq!(a.len(), b.len()); + a.iter().zip(b).flat_map(|(p, q)| (0..4).map(move |c| (p[c] * p[3].max(0.0) - q[c] * q[3].max(0.0)).abs())).fold(0.0, f32::max) +} + +/// Every block with inner structure re-parses strictly (`TaggedBlock::check_structure`). +fn structure_errors(bytes: &[u8]) -> Vec { + let f = photocraft_psd::PsdFile::from_bytes(bytes).unwrap(); + let mut errs = Vec::new(); + for b in &f.global_blocks { + if let Err(e) = b.check_structure() { + errs.push(format!("global {}: {e}", b.key_str())); + } + } + for l in f.layers() { + for b in &l.blocks { + if let Err(e) = b.check_structure() { + errs.push(format!("layer {}: {e}", b.key_str())); + } + } + } + errs +} + +fn keep(name: &str, bytes: &[u8]) { + if let Ok(dir) = std::env::var("PHOTOCRAFT_SMART_PSD_OUT") { + std::fs::write(std::path::Path::new(&dir).join(name), bytes).unwrap(); + } +} + +#[test] +fn smart_objects_and_filters_survive_psd() { + for depth in [8, 16, 32] { + let mut s = session(depth); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 40, "height": 64})).unwrap(); + s.execute("filter.noise.addNoise", json!({"amount": 20, "seed": 3, "distribution": "gaussian"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.smartFilter.blendingOptions", json!({"opacity": 0.5, "blend": "screen"})).unwrap(); + s.execute("filter.stylize.emboss", json!({"angle": 120, "height": 2, "amount": 80})).unwrap(); + s.execute("layer.smartFilter.setVisible", json!({"index": 2, "visible": false})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + s.execute("edit.transform", json!({"layer": id, "matrix": [0.75, 0, 0, 0.75, 10, 4]})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let (_, before) = smart(&doc); + let SmartSource::Embedded { bytes: pcraft, .. } = &before.source else { panic!("embedded") }; + let nested = decode_source("x.pcraft", pcraft).unwrap(); + + let out = photocraft_io::export(&doc, "psd", &Default::default()).unwrap(); + assert!(!out.warnings.iter().any(|w| w.contains("pixels")), "{:?}", out.warnings); + assert_eq!(structure_errors(&out.bytes), Vec::::new()); + keep(&format!("smart-{depth}.psd"), &out.bytes); + + let back = photocraft_io::import("x.psd", &out.bytes).unwrap().document; + let (bid, after) = smart(&back); + assert!(matches!(after.source, SmartSource::Linked { .. }), "depth {depth}: source in lnk2"); + // Same filters, blending and settings (numbers may come back as floats, defaults spelled out). + assert_eq!(after.smart_filters.len(), before.smart_filters.len()); + for (a, b) in after.smart_filters.iter().zip(&before.smart_filters) { + assert_eq!((&a.command, a.blend, a.opacity, a.visible), (&b.command, b.blend, b.opacity, b.visible), "depth {depth}"); + for (k, v) in b.params.as_object().unwrap() { + let w = &a.params[k]; + assert!(w == v || w.as_f64().zip(v.as_f64()).is_some_and(|(x, y)| x == y), "{}: {k} {v} came back as {w}", b.command); + } + } + assert_eq!(after.filters_enabled, before.filters_enabled); + // The filter mask covers the same pixels (PSD extends it with white past its bounds). + let (ma, mb) = (after.filter_mask.as_ref().unwrap(), before.filter_mask.as_ref().unwrap()); + assert_eq!((ma.enabled, ma.linked), (mb.enabled, mb.linked)); + let area = doc.bounds().union(&before.cache.as_ref().unwrap().content_bounds()); + let (mut va, mut vb) = (Vec::new(), Vec::new()); + ma.values_into(area, &mut va); + mb.values_into(area, &mut vb); + assert_eq!(va, vb, "depth {depth}: filter mask"); + for (a, b) in after.transform.m.iter().zip(before.transform.m) { + assert!((a - b).abs() < 1e-9, "depth {depth}: transform {:?} vs {:?}", after.transform, before.transform); + } + // The nested document came back (as the embedded PSB), layer for layer. + let (name, src) = source_bytes(&back.metadata, &after.source).unwrap(); + assert!(name.ends_with(".psb"), "{name}"); + let inner = decode_source(&name, &src).unwrap(); + assert_eq!((inner.size, inner.layers.len(), inner.depth), (nested.size, nested.layers.len(), nested.depth)); + assert!(max_diff(&photocraft_compose::flatten(&inner).px, &photocraft_compose::flatten(&nested).px) <= 1.0 / 255.0 + 1e-6); + // And it re-renders from that source like the original did. + let mut l = back.layer(photocraft_doc::LayerId(bid)).unwrap().clone(); + assert!(refresh_layer(&back, &mut l).unwrap()); + let mut rerendered = back.clone(); + *rerendered.layer_mut(photocraft_doc::LayerId(bid)).unwrap() = l; + let d = max_diff(&photocraft_compose::flatten(&rerendered).px, &photocraft_compose::flatten(&doc).px); + assert!(d <= 2.0 / 255.0, "depth {depth}: re-render differs by {d}"); + + // Saving the re-imported file again keeps the same embedded file (no growth). + let again = photocraft_io::export(&back, "psd", &Default::default()).unwrap(); + assert_eq!(structure_errors(&again.bytes), Vec::::new()); + assert!(again.bytes.len() <= out.bytes.len() + 64, "{} vs {}", again.bytes.len(), out.bytes.len()); + } +} + +/// Every colour model: the smart object, its filters and the filter cache's unfiltered pixels +/// (in the document's model, CMYK inverted and 16-bit Lab scaled as in layer channels) survive. +#[test] +fn smart_filters_survive_psd_in_every_colour_model() { + for (mode, depth) in [("cmyk", 8), ("cmyk", 16), ("grayscale", 8), ("lab", 8), ("lab", 16)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 24, "mode": mode, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 4, "y": 4, "width": 20, "height": 12})).unwrap(); + s.execute("edit.fill", json!({"color": "#c83c28"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 1.5})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let out = photocraft_io::export(&doc, "psd", &Default::default()).unwrap(); + assert!(!out.warnings.iter().any(|w| w.contains("smart")), "{mode} {depth}: {:?}", out.warnings); + assert_eq!(structure_errors(&out.bytes), Vec::::new()); + let back = photocraft_io::import("x.psd", &out.bytes).unwrap().document; + let ((_, a), (_, b)) = (smart(&doc), smart(&back)); + assert_eq!(b.smart_filters.len(), 1, "{mode} {depth}"); + assert_eq!(b.smart_filters[0].command, a.smart_filters[0].command); + // The cache's unfiltered planes decode back to the unfiltered rendering. + let f = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + let fx = f.global_blocks.iter().find(|g| &g.key == b"FEid").map(|g| photocraft_psd::filter_effects::FilterEffects::parse(&g.data).unwrap()).unwrap(); + let item = &fx.items[0]; + let (w, h) = item.rect.size().unwrap(); + let bits = u16::try_from(item.depth).unwrap(); + let first = item.slots[0].as_ref().unwrap().decode(w, h, bits).unwrap(); + let mut unfiltered = a.clone(); + unfiltered.smart_filters.clear(); + let px = photocraft_engine::smart_cmds::render(&doc, &unfiltered).unwrap().unwrap(); + let (x, y) = (10 - item.rect.left, 8 - item.rect.top); + let at = (y as usize * w + x as usize) * usize::from(bits / 8); + let stored = if bits == 8 { f32::from(first[at]) / 255.0 } else { f32::from(u16::from_be_bytes([first[at], first[at + 1]])) / 65535.0 }; + let v = px.sample_channel(10, 8, 0); + let expect = if mode == "cmyk" { 1.0 - v } else { v }; + assert!((stored - expect).abs() < 0.01, "{mode} {depth}: slot 0 holds {stored}, expected {expect}"); + } +} + +/// Writes sample files for checking in Photoshop (`PHOTOCRAFT_SMART_PSD_OUT`): per depth, a +/// plain layer, a smart object without filters, and one with filters. +#[test] +#[ignore = "writes files for a manual Photoshop check"] +fn photoshop_samples() { + for (mode, depth) in [("rgb", 8), ("rgb", 16), ("rgb", 32), ("cmyk", 8), ("lab", 16), ("grayscale", 8)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H, "mode": mode, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 8, "width": 50, "height": 40})).unwrap(); + s.execute("edit.fill", json!({"color": "#c83c28"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let tag = format!("{mode}{depth}"); + keep(&format!("plain-{tag}.psd"), &photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap().bytes); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + keep(&format!("nofilter-{tag}.psd"), &photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap().bytes); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + keep(&format!("gauss-{tag}.psd"), &photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap().bytes); + } +} + +/// PSD export of a 24 MP smart object versus the same layer as pixels (release: +/// `cargo test --release -p photocraft-engine --test smart_psd -- --ignored --nocapture perf`). +#[test] +#[ignore = "benchmark"] +fn perf_24mp_smart_object_export() { + let (w, h) = (6000, 4000); + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "depth": 8})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let fmt = surf.format(); + let r = Rect::new(0, 0, w, h); + let mut data = Vec::with_capacity((w * h * 4) as usize); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + // Photo-like: smooth gradients plus a little texture (RLE can't collapse it). + let v = (x as f32 / w as f32) * 0.7 + (((x * 31 + y * 17) % 29) as f32 / 29.0) * 0.1; + data.extend(photocraft_raster::from_rgba(&fmt, [v, (y as f32 / h as f32) * 0.8, 1.0 - v, 1.0])); + } + } + surf.write_region(r, &data); + Ok(()) + }) + .unwrap(); + let time = |doc: &Document| { + let t = std::time::Instant::now(); + let out = photocraft_io::export(doc, "psd", &Default::default()).unwrap(); + (t.elapsed().as_secs_f64(), out.bytes.len(), out.warnings) + }; + let (tf, sf, _) = time(&s.active().unwrap().doc); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let (ts, ss, ws) = time(&s.active().unwrap().doc); + assert!(ws.is_empty(), "{ws:?}"); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + let (tg, sg, wg) = time(&s.active().unwrap().doc); + assert!(wg.is_empty(), "{wg:?}"); + let mb = |b: usize| b as f64 / 1e6; + eprintln!( + "24 MP PSD export: pixels {tf:.2} s {:.1} MB | smart object {ts:.2} s {:.1} MB | + Gaussian Blur smart filter {tg:.2} s {:.1} MB", + mb(sf), + mb(ss), + mb(sg) + ); + // The embedded file is about one more copy of the layer; the filter cache one more again. + assert!(ss < sf * 5 / 2, "smart object {ss} vs pixels {sf}"); + assert!(sg < sf * 4, "with filters {sg} vs pixels {sf}"); +} + +#[test] +fn edited_psd_smart_objects_drop_their_old_file() { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let first = photocraft_io::export(&doc, "psd", &Default::default()).unwrap().bytes; + // Re-open, edit the contents (a new embedded file), save: one embedded file, the new one. + let mut s2 = Session::new(); + s2.add_document(photocraft_io::import("a.psd", &first).unwrap().document, None); + let (id, _) = smart(&s2.active().unwrap().doc); + s2.select_layer(photocraft_doc::LayerId(id)).unwrap(); + s2.execute("layer.smartObjects.editContents", json!({})).unwrap(); + s2.execute("image.adjustments.invert", json!({})).unwrap(); + s2.execute("layer.smartObjects.saveContents", json!({})).unwrap(); + let parent = s2.documents().iter().position(|d| d.doc.layer(photocraft_doc::LayerId(id)).is_some()).unwrap(); + let doc2 = s2.documents()[parent].doc.clone(); + let second = photocraft_io::export(&doc2, "psd", &Default::default()).unwrap().bytes; + let f = photocraft_psd::PsdFile::from_bytes(&second).unwrap(); + let uuids: Vec = f.global_blocks.iter().filter(|b| &b.key == b"lnk2").flat_map(|b| photocraft_io::linked::block_uuids(&b.data)).collect(); + let back = photocraft_io::import("b.psd", &second).unwrap().document; + let (_, sm) = smart(&back); + let SmartSource::Linked { path } = &sm.source else { panic!() }; + assert_eq!(uuids, vec![path.clone()]); +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 341cd95c28..10e380434a 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -20,6 +20,7 @@ photocraft-cms = { workspace = true } photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-compose = { workspace = true } +photocraft-algo = { workspace = true } photocraft-psd = { workspace = true } photocraft-codecs = { workspace = true } photocraft-raw = { workspace = true } @@ -28,6 +29,8 @@ photocraft-paint = { workspace = true } photocraft-vector = { workspace = true } photocraft-format = { path = "../format" } thiserror = { workspace = true } +serde_json = { workspace = true } +blake3 = { version = "1.8", default-features = false, features = ["pure"] } [dev-dependencies] photocraft-psd = { workspace = true, features = ["testgen"] } diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index e0ec5b33fc..b2cfb64022 100644 --- a/crates/io/src/effects_map.rs +++ b/crates/io/src/effects_map.rs @@ -70,7 +70,7 @@ const BLEND_LONG_NAMES: [(BlendMode, &str); 17] = [ ]; /// A blend-mode enum from either its four-character code or its long string ID. -fn blend_from_id(v: &[u8]) -> Option { +pub(crate) fn blend_from_id(v: &[u8]) -> Option { BLEND_NAMES.iter().chain(&BLEND_LONG_NAMES).find(|(_, n)| n.as_bytes() == v).map(|(m, _)| *m) } @@ -78,6 +78,13 @@ fn blend_of(d: &Descriptor, key: &str, default: BlendMode) -> BlendMode { enum_of(d, key).and_then(blend_from_id).unwrap_or(default) } +/// A `BlnM` enum with the long string ID where the mode has one (`multiply`, `overlay`), as +/// Photoshop writes smart-filter blend options; the four-character code otherwise. +pub(crate) fn blend_long_value(m: BlendMode) -> Value { + let n = BLEND_LONG_NAMES.iter().chain(&BLEND_NAMES).find(|(b, _)| *b == m).map_or("normal", |(_, n)| n); + Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(n) } +} + fn blend_value(m: BlendMode) -> Value { let n = BLEND_NAMES.iter().find(|(b, _)| *b == m).map_or("Nrml", |(_, n)| n); Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(n) } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index fa15b77718..576d8a32e0 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -38,6 +38,7 @@ mod psd_export; mod psd_import; pub mod raw; pub mod slices_map; +pub mod smart_map; pub mod text_styles_map; pub mod vector_map; diff --git a/crates/io/src/linked.rs b/crates/io/src/linked.rs index 7f569ca19b..fbc8f0d880 100644 --- a/crates/io/src/linked.rs +++ b/crates/io/src/linked.rs @@ -94,23 +94,42 @@ pub fn find_linked_file(meta: &Metadata, uuid: &str) -> Option { .find(|f| f.uuid == uuid) } -/// Encodes one `liFD` item (version 2, no open descriptor). Used by tests and writers that -/// need a minimal linked-layer block. +/// The four-character file type Photoshop records for an embedded file, from its contents. +pub fn file_type(bytes: &[u8]) -> [u8; 4] { + match bytes { + [b'8', b'B', b'P', b'S', 0, 2, ..] => *b"8BPB", + [b'8', b'B', b'P', b'S', ..] => *b"8BPS", + [0x89, b'P', b'N', b'G', ..] => *b"PNGf", + [0xFF, 0xD8, ..] => *b"JPEG", + [b'I', b'I', 42, 0, ..] | [b'M', b'M', 0, 42, ..] => *b"TIFF", + [b'G', b'I', b'F', ..] => *b"GIFf", + _ => *b" ", + } +} + +/// Encodes one `liFD` item (version 7: no open descriptor, empty child document id, no +/// modification time, unlocked), length-prefixed and padded to 4 bytes. pub fn encode_linked_file(f: &LinkedFile) -> Vec { let mut item = Vec::new(); item.extend_from_slice(b"liFD"); - item.extend_from_slice(&2u32.to_be_bytes()); - item.push(f.uuid.len().min(255) as u8); - item.extend_from_slice(&f.uuid.as_bytes()[..f.uuid.len().min(255)]); - let units: Vec = f.file_name.encode_utf16().collect(); + item.extend_from_slice(&7u32.to_be_bytes()); + let id = f.uuid.as_bytes().get(..f.uuid.len().min(255)).unwrap_or_default(); + item.push(id.len() as u8); + item.extend_from_slice(id); + // NUL-terminated, the terminator counted (as Photoshop writes and reads it). + let units: Vec = f.file_name.encode_utf16().chain(std::iter::once(0)).collect(); item.extend_from_slice(&(units.len() as u32).to_be_bytes()); for u in units { item.extend_from_slice(&u.to_be_bytes()); } - item.extend_from_slice(b" 8BIM"); + item.extend_from_slice(&file_type(&f.bytes)); + item.extend_from_slice(b"8BIM"); item.extend_from_slice(&(f.bytes.len() as u64).to_be_bytes()); item.push(0); item.extend_from_slice(&f.bytes); + item.extend_from_slice(&0u32.to_be_bytes()); // child document id (version 5) + item.extend_from_slice(&0f64.to_be_bytes()); // asset modification time (version 6) + item.push(0); // asset locked (version 7) let mut out = (item.len() as u64).to_be_bytes().to_vec(); out.extend_from_slice(&item); while !out.len().is_multiple_of(4) { @@ -119,6 +138,56 @@ pub fn encode_linked_file(f: &LinkedFile) -> Vec { out } +/// The uuid of one linked-layer item (any type), if its header is readable. +fn item_uuid(item: &[u8]) -> Option { + let n = usize::from(*item.get(8)?); + Some(String::from_utf8_lossy(item.get(9..9 + n)?).into_owned()) +} + +/// The items of a linked-layer block: each item's uuid (when readable) and its bytes including +/// the length prefix and padding. A malformed tail is returned as one unnamed piece. +fn split_items(data: &[u8]) -> Vec<(Option, &[u8])> { + let mut out = Vec::new(); + let mut at = 0usize; + while let Some(rest) = data.get(at..).filter(|r| !r.is_empty()) { + let len = rest.get(..8).map(|b| u64::from_be_bytes(b.try_into().unwrap_or([0; 8]))); + let end = len.and_then(|l| usize::try_from(l).ok()).and_then(|l| l.checked_add(8)).filter(|&e| e <= rest.len()); + let (Some(end), Some(item)) = (end, end.and_then(|e| rest.get(8..e))) else { + out.push((None, rest)); + break; + }; + let padded = end.next_multiple_of(4).min(rest.len()); + out.push((item_uuid(item), rest.get(..padded).unwrap_or(rest))); + at += padded; + } + out +} + +/// Rewrites a linked-layer block: keeps the items `keep` accepts (and anything unreadable), then +/// appends `add`. `None` when nothing would change. +pub fn rebuild_block(data: &[u8], keep: &dyn Fn(&str) -> bool, add: &[LinkedFile]) -> Option> { + let items = split_items(data); + let dropped = items.iter().any(|(u, _)| u.as_deref().is_some_and(|u| !keep(u))); + if !dropped && add.is_empty() { + return None; + } + let mut out = Vec::with_capacity(data.len()); + for (u, bytes) in items { + if u.as_deref().is_none_or(keep) { + out.extend_from_slice(bytes); + } + } + for f in add { + out.extend(encode_linked_file(f)); + } + Some(out) +} + +/// The uuids of every item in a linked-layer block. +pub fn block_uuids(data: &[u8]) -> Vec { + split_items(data).into_iter().filter_map(|(u, _)| u).collect() +} + #[cfg(test)] mod tests { use super::*; @@ -138,6 +207,27 @@ mod tests { assert_eq!(find_linked_file(&meta, ""), None); } + #[test] + fn blocks_are_pruned_and_extended() { + let a = LinkedFile { uuid: "a".into(), file_name: "a.png".into(), bytes: vec![0x89, b'P', b'N', b'G', 1] }; + let b = LinkedFile { uuid: "b".into(), file_name: "b.psb".into(), bytes: b"8BPS\0\x02rest".to_vec() }; + let c = LinkedFile { uuid: "c".into(), file_name: "c".into(), bytes: vec![7; 3] }; + let mut data = encode_linked_file(&a); + data.extend(encode_linked_file(&b)); + assert_eq!(block_uuids(&data), vec!["a", "b"]); + assert_eq!(rebuild_block(&data, &|_| true, &[]), None); + let out = rebuild_block(&data, &|u| u != "a", std::slice::from_ref(&c)).unwrap(); + assert_eq!(parse_linked_files(&out), vec![b.clone(), c.clone()]); + photocraft_psd::TaggedBlock::new(*b"lnk2", out).check_structure().unwrap(); + assert_eq!(file_type(&b.bytes), *b"8BPB"); + assert_eq!(file_type(&a.bytes), *b"PNGf"); + // A malformed tail is kept as is. + let mut bad = encode_linked_file(&a); + bad.extend([0, 0, 0, 0, 0, 0, 1, 0, 9]); + let out = rebuild_block(&bad, &|_| true, std::slice::from_ref(&c)).unwrap(); + assert!(out.starts_with(&bad)); + } + #[test] fn malformed_data_is_ignored() { assert!(parse_linked_files(&[0, 0, 0, 0, 0, 0, 0, 200, 1, 2]).is_empty()); diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 8b184189b2..fcf947ec0d 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -42,6 +42,166 @@ struct Ex { guides: photocraft_doc::Guides, /// Layer comps to regenerate into each layer's `cmls`; None = preserved data still matches. comps: Option<(Vec, Option)>, + /// Smart objects: embedded files and filter caches for the global blocks. + smart: SmartOut, +} + +/// How deep embedded documents are exported inside each other before a smart object is written +/// as pixels instead. +const MAX_NESTING: u32 = 8; + +/// An embedded smart-object source as written to `lnk2`. +#[derive(Clone, Debug)] +struct Source { + uuid: String, + /// Pixel size of the source document. + size: (f64, f64), + dpi: f64, +} + +/// Smart-object state collected while emitting layers, written into the global blocks at the end. +#[derive(Default)] +struct SmartOut { + /// Nesting depth of this export (embedded documents are exported recursively). + depth: u32, + /// The document's preserved global blocks (embedded files and filter caches from PSD import). + globals: Vec, + /// uuids of the files in the preserved linked-layer blocks. + known: std::collections::HashSet, + /// uuids the layers reference. + used: std::collections::HashSet, + /// Unreferenced preserved files may be dropped (every smart layer's file id is known). + prune_ok: bool, + /// Files to embed. + add: Vec, + /// Sources converted so far, by content hash (`Err` = can't be embedded). + converted: std::collections::HashMap<[u8; 32], Result>, + /// Embedded documents decoded while converting them, by uuid (for the filter caches). + docs: std::collections::HashMap, + /// Source composites (document pixel format, source space) by uuid; `None` = unreadable. + composites: std::collections::HashMap, photocraft_geom::Rect)>>, + /// Preserved `FEid` items (by placed id). + old_fx: Vec, + /// Placed ids whose preserved `FEid` item stays. + keep_fx: std::collections::HashSet, + /// Regenerated `FEid` items. + new_fx: Vec, +} + +/// `FEid` data padded to 4 bytes inside the block length: Photoshop counts the padding in the +/// length and can't open a file whose `FEid` is padded outside it. +fn padded_fx(fx: &photocraft_psd::filter_effects::FilterEffects) -> Vec { + let mut d = fx.to_bytes(); + d.resize(d.len().next_multiple_of(4), 0); + d +} + +const LINK_KEYS: [&[u8; 4]; 4] = [b"lnk2", b"lnk3", b"lnkD", b"lnkE"]; +const FX_KEYS: [&[u8; 4]; 2] = [b"FEid", b"FXid"]; + +impl SmartOut { + fn new(doc: &Document, depth: u32) -> Self { + let globals = doc.metadata.psd_global_blocks.clone(); + let known = globals.iter().filter(|(_, k, _)| LINK_KEYS.contains(&k)).flat_map(|(_, _, d)| crate::linked::block_uuids(d)).collect(); + let old_fx = globals + .iter() + .filter(|(_, k, _)| FX_KEYS.contains(&k)) + .filter_map(|(_, _, d)| photocraft_psd::filter_effects::FilterEffects::parse(d).ok()) + .flat_map(|fx| fx.items) + .collect(); + SmartOut { depth, globals, known, prune_ok: true, old_fx, ..Default::default() } + } + + /// The document's global blocks with the linked-layer files and filter caches brought up to + /// date: unreferenced files dropped (when every reference is known), new files appended, + /// filter caches kept or regenerated per smart object. Unchanged blocks stay byte-identical. + fn finish(&self, blocks: Vec) -> Vec { + use std::sync::Arc; + let keep = |u: &str| !self.prune_ok || self.used.contains(u); + let mut out = Vec::with_capacity(blocks.len() + 2); + let mut added = false; + let mut fx_placed = false; + for (sig, key, data) in blocks { + if LINK_KEYS.contains(&&key) { + let add: &[crate::linked::LinkedFile] = if !added && &key == b"lnk2" { &self.add } else { &[] }; + added |= &key == b"lnk2"; + match crate::linked::rebuild_block(&data, &keep, add) { + None => out.push((sig, key, data)), + Some(d) if d.is_empty() => {} + Some(d) => out.push((sig, key, Arc::new(d))), + } + } else if FX_KEYS.contains(&&key) { + let Ok(mut fx) = photocraft_psd::filter_effects::FilterEffects::parse(&data) else { + out.push((sig, key, data)); + continue; + }; + let before = fx.items.len(); + fx.items.retain(|i| self.keep_fx.contains(&i.id)); + let fresh = !fx_placed && !self.new_fx.is_empty(); + if fresh { + fx.items.extend(self.new_fx.iter().cloned()); + fx_placed = true; + } + if fx.items.len() == before && !fresh { + out.push((sig, key, data)); + } else if !fx.items.is_empty() { + out.push((sig, key, Arc::new(padded_fx(&fx)))); + } + } else { + out.push((sig, key, data)); + } + } + if !added && !self.add.is_empty() { + let data: Vec = self.add.iter().flat_map(crate::linked::encode_linked_file).collect(); + out.push((*b"8BIM", *b"lnk2", Arc::new(data))); + } + if !fx_placed && !self.new_fx.is_empty() { + let fx = photocraft_psd::filter_effects::FilterEffects { version: 3, items: self.new_fx.clone() }; + out.push((*b"8BIM", *b"FEid", Arc::new(padded_fx(&fx)))); + if !out.iter().any(|(_, k, _)| k == b"FMsk") { + // Filter mask overlay: RGB red at 50 %, Photoshop's default. + out.push((*b"8BIM", *b"FMsk", Arc::new(vec![0, 0, 255, 255, 0, 0, 0, 0, 0, 0, 0, 50]))); + } + } + out + } +} + +/// Size and resolution of a smart-object source file (PSD/PSB header, `.pcraft`, or any image). +fn source_geometry(name: &str, bytes: &[u8]) -> Result<((f64, f64), f64), String> { + if crate::is_psd(bytes) { + let rd = |at: usize| bytes.get(at..at + 4).map(|b| f64::from(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))); + let (h, w) = rd(14).zip(rd(18)).ok_or("truncated PSD header")?; + return Ok(((w, h), 72.0)); + } + let doc = crate::import(name, bytes).map_err(|e| e.to_string())?.document; + Ok(((f64::from(doc.size.width), f64::from(doc.size.height)), f64::from(doc.resolution_dpi))) +} + +/// The source size of a smart object whose file we can't decode: its rendered bounds taken back +/// through the inverse transform (the source's top-left is the origin). +fn size_from_layer(sm: &photocraft_doc::SmartObject) -> (f64, f64) { + let r = sm.cache.as_ref().map(Surface::content_bounds).filter(|r| !r.is_empty()); + let (Some(r), Some(inv)) = (r, sm.transform.inverse()) else { return (1.0, 1.0) }; + let [a, b, c, d, e, f] = inv.m; + let (mut w, mut h) = (1.0f64, 1.0f64); + for (x, y) in [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)] { + let (x, y) = (f64::from(x), f64::from(y)); + w = w.max((a * x + c * y + e).round()); + h = h.max((b * x + d * y + f).round()); + } + (w, h) +} + +fn upsert(raw: &mut Vec<([u8; 4], Vec)>, key: &[u8; 4], data: Vec, before: Option<&[u8; 4]>) { + if let Some(e) = raw.iter_mut().find(|(k, _)| k == key) { + e.1 = data; + return; + } + match before.and_then(|b| raw.iter().position(|(k, _)| k == b)) { + Some(i) => raw.insert(i, (*key, data)), + None => raw.push((*key, data)), + } } fn psd_mode(m: ColorMode) -> PsdMode { @@ -287,9 +447,13 @@ impl Ex { } } LayerContent::Smart(sm) => { - set_principal(&mut raw, smart_keys(sm.psd_raw.as_deref().map(Vec::as_slice)), sm.psd_raw.as_ref()); - if !raw.iter().any(|(k, _)| matches!(k, b"SoLd" | b"PlLd" | b"SoLE")) { - self.warnings.push(format!("layer \"{}\": smart object written as pixels", l.name)); + let parsed = sm.psd_raw.as_deref().and_then(|d| crate::smart_map::parse_sold(d)); + if sm.psd_raw.is_some() && parsed.is_none() { + // Placed-layer data we don't parse (`PlLd` only): kept verbatim. + self.smart.prune_ok = false; + set_principal(&mut raw, smart_keys(sm.psd_raw.as_deref().map(Vec::as_slice)), sm.psd_raw.as_ref()); + } else { + self.smart_blocks(l, sm, parsed, &mut raw); } } LayerContent::Raster(_) | LayerContent::Group(_) => {} @@ -343,6 +507,203 @@ impl Ex { } } + /// Placed-layer blocks (`PlLd` + `SoLd`) of a smart object, its embedded file (`lnk2`) and + /// filter cache (`FEid`). An imported placed layer that nothing changed is written verbatim; + /// otherwise the blocks are regenerated (from the imported descriptor where the source is + /// the same). Without a source that can be embedded the layer is written as pixels. + fn smart_blocks(&mut self, l: &Layer, sm: &photocraft_doc::SmartObject, template: Option, raw: &mut Vec<([u8; 4], Vec)>) { + use crate::smart_map::{FilterStack, PlacedSpec, filter_fx, plld_bytes, sold_bytes, uuid_from}; + let src = match self.smart_source(sm, template.as_ref()) { + Ok(s) => s, + Err(e) => { + raw.retain(|(k, _)| !matches!(k, b"SoLd" | b"PlLd" | b"SoLE")); + self.warnings.push(format!("layer \"{}\": smart object written as pixels ({e})", l.name)); + return; + } + }; + self.smart.used.insert(src.uuid.clone()); + let stack = FilterStack { + enabled: sm.filters_enabled, + filters: sm.smart_filters.clone(), + mask_enabled: sm.filter_mask.as_ref().is_none_or(|m| m.enabled), + mask_linked: sm.filter_mask.as_ref().is_some_and(|m| m.linked), + }; + let same_source = template.as_ref().filter(|t| t.idnt == src.uuid); + if let (Some(t), Some(data)) = (same_source, sm.psd_raw.as_ref()) + && self.placed_unchanged(t, data, sm, &stack) + { + let keys: &[&[u8; 4]] = if raw.iter().any(|(k, _)| k == b"SoLd") { &[b"SoLd"] } else { &[b"SoLE", b"SoLd"] }; + match keys.iter().find_map(|k| raw.iter().position(|(rk, _)| rk == *k)) { + Some(i) => raw[i].1 = data.to_vec(), + None => raw.push((*b"SoLd", data.to_vec())), + } + if !stack.filters.is_empty() { + self.smart.keep_fx.insert(t.placed.clone()); + } + return; + } + let placed = match same_source { + Some(t) if !t.placed.is_empty() => t.placed.clone(), + _ => uuid_from(format!("{}:{}", src.uuid, l.id.0).as_bytes()), + }; + let size = same_source.and_then(|t| crate::smart_map::stored_size(&t.descriptor)).unwrap_or(src.size); + let size = if size.0 > 0.0 && size.1 > 0.0 { size } else { size_from_layer(sm) }; + let mut warnings = Vec::new(); + let fx = (!stack.filters.is_empty()).then(|| filter_fx(&stack, &l.name, &mut warnings)); + let spec = PlacedSpec { idnt: &src.uuid, placed: &placed, transform: sm.transform, size, dpi: src.dpi, warp: sm.warp.as_ref(), filter_fx: fx }; + let sold = sold_bytes(same_source.map(|t| &t.descriptor), &spec, &mut warnings); + let plld = plld_bytes(&spec, &mut warnings); + warnings.dedup(); + let key = if same_source.is_some() && raw.iter().any(|(k, _)| k == b"SoLE") && !raw.iter().any(|(k, _)| k == b"SoLd") { *b"SoLE" } else { *b"SoLd" }; + raw.retain(|(k, _)| k == &key || !matches!(k, b"SoLd" | b"SoLE")); + upsert(raw, b"PlLd", plld, Some(&key)); + upsert(raw, &key, sold, None); + if !stack.filters.is_empty() { + let bounds = sm.cache.as_ref().map_or(self.canvas, |c| c.content_bounds().union(&self.canvas)); + let item = match (sm.stack_mode, self.source_composite(&src.uuid)) { + (None, Some((img, img_bounds))) => { + let unfiltered = photocraft_algo::warp::place_source(&img, img_bounds, &sm.transform, sm.warp.as_ref()); + crate::smart_map::feid_item(&placed, &unfiltered, sm.filter_mask.as_ref(), bounds, self.fmt) + } + _ => None, + }; + match item { + Some(i) => self.smart.new_fx.push(i), + // Without the unfiltered pixels Photoshop re-renders from the source itself; only + // the filter mask has nowhere to go. + None if sm.filter_mask.is_some() => self.warnings.push(format!("layer \"{}\": the smart filter mask was not written to PSD", l.name)), + None => {} + } + } + self.warnings.extend(warnings.into_iter().map(|w| if w.starts_with("layer ") { w } else { format!("layer \"{}\": {w}", l.name) })); + } + + /// Whether an imported placed layer still says what the smart object is: transform, warp, + /// filter stack and filter mask as stored. + fn placed_unchanged(&self, t: &crate::smart_map::Placed, data: &[u8], sm: &photocraft_doc::SmartObject, stack: &crate::smart_map::FilterStack) -> bool { + if t.transform != sm.transform || blocks::parse_placed_warp(b"SoLd", data) != sm.warp || t.stack.clone().unwrap_or_default() != *stack { + return false; + } + if stack.filters.is_empty() { + return true; + } + let old = self.smart.old_fx.iter().find(|i| i.id == t.placed); + let mask = old.map(|i| crate::smart_map::mask_from_item(i, self.mask_fmt.sample, stack)); + match mask { + Some(Ok(m)) => m == sm.filter_mask, + Some(Err(_)) => false, + None => sm.filter_mask.is_none(), + } + } + + /// The `lnk2` file a smart object's source is written as: a preserved embedded file, or a new + /// one (a `.pcraft` source becomes a PSB of the nested document; image files are embedded as + /// they are; a linked file is read and embedded). + fn smart_source(&mut self, sm: &photocraft_doc::SmartObject, template: Option<&crate::smart_map::Placed>) -> Result { + use photocraft_doc::SmartSource; + match &sm.source { + // A file of the imported document, or one its placed-layer data names (a missing + // link stays a smart object, as in Photoshop). + SmartSource::Linked { path } if self.smart.known.contains(path) || template.is_some_and(|t| t.idnt == *path) => { + let stored = template.filter(|t| t.idnt == *path).and_then(|t| crate::smart_map::stored_size(&t.descriptor)); + let geometry = stored.map(|size| (size, f64::from(self.dpi))).or_else(|| { + let meta = photocraft_doc::Metadata { psd_global_blocks: self.smart.globals.clone(), ..Default::default() }; + let f = crate::linked::find_linked_file(&meta, path)?; + source_geometry(&f.file_name, &f.bytes).ok() + }); + let (size, dpi) = geometry.unwrap_or((size_from_layer(sm), f64::from(self.dpi))); + Ok(Source { uuid: path.clone(), size, dpi }) + } + SmartSource::Linked { path } => { + #[cfg(not(target_arch = "wasm32"))] + { + let bytes = std::fs::read(path).map_err(|e| format!("the linked file {path} can't be read: {e}"))?; + let name = path.rsplit(['/', '\\']).next().unwrap_or(path).to_string(); + self.warnings.push(format!("the linked smart object {name} was embedded (PSD export keeps no external links)")); + self.embed(&name, &bytes) + } + #[cfg(target_arch = "wasm32")] + Err(format!("the linked file {path} can't be read here")) + } + SmartSource::Embedded { file_name, bytes } => self.embed(file_name, bytes), + } + } + + /// The composite of the source `uuid` (an embedded file of this export or of the imported + /// document) in the document's pixel format, its top-left at the origin; decoded once. + fn source_composite(&mut self, uuid: &str) -> Option<(std::sync::Arc, photocraft_geom::Rect)> { + if let Some(c) = self.smart.composites.get(uuid) { + return c.clone(); + } + let doc = match self.smart.docs.get(uuid) { + Some(d) => Some(d.clone()), + None => { + let file = self.smart.add.iter().find(|f| f.uuid == uuid).cloned().or_else(|| { + let meta = photocraft_doc::Metadata { psd_global_blocks: self.smart.globals.clone(), ..Default::default() }; + crate::linked::find_linked_file(&meta, uuid) + }); + file.and_then(|f| crate::import(&f.file_name, &f.bytes).ok()).map(|r| r.document) + } + }; + let c = doc.map(|d| { + let mut s = Surface::new(self.fmt); + let fmt = self.fmt; + let _ = photocraft_compose::render_bands(&d, d.bounds(), 0, |band| -> Result<(), ()> { + let mut vals = Vec::with_capacity(band.px.len() * fmt.channels()); + let mut v = [0.0f32; 5]; + for p in &band.px { + let n = photocraft_raster::from_rgba_into(&fmt, *p, &mut v); + vals.extend_from_slice(&v[..n]); + } + s.write_region(band.rect, &vals); + Ok(()) + }); + s.prune(); + (std::sync::Arc::new(s), d.bounds()) + }); + self.smart.composites.insert(uuid.to_string(), c.clone()); + c + } + + /// Adds `bytes` as an embedded file (converted to PSB when it is a `.pcraft` bundle), once + /// per distinct content. + fn embed(&mut self, file_name: &str, bytes: &[u8]) -> Result { + let key = *blake3::hash(bytes).as_bytes(); + if let Some(r) = self.smart.converted.get(&key) { + return r.clone(); + } + let r = self.convert_source(file_name, bytes); + self.smart.converted.insert(key, r.clone()); + r + } + + fn convert_source(&mut self, file_name: &str, bytes: &[u8]) -> Result { + let (name, data, size, dpi, nested) = if photocraft_format::is_pcraft(bytes) { + if self.smart.depth >= MAX_NESTING { + return Err(format!("smart objects nest more than {MAX_NESTING} deep")); + } + let doc = photocraft_format::load_from_bytes(bytes).map_err(|e| format!("its contents can't be read: {e}"))?; + let (file, warnings) = document_to_psd_nested(&doc, &PsdExportOptions { force_psb: true }, self.smart.depth + 1); + let data = file.to_bytes().map_err(|e| format!("its contents can't be written: {e}"))?; + let stem = file_name.rsplit_once('.').map_or(file_name, |(a, _)| a); + self.warnings.extend(warnings.into_iter().map(|w| format!("smart object {stem}: {w}"))); + let size = (f64::from(doc.size.width), f64::from(doc.size.height)); + (format!("{stem}.psb"), data, size, f64::from(doc.resolution_dpi), Some(doc)) + } else { + // Formats we can't decode (vector PDF/AI…) are still embedded; their size comes from the layer. + let (size, dpi) = source_geometry(file_name, bytes).unwrap_or(((0.0, 0.0), f64::from(self.dpi))); + (file_name.to_string(), bytes.to_vec(), size, dpi, None) + }; + let uuid = crate::smart_map::uuid_from(&data); + if let Some(d) = nested { + self.smart.docs.insert(uuid.clone(), d); + } + if !self.smart.known.contains(&uuid) && !self.smart.add.iter().any(|f| f.uuid == uuid) { + self.smart.add.push(crate::linked::LinkedFile { uuid: uuid.clone(), file_name: name, bytes: data }); + } + Ok(Source { uuid, size, dpi }) + } + /// Pixels for a fill layer: Photoshop's cached rendering while valid, /// otherwise our own rendering of the fill over the canvas. fn fill_pixels(&self, l: &Layer, f: &photocraft_doc::Fill) -> Surface { @@ -578,6 +939,11 @@ pub fn document_to_psd(doc: &Document) -> PsdFile { /// anything that could not be represented. The merged composite is rendered /// with `photocraft_compose::flatten`. pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile, Vec) { + document_to_psd_nested(doc, opts, 0) +} + +/// [`document_to_psd_with`] for a document embedded `depth` smart objects deep. +fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32) -> (PsdFile, Vec) { if doc.mode == ColorMode::Multichannel { return crate::multichannel_map::document_to_psd(doc, opts.force_psb); } @@ -601,6 +967,7 @@ pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile warnings: Vec::new(), guides: doc.guides.clone(), comps: (!crate::comps_map::comps_unchanged(doc)).then(|| (doc.layer_comps.clone(), doc.last_document_state.clone())), + smart: SmartOut::new(doc, depth), }; if big && !opts.force_psb { ex.warnings.push("document exceeds 30000 px; written as PSB".into()); @@ -746,7 +1113,7 @@ pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile resources.push(ImageResource::new(ids::EXIF, e.to_vec())); } let mut global_blocks = Vec::new(); - for (sig, key, data) in &crate::annotations_map::export_blocks(doc, crate::pattern_map::export_global_blocks(doc)) { + for (sig, key, data) in &ex.smart.finish(crate::annotations_map::export_blocks(doc, crate::pattern_map::export_global_blocks(doc))) { let mut tb = TaggedBlock::new(*key, data.to_vec()); tb.signature = *sig; // Photoshop pads document-level (global) blocks to a multiple of 4, and readers such as diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index ecfdd21724..031234457e 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -53,6 +53,8 @@ pub(crate) struct Ctx<'a> { pub dpi: f32, /// The parsed `Txt2` block (type settings EngineData lacks, e.g. optical kerning). pub txt2: Option, + /// Smart-filter caches from the global `FEid`/`FXid` blocks (filter masks, by placed id). + pub filter_effects: Vec, } fn doc_mode(m: PsdMode) -> Option { @@ -163,6 +165,20 @@ impl Ctx<'_> { }) } + /// The filter mask of the smart object whose `placed` id is `placed`, from the `FEid` cache: + /// document coordinates, white beyond its bounds (`filterMaskExtendWithWhite`). An all-white + /// mask is no mask. + fn filter_mask(&mut self, placed: &str, stack: &crate::smart_map::FilterStack, name: &str) -> Option { + let item = self.filter_effects.iter().find(|i| i.id == placed)?; + match crate::smart_map::mask_from_item(item, self.mask_fmt.sample, stack) { + Ok(m) => m, + Err(e) => { + self.warn(format!("layer \"{name}\": {e}; the filter mask was ignored")); + None + } + } + } + fn apply_common(&mut self, l: &mut Layer, rec: &LayerRecord, blend_override: Option) { l.visible = rec.is_visible(); l.opacity = f32::from(rec.opacity) / 255.0; @@ -261,14 +277,21 @@ impl Ctx<'_> { LayerContent::Text(t) } else if let Some(k) = smart_key { let (id, transform) = rec.block(k).map(|b| blocks::parse_smart(k, &b.data)).unwrap_or_default(); + // Smart filters (`filterFX` in the placed-layer data) and their mask (`FEid`). + let placed = rec.block(b"SoLd").or_else(|| rec.block(b"SoLE")).and_then(|b| crate::smart_map::parse_sold(&b.data)); + let stack = placed.as_ref().and_then(|p| p.stack.clone()).unwrap_or_default(); + let filter_mask = match &placed { + Some(p) if !stack.filters.is_empty() => self.filter_mask(&p.placed, &stack, &name), + _ => None, + }; LayerContent::Smart(SmartObject { source: SmartSource::Linked { path: id }, transform, - smart_filters: Vec::new(), + smart_filters: stack.filters, cache: Some(self.record_surface(rec, &name)), psd_raw: principal(k), - filters_enabled: true, - filter_mask: None, + filters_enabled: stack.enabled, + filter_mask, warp: rec.block(k).and_then(|b| blocks::parse_placed_warp(k, &b.data)), stack_mode: None, }) @@ -496,7 +519,14 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { warnings, dpi: doc.resolution_dpi, txt2: file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)), + filter_effects: Vec::new(), }; + for b in file.global_blocks.iter().filter(|b| matches!(&b.key, b"FEid" | b"FXid")) { + match photocraft_psd::filter_effects::FilterEffects::parse(&b.data) { + Ok(fx) => cx.filter_effects.extend(fx.items), + Err(e) => cx.warn(format!("smart filter masks ({}) could not be read: {e}", b.key_str())), + } + } let (w, hh) = (h.width as usize, h.height as usize); let n = w * hh; diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs new file mode 100644 index 0000000000..b88aa9404a --- /dev/null +++ b/crates/io/src/smart_map.rs @@ -0,0 +1,973 @@ +//! Smart objects in PSD: the placed-layer blocks (`SoLd`, `PlLd`) and the smart filter stack +//! (`filterFX` inside `SoLd`). +//! +//! Layouts follow the Adobe PSD spec ("Placed Layer", "Placed Layer Data") and Photoshop-authored +//! files (`corpus/photoshop/smart-filters`): a `soLD` descriptor holds the linked-file id (`Idnt`, +//! the uuid of the `lnk2` item), the instance id (`placed`, which keys the `FEid` filter cache), +//! the transform quad (`Trnf`), the source size (`Sz `), the warp, and when filters are applied a +//! `filterFX` object: +//! +//! ```text +//! filterFX (filterFXStyle): enab, validAtPosition, filterMaskEnable, filterMaskLinked, +//! filterMaskExtendWithWhite, filterFXList = [ filterFX: Nm, blendOptions {Opct, Md}, +//! enab, hasoptions, FrgC, BckC, Fltr (the filter's own descriptor), filterID ] +//! ``` +//! +//! The list is in application order (first = bottom, applied first), like +//! [`SmartObject::smart_filters`](photocraft_doc::SmartObject). Filters PhotoCraft implements map +//! to their command id and dialog parameters; any other filter becomes a +//! [`UNSUPPORTED_FILTER`] entry that keeps its descriptor verbatim (hex in `params.psd`), so it is +//! listed, can be hidden, reordered or deleted, and is written back unchanged. + +use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{LayerMask, SmartFilter}; +use photocraft_geom::warp::{Warp, WarpStyle}; +use photocraft_geom::{Affine, Rect as GeomRect}; +use photocraft_psd::Compression; +use photocraft_psd::Rect as PsdRect; +use photocraft_psd::descriptor::{Class, Descriptor, Id, ObjectArray, ReferenceItem, UnicodeString, Value, VersionedDescriptor}; +use photocraft_psd::filter_effects::{EffectsPlane, FilterEffectsItem}; +use photocraft_raster::Surface; +use serde_json::{Map, Value as J, json}; + +use crate::blocks::{enum_of, get_desc, num}; + +/// Command id of a Photoshop smart filter PhotoCraft does not implement. Its params hold the +/// filter's name, Photoshop filter id and descriptor (`psd`, hex); it renders as a pass-through. +pub const UNSUPPORTED_FILTER: &str = "psd.unsupportedFilter"; + +/// The smart filter stack of a placed layer, as stored in `filterFX`. +#[derive(Clone, Debug, PartialEq)] +pub struct FilterStack { + /// `enab`: Layer › Smart Filter › Disable Smart Filters clears it. + pub enabled: bool, + pub filters: Vec, + /// `filterMaskEnable`. + pub mask_enabled: bool, + /// `filterMaskLinked`. + pub mask_linked: bool, +} + +impl Default for FilterStack { + fn default() -> Self { + FilterStack { enabled: true, filters: Vec::new(), mask_enabled: true, mask_linked: false } + } +} + +/// What a `soLD` descriptor says about a smart object. +#[derive(Clone, Debug)] +pub struct Placed { + pub descriptor: Descriptor, + /// `Idnt`: uuid of the embedded or linked file (`lnk2` item). + pub idnt: String, + /// `placed`: id of this instance (keys the `FEid` filter cache). + pub placed: String, + /// Source pixels → document pixels (from `Trnf` and `Sz `). + pub transform: Affine, + /// `filterFX`, if any filters were ever applied. + pub stack: Option, +} + +fn text_of(d: &Descriptor, key: &str) -> Option { + match d.get(key)? { + Value::Text(t) => Some(t.to_string_lossy()), + _ => None, + } +} + +fn bool_or(d: &Descriptor, key: &str, default: bool) -> bool { + match d.get(key) { + Some(Value::Boolean(b)) => *b, + _ => default, + } +} + +/// The `soLD` descriptor of `SoLd`/`SoLE` block data. +pub fn sold_descriptor(data: &[u8]) -> Option { + if data.get(..4)? != b"soLD" { + return None; + } + crate::blocks::parse_prefix_versioned(data.get(8..)?) +} + +/// Parses `SoLd`/`SoLE` block data (best effort: `None` when it is not a `soLD` structure). +pub fn parse_sold(data: &[u8]) -> Option { + let d = sold_descriptor(data)?; + let (idnt, transform) = crate::blocks::parse_smart(b"SoLd", data); + let placed = text_of(&d, "placed").unwrap_or_default(); + let stack = get_desc(&d, "filterFX").map(filter_stack); + Some(Placed { descriptor: d, idnt, placed, transform, stack }) +} + +/// Reads a `filterFX` object. +pub fn filter_stack(fx: &Descriptor) -> FilterStack { + let filters = match fx.get("filterFXList") { + Some(Value::List(items)) => items + .iter() + .filter_map(|v| match v { + Value::Descriptor(d) => Some(filter_from_item(d)), + _ => None, + }) + .collect(), + _ => Vec::new(), + }; + FilterStack { + enabled: bool_or(fx, "enab", true), + filters, + mask_enabled: bool_or(fx, "filterMaskEnable", true), + mask_linked: bool_or(fx, "filterMaskLinked", false), + } +} + +// ---------- the filters we model ---------- + +/// Photoshop filter → PhotoCraft command, keyed by the `Fltr` class (and `filterID`). +struct Known { + class: &'static str, + filter_id: i32, + /// `Nm ` as Photoshop writes it (the menu item, with its ellipsis and mnemonic). + name: &'static str, + command: &'static str, +} + +const fn code(c: &[u8; 4]) -> i32 { + i32::from_be_bytes(*c) +} + +const KNOWN: [Known; 14] = [ + Known { class: "GsnB", filter_id: code(b"GsnB"), name: "Gaussian Blur...", command: "filter.blur.gaussianBlur" }, + Known { class: "boxblur", filter_id: 840, name: "Box Blur...", command: "filter.blur.boxBlur" }, + Known { class: "MtnB", filter_id: code(b"MtnB"), name: "Motion Blur...", command: "filter.blur.motionBlur" }, + Known { class: "UnsM", filter_id: code(b"UnsM"), name: "Unsharp Mask...", command: "filter.sharpen.unsharpMask" }, + Known { class: "AdNs", filter_id: code(b"AdNs"), name: "Add Noise...", command: "filter.noise.addNoise" }, + Known { class: "Mdn ", filter_id: code(b"Mdn "), name: "Median...", command: "filter.noise.median" }, + Known { class: "HghP", filter_id: code(b"HghP"), name: "High Pass...", command: "filter.other.highPass" }, + Known { class: "Mxm ", filter_id: code(b"Mxm "), name: "Maximum...", command: "filter.other.maximum" }, + Known { class: "Mnm ", filter_id: code(b"Mnm "), name: "Minimum...", command: "filter.other.minimum" }, + Known { class: "Msc ", filter_id: code(b"Msc "), name: "Mosaic...", command: "filter.pixelate.mosaic" }, + Known { class: "Embs", filter_id: code(b"Embs"), name: "Emboss...", command: "filter.stylize.emboss" }, + Known { class: "Lvls", filter_id: code(b"Lvls"), name: "Levels...", command: "image.adjustments.levels" }, + Known { class: "Crvs", filter_id: code(b"Crvs"), name: "Curves...", command: "image.adjustments.curves" }, + // Shadows/Highlights at its defaults stores no `Fltr` (`hasoptions` false). + Known { class: "adaptCorrect", filter_id: 781, name: "Shado&ws/Highlights...", command: "image.adjustments.shadowsHighlights" }, +]; + +fn known_by_command(command: &str) -> Option<&'static Known> { + KNOWN.iter().find(|k| k.command == command) +} + +/// RGB channel references in Levels/Curves (`Chnl` enum) and their parameter keys ("" = the +/// composite: top-level keys). +const CHANNELS: [(&[u8; 4], &str); 4] = [(b"Cmps", ""), (b"Rd ", "red"), (b"Grn ", "green"), (b"Bl ", "blue")]; + +fn px(d: &Descriptor, key: &str) -> Option { + num(d.get(key)).filter(|v| v.is_finite()) +} + +fn int(d: &Descriptor, key: &str) -> Option { + match d.get(key)? { + Value::Integer(i) => Some(*i), + _ => None, + } +} + +fn channel_key(d: &Descriptor) -> Option<&'static str> { + let Some(Value::Reference(items)) = d.get("Chnl") else { return None }; + let Some(ReferenceItem::Enumerated { value, .. }) = items.first() else { return None }; + CHANNELS.iter().find(|(c, _)| value.as_bytes() == &c[..]).map(|(_, k)| *k) +} + +fn adjustment_list(d: &Descriptor) -> Option> { + let Some(Value::List(items)) = d.get("Adjs") else { return None }; + items + .iter() + .map(|v| match v { + Value::Descriptor(x) => Some(x), + _ => None, + }) + .collect() +} + +fn levels_params(f: &Descriptor) -> Option { + let mut out = Map::new(); + for a in adjustment_list(f)? { + let key = channel_key(a)?; + let mut o = Map::new(); + let pair = |k: &str| match a.get(k) { + Some(Value::List(v)) if v.len() == 2 => Some((int_value(&v[0])?, int_value(&v[1])?)), + _ => None, + }; + if let Some((lo, hi)) = pair("Inpt") { + o.insert("inBlack".into(), json!(lo)); + o.insert("inWhite".into(), json!(hi)); + } + if let Some(g) = px(a, "Gmm ") { + o.insert("gamma".into(), json!(g)); + } + if let Some((lo, hi)) = pair("Otpt") { + o.insert("outBlack".into(), json!(lo)); + o.insert("outWhite".into(), json!(hi)); + } + if key.is_empty() { + out.extend(o); + } else { + out.insert(key.into(), J::Object(o)); + } + } + Some(J::Object(out)) +} + +fn int_value(v: &Value) -> Option { + match v { + Value::Integer(i) => Some(*i), + _ => None, + } +} + +fn curves_params(f: &Descriptor) -> Option { + let mut out = Map::new(); + for a in adjustment_list(f)? { + let key = channel_key(a)?; + let Some(Value::List(pts)) = a.get("Crv ") else { return None }; + let pts: Option> = pts + .iter() + .map(|p| match p { + Value::Descriptor(p) => Some(json!([px(p, "Hrzn")?, px(p, "Vrtc")?])), + _ => None, + }) + .collect(); + out.insert(if key.is_empty() { "points".into() } else { key.into() }, J::Array(pts?)); + } + Some(J::Object(out)) +} + +/// Our command and parameters for a known Photoshop filter; `None` when the filter (or one of its +/// settings) is not modelled. +fn known_params(class: &str, f: Option<&Descriptor>) -> Option<(&'static str, J)> { + let k = KNOWN.iter().find(|k| k.class == class)?; + let params = match (class, f) { + ("adaptCorrect", None) => json!({}), + (_, None) => return None, + ("GsnB" | "boxblur" | "Mdn " | "HghP" | "Mxm " | "Mnm ", Some(f)) => json!({"radius": px(f, "Rds ")?}), + ("MtnB", Some(f)) => json!({"angle": int(f, "Angl")?, "distance": px(f, "Dstn")?}), + ("UnsM", Some(f)) => json!({"amount": px(f, "Amnt")?, "radius": px(f, "Rds ")?, "threshold": int(f, "Thsh")?}), + ("AdNs", Some(f)) => { + let dist = match enum_of(f, "Dstr")? { + b"Unfr" => "uniform", + b"Gsn " => "gaussian", + _ => return None, + }; + let mono = match f.get("Mnch") { + Some(Value::Boolean(b)) => *b, + _ => return None, + }; + json!({"amount": px(f, "Nose")?, "distribution": dist, "monochromatic": mono, "seed": int(f, "FlRs").unwrap_or(0)}) + } + ("Msc ", Some(f)) => json!({"cellSize": px(f, "ClSz")?}), + ("Embs", Some(f)) => json!({"angle": int(f, "Angl")?, "height": int(f, "Hght")?, "amount": int(f, "Amnt")?}), + ("Lvls", Some(f)) => levels_params(f)?, + ("Crvs", Some(f)) => curves_params(f)?, + _ => return None, + }; + Some((k.command, params)) +} + +/// Lowercase hex of `bytes`. +fn to_hex(bytes: &[u8]) -> String { + const H: &[u8; 16] = b"0123456789abcdef"; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push(char::from(H[usize::from(b >> 4)])); + s.push(char::from(H[usize::from(b & 15)])); + } + s +} + +fn from_hex(s: &str) -> Option> { + let d = |c: u8| (c as char).to_digit(16).map(|v| v as u8); + let b = s.as_bytes(); + if !b.len().is_multiple_of(2) { + return None; + } + b.as_chunks::<2>().0.iter().map(|c| Some(d(c[0])? << 4 | d(c[1])?)).collect() +} + +/// One `filterFXList` item → [`SmartFilter`] (an [`UNSUPPORTED_FILTER`] when not modelled). +pub fn filter_from_item(item: &Descriptor) -> SmartFilter { + let opts = get_desc(item, "blendOptions"); + let opacity = opts.and_then(|o| px(o, "Opct")).map_or(1.0, |v| (v / 100.0).clamp(0.0, 1.0) as f32); + let blend = opts.and_then(|o| enum_of(o, "Md ")).and_then(crate::effects_map::blend_from_id).unwrap_or(BlendMode::Normal); + let visible = bool_or(item, "enab", true); + let fltr = get_desc(item, "Fltr"); + let class = fltr.map(|f| String::from_utf8_lossy(f.class_id.as_bytes()).into_owned()); + let filter_id = int(item, "filterID").unwrap_or(0); + let class = class.or_else(|| KNOWN.iter().find(|k| k.filter_id == filter_id).map(|k| k.class.to_string())); + let (command, params) = match class.as_deref().and_then(|c| known_params(c, fltr)) { + Some((c, p)) => (c.to_string(), p), + None => { + let name = text_of(item, "Nm ").unwrap_or_default(); + // The stored descriptor carries neutral blending (the model's own fields replace + // them on export), so hiding or re-blending the filter leaves its params alone. + let mut neutral = item.clone(); + for (k, v) in &mut neutral.items { + if k.is("blendOptions") { + *v = blend_options(&SmartFilter { command: String::new(), params: J::Null, blend: BlendMode::Normal, opacity: 1.0, visible: true }); + } else if k.is("enab") { + *v = Value::Boolean(true); + } + } + (UNSUPPORTED_FILTER.to_string(), json!({"name": name.replace('&', ""), "filterID": filter_id, "psd": to_hex(&neutral.to_bytes())})) + } + }; + SmartFilter { command, params, blend, opacity, visible } +} + +// ---------- writing ---------- + +fn unit(u: &[u8; 4], v: f64) -> Value { + Value::UnitFloat { unit: *u, value: v } +} + +fn rgbc(v: f64) -> Value { + Value::Descriptor(Descriptor::new("RGBC").with("Rd ", Value::Double(v)).with("Grn ", Value::Double(v)).with("Bl ", Value::Double(v))) +} + +fn blend_options(f: &SmartFilter) -> Value { + let opacity = if f.opacity.is_finite() { f64::from(f.opacity.clamp(0.0, 1.0)) * 100.0 } else { 100.0 }; + Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", opacity)).with("Md ", crate::effects_map::blend_long_value(f.blend))) +} + +/// Parameter keys that are bookkeeping, not filter settings. +fn is_meta(k: &str) -> bool { + k == "layer" || k.starts_with("__") +} + +fn pf(p: &J, key: &str, default: f64) -> f64 { + p.get(key).and_then(J::as_f64).filter(|v| v.is_finite()).unwrap_or(default) +} + +fn pi(p: &J, key: &str, default: f64) -> Value { + Value::Integer(pf(p, key, default).round().clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32) +} + +fn channel_ref(code: &[u8; 4]) -> Value { + Value::Reference(vec![ReferenceItem::Enumerated { + class: Class { name: UnicodeString::new_nul(""), class_id: Id::new("Chnl") }, + type_id: Id::new("Chnl"), + value: Id::Code(*code), + }]) +} + +/// The `Adjs` entries of a Levels or Curves filter: the composite first, then red, green, blue. +fn adjustments(p: &J, curves: bool) -> Result { + let obj = p.as_object().ok_or("parameters are not an object")?; + let allowed: &[&str] = + if curves { &["points", "red", "green", "blue"] } else { &["inBlack", "inWhite", "gamma", "outBlack", "outWhite", "red", "green", "blue"] }; + if let Some(k) = obj.keys().find(|k| !is_meta(k) && !allowed.contains(&k.as_str())) { + return Err(format!("setting `{k}` has no Photoshop equivalent")); + } + let mut list = Vec::new(); + for (code, key) in CHANNELS { + let o = if key.is_empty() { Some(p) } else { p.get(key) }; + let Some(o) = o else { continue }; + let mut a = Descriptor::new(if curves { "CrvA" } else { "LvlA" }).with("Chnl", channel_ref(code)); + if curves { + let src = if key.is_empty() { p.get("points") } else { Some(o) }; + let Some(pts) = src.and_then(J::as_array) else { continue }; + let pts: Option> = pts + .iter() + .map(|q| { + let x = q.get(0)?.as_f64().filter(|v| v.is_finite())?; + let y = q.get(1)?.as_f64().filter(|v| v.is_finite())?; + Some(Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", Value::Double(x)).with("Vrtc", Value::Double(y)))) + }) + .collect(); + a = a.with("Crv ", Value::List(pts.ok_or("a curve point is not [input, output]")?)); + } else { + if key.is_empty() && !["inBlack", "inWhite", "gamma", "outBlack", "outWhite"].iter().any(|k| o.get(k).is_some()) { + continue; + } + a = a.with("Inpt", Value::List(vec![pi(o, "inBlack", 0.0), pi(o, "inWhite", 255.0)])).with("Gmm ", Value::Double(pf(o, "gamma", 1.0))); + if o.get("outBlack").is_some() || o.get("outWhite").is_some() { + a = a.with("Otpt", Value::List(vec![pi(o, "outBlack", 0.0), pi(o, "outWhite", 255.0)])); + } + } + list.push(Value::Descriptor(a)); + } + Ok(Value::List(list)) +} + +/// The `Fltr` descriptor of a modelled filter (`None` = written without options). +fn fltr_for(k: &Known, p: &J) -> Result, String> { + let d = Descriptor::new(k.class); + Ok(Some(match k.class { + "GsnB" | "boxblur" | "Mdn " | "HghP" | "Mxm " | "Mnm " => { + if p.get("preserve").and_then(J::as_str).is_some_and(|s| s == "roundness") { + return Err("“preserve roundness” has no Photoshop equivalent".into()); + } + d.with("Rds ", unit(b"#Pxl", pf(p, "radius", 1.0))) + } + "MtnB" => d.with("Angl", pi(p, "angle", 0.0)).with("Dstn", unit(b"#Pxl", pf(p, "distance", 10.0))), + "UnsM" => d.with("Amnt", unit(b"#Prc", pf(p, "amount", 50.0))).with("Rds ", unit(b"#Pxl", pf(p, "radius", 1.0))).with("Thsh", pi(p, "threshold", 0.0)), + "AdNs" => { + let gauss = p.get("distribution").and_then(J::as_str) == Some("gaussian"); + d.with("Dstr", Value::Enumerated { type_id: Id::new("Dstr"), value: Id::new(if gauss { "Gsn " } else { "Unfr" }) }) + .with("Nose", unit(b"#Prc", pf(p, "amount", 12.5))) + .with("Mnch", Value::Boolean(p.get("monochromatic").and_then(J::as_bool).unwrap_or(false))) + .with("FlRs", pi(p, "seed", 0.0)) + } + "Msc " => d.with("ClSz", unit(b"#Pxl", pf(p, "cellSize", 10.0))), + "Embs" => d.with("Angl", pi(p, "angle", 135.0)).with("Hght", pi(p, "height", 3.0)).with("Amnt", pi(p, "amount", 100.0)), + "Lvls" => d.with("Adjs", adjustments(p, false)?), + "Crvs" => d.with("Adjs", adjustments(p, true)?), + "adaptCorrect" => { + if p.as_object().is_some_and(|o| o.keys().any(|k| !is_meta(k))) { + return Err("only default Shadows/Highlights settings are written to PSD".into()); + } + return Ok(None); + } + _ => return Err("not a Photoshop filter".into()), + })) +} + +/// The `filterFXList` item for one smart filter, or why it can't be written. +pub fn item_for_filter(f: &SmartFilter) -> Result { + if f.command == UNSUPPORTED_FILTER { + let raw = f.params.get("psd").and_then(J::as_str).and_then(from_hex).ok_or("its Photoshop data is missing")?; + let mut d = Descriptor::from_bytes(&raw).map_err(|e| format!("its Photoshop data is unreadable ({e})"))?; + set(&mut d, "blendOptions", blend_options(f)); + set(&mut d, "enab", Value::Boolean(f.visible)); + return Ok(d); + } + let k = known_by_command(&f.command).ok_or("Photoshop has no equivalent filter")?; + let fltr = fltr_for(k, &f.params)?; + let mut d = Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul(k.name))) + .with("blendOptions", blend_options(f)) + .with("enab", Value::Boolean(f.visible)) + .with("hasoptions", Value::Boolean(fltr.is_some())) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)); + if let Some(x) = fltr { + d = d.with("Fltr", Value::Descriptor(x)); + } + Ok(d.with("filterID", Value::Integer(k.filter_id))) +} + +/// Builds a `filterFX` object; filters that can't be written are left out, with a message each. +pub fn filter_fx(stack: &FilterStack, layer: &str, warnings: &mut Vec) -> Descriptor { + let mut list = Vec::new(); + for f in &stack.filters { + match item_for_filter(f) { + Ok(d) => list.push(Value::Descriptor(d)), + Err(e) => warnings.push(format!("layer \"{layer}\": smart filter {} was not written to PSD: {e}", f.command)), + } + } + Descriptor::new("filterFXStyle") + .with("enab", Value::Boolean(stack.enabled)) + .with("validAtPosition", Value::Boolean(true)) + .with("filterMaskEnable", Value::Boolean(stack.mask_enabled)) + .with("filterMaskLinked", Value::Boolean(stack.mask_linked)) + .with("filterMaskExtendWithWhite", Value::Boolean(true)) + .with("filterFXList", Value::List(list)) +} + +/// Replaces item `key` in place, or appends it. +fn set(d: &mut Descriptor, key: &str, v: Value) { + match d.items.iter_mut().find(|(k, _)| k.is(key)) { + Some(e) => e.1 = v, + None => d.items.push((Id::new(key), v)), + } +} + +/// Everything needed to write the placed-layer blocks of a smart object. +#[derive(Clone, Debug)] +pub struct PlacedSpec<'a> { + pub idnt: &'a str, + pub placed: &'a str, + /// Source pixels → document pixels. + pub transform: Affine, + /// Source size in pixels. + pub size: (f64, f64), + /// Source resolution. + pub dpi: f64, + pub warp: Option<&'a Warp>, + /// `filterFX`, when the smart object has filters. + pub filter_fx: Option, +} + +/// The transform quad: the source corners (top-left, top-right, bottom-right, bottom-left). +fn quad(t: &Affine, (w, h): (f64, f64)) -> Value { + let pts = [(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)]; + Value::List( + pts.iter() + .flat_map(|&(x, y)| { + let [a, b, c, d, e, f] = t.m; + [Value::Double(a * x + c * y + e), Value::Double(b * x + d * y + f)] + }) + .collect(), + ) +} + +fn enumv(t: &str, v: &str) -> Value { + Value::Enumerated { type_id: Id::new(t), value: Id::new(v) } +} + +/// The `warp` descriptor (also the `PlLd` warp). Multi-patch meshes have no 1 × 1 envelope and +/// are written as no warp, with a message. +fn warp_desc(w: Option<&Warp>, size: (f64, f64), warnings: &mut Vec) -> Value { + let none = Warp::none([0.0, 0.0, size.0, size.1]); + let mut w = w.unwrap_or(&none).clone(); + let mesh = match (&w.style, &w.mesh) { + (WarpStyle::Custom, Some(m)) if m.points.len() == 16 => Some(m.clone()), + (WarpStyle::Custom, _) => { + warnings.push("a custom warp with more than one patch was written to PSD without its warp".into()); + w = none.clone(); + None + } + _ => None, + }; + let [x0, y0, x1, y1] = w.bounds; + let bounds = Descriptor::new("classFloatRect") + .with("Top ", Value::Double(y0)) + .with("Left", Value::Double(x0)) + .with("Btom", Value::Double(y1)) + .with("Rght", Value::Double(x1)); + let mut d = Descriptor::new("warp") + .with("warpStyle", enumv("warpStyle", w.style.psd_name())) + .with("warpValue", Value::Double(w.bend)) + .with("warpPerspective", Value::Double(w.h_distort)) + .with("warpPerspectiveOther", Value::Double(w.v_distort)) + .with("warpRotate", enumv("Ornt", if w.vertical { "Vrtc" } else { "Hrzn" })) + .with("bounds", Value::Descriptor(bounds)) + .with("uOrder", Value::Integer(4)) + .with("vOrder", Value::Integer(4)); + if let Some(m) = mesh { + let body = Descriptor::new("rationalPoint") + .with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: m.points.iter().map(|p| p[0]).collect() }) + .with("Vrtc", Value::UnitFloats { unit: *b"#Pxl", values: m.points.iter().map(|p| p[1]).collect() }); + d = d.with( + "customEnvelopeWarp", + Value::Descriptor(Descriptor::new("customEnvelopeWarp").with("meshPoints", Value::ObjectArray(ObjectArray { prefix: 16, body }))), + ); + } + Value::Descriptor(d) +} + +fn ratio(n: i32, d: i32) -> Value { + Value::Descriptor(Descriptor::new("null").with("numerator", Value::Integer(n)).with("denominator", Value::Integer(d))) +} + +fn pad4(mut v: Vec) -> Vec { + while !v.len().is_multiple_of(4) { + v.push(0); + } + v +} + +/// `SoLd` block data. With `template` (the imported descriptor) every key it has is kept and only +/// the transform (when it moved), warp and filter stack are rewritten; otherwise a fresh +/// descriptor in Photoshop's key order. +pub fn sold_bytes(template: Option<&Descriptor>, s: &PlacedSpec<'_>, warnings: &mut Vec) -> Vec { + let warp = warp_desc(s.warp, s.size, warnings); + let d = match template { + Some(t) => { + let mut d = t.clone(); + let moved = parse_transform(t).is_none_or(|old| old != s.transform); + if moved { + set(&mut d, "Trnf", quad(&s.transform, s.size)); + set(&mut d, "nonAffineTransform", quad(&s.transform, s.size)); + } + set(&mut d, "warp", warp); + match &s.filter_fx { + Some(fx) => set(&mut d, "filterFX", Value::Descriptor(fx.clone())), + None => d.items.retain(|(k, _)| !k.is("filterFX")), + } + d + } + None => { + let mut d = Descriptor::new("null") + .with("Idnt", Value::Text(UnicodeString::new_nul(s.idnt))) + .with("placed", Value::Text(UnicodeString::new_nul(s.placed))) + .with("PgNm", Value::Integer(1)) + .with("totalPages", Value::Integer(1)) + .with("Crop", Value::Integer(1)) + .with("frameStep", ratio(0, 600)) + .with("duration", ratio(0, 600)) + .with("frameCount", Value::Integer(1)) + .with("Annt", Value::Integer(16)) + .with("Type", Value::Integer(2)) + .with("Trnf", quad(&s.transform, s.size)) + .with("nonAffineTransform", quad(&s.transform, s.size)) + .with("warp", warp) + .with("Sz ", Value::Descriptor(Descriptor::new("Pnt ").with("Wdth", Value::Double(s.size.0)).with("Hght", Value::Double(s.size.1)))) + .with("Rslt", unit(b"#Rsl", s.dpi)); + if let Some(fx) = &s.filter_fx { + d = d.with("filterFX", Value::Descriptor(fx.clone())); + } + d.with("comp", Value::Integer(-1)) + .with("compInfo", Value::Descriptor(Descriptor::new("null").with("compID", Value::Integer(-1)).with("originalCompID", Value::Integer(-1)))) + } + }; + let mut out = b"soLD".to_vec(); + out.extend_from_slice(&4u32.to_be_bytes()); + out.extend(VersionedDescriptor::new(d).to_bytes()); + pad4(out) +} + +/// `PlLd` block data (`plcL` version 3): the legacy placed-layer record Photoshop writes next to +/// `SoLd`. +pub fn plld_bytes(s: &PlacedSpec<'_>, warnings: &mut Vec) -> Vec { + let mut out = b"plcL".to_vec(); + out.extend_from_slice(&3u32.to_be_bytes()); + let id = s.idnt.as_bytes(); + let n = id.len().min(255); + out.push(n as u8); + out.extend_from_slice(&id[..n]); + for v in [1i32, 1, 16, 2] { + out.extend_from_slice(&v.to_be_bytes()); + } + if let Value::List(q) = quad(&s.transform, s.size) { + for v in q { + if let Value::Double(x) = v { + out.extend_from_slice(&x.to_be_bytes()); + } + } + } + out.extend_from_slice(&0u32.to_be_bytes()); + let Value::Descriptor(w) = warp_desc(s.warp, s.size, warnings) else { return pad4(out) }; + out.extend(VersionedDescriptor::new(w).to_bytes()); + pad4(out) +} + +/// The transform a `soLD` descriptor stores (as [`crate::blocks::parse_smart`] reads it). +fn parse_transform(d: &Descriptor) -> Option { + let Some(Value::List(pts)) = d.get("Trnf") else { return None }; + let sz = get_desc(d, "Sz ")?; + let p: Vec = pts.iter().filter_map(|v| num(Some(v))).collect(); + let (w, h) = (num(sz.get("Wdth"))?, num(sz.get("Hght"))?); + (p.len() == 8 && w > 0.0 && h > 0.0).then(|| Affine { m: [(p[2] - p[0]) / w, (p[3] - p[1]) / w, (p[6] - p[0]) / h, (p[7] - p[1]) / h, p[0], p[1]] }) +} + +/// The source size a `soLD` descriptor stores (`Sz `). +pub fn stored_size(d: &Descriptor) -> Option<(f64, f64)> { + let sz = get_desc(d, "Sz ")?; + Some((num(sz.get("Wdth"))?, num(sz.get("Hght"))?)).filter(|(w, h)| *w > 0.0 && *h > 0.0) +} + +// ---------- the filter mask (`FEid`) ---------- + +/// The filter mask stored in an `FEid` item, in document coordinates and white beyond its bounds +/// (`filterMaskExtendWithWhite`), as a grayscale mask of `sample` depth. `Ok(None)` when the +/// item has no mask or it is all white (no mask); `Err` when it can't be decoded. +pub fn mask_from_item(item: &FilterEffectsItem, sample: SampleType, stack: &FilterStack) -> Result, String> { + if item.mask.is_none() { + return Ok(None); + } + let (rect, plane) = item.decoded_mask().ok_or("the smart filter mask could not be decoded")?; + if item.depth != u32::from(crate::pixels::psd_depth(sample)) { + return Err(format!("the smart filter mask is {}-bit in a {:?} document", item.depth, sample)); + } + let (w, h) = rect.size().map_err(|e| e.to_string())?; + let fmt = PixelFormat::new(ColorMode::Grayscale, sample, false); + let mut surface = Surface::with_default(fmt, &[1.0]); + let n = w.checked_mul(h).ok_or("the smart filter mask is too large")?; + let bytes = crate::pixels::interleave(&[Some(&plane)], &[crate::pixels::zero_sample(sample)], n, sample, &[false]); + surface.write_interleaved(GeomRect::new(rect.left, rect.top, rect.right, rect.bottom), &bytes); + surface.prune(); + if surface.content_bounds().is_empty() { + return Ok(None); + } + Ok(Some(LayerMask { surface, enabled: stack.mask_enabled, linked: stack.mask_linked, density: 1.0, feather: 0.0 })) +} + +/// An `FEid` item for a smart object with filters: the unfiltered placed pixels Photoshop +/// filters from (colour channels from slot 0, transparency in the last slot; in the document's +/// pixel format, CMYK inverted as in layer channels) and the filter mask over `bounds` (white +/// when `mask` is `None`). Photoshop needs the planes: re-rendering the filters of an item +/// without them crashes it. `None` when there are no unfiltered pixels. +pub fn feid_item(placed: &str, unfiltered: &Surface, mask: Option<&LayerMask>, bounds: GeomRect, doc_fmt: PixelFormat) -> Option { + let sample = doc_fmt.sample; + let px = if unfiltered.format() == doc_fmt { unfiltered.clone() } else { unfiltered.convert(doc_fmt) }; + let content = px.content_bounds(); + if content.is_empty() { + return None; + } + // Over the whole area the filters may draw into: Photoshop clips the filtered result to these + // planes, so the content bounds alone would cut blurs off at the source's edge. + let r = content.union(&bounds); + let cc = doc_fmt.mode.color_channels(); + let depth = crate::pixels::psd_depth(sample); + let compression = if sample == SampleType::F32 { Compression::ZipPrediction } else { Compression::Rle }; + let encode = |plane: &[u8], w: usize, h: usize| { + EffectsPlane::encode(compression, plane, w, h, depth) + .or_else(|_| EffectsPlane::encode(Compression::Raw, plane, w, h, depth)) + .unwrap_or(EffectsPlane { compression: Compression::Raw, data: plane.to_vec() }) + }; + let mut invert = vec![doc_fmt.mode == ColorMode::Cmyk; cc]; + invert.push(false); + // Fully transparent pixels carry white, as in Photoshop's own cache: filters that look at + // colour regardless of alpha (Shadows/Highlights) read it. + let mut vals = px.read_region(r); + let white = photocraft_raster::from_rgba(&doc_fmt, [1.0, 1.0, 1.0, 1.0]); + for p in vals.chunks_exact_mut(cc + 1) { + if p[cc] <= 0.0 { + p[..cc].copy_from_slice(&white[..cc]); + } + } + let mut filled = Surface::new(doc_fmt); + filled.write_region(r, &vals); + drop(vals); + let mut bytes = filled.to_interleaved(r); + if doc_fmt.mode == ColorMode::Lab && sample == SampleType::U16 { + // 16-bit Lab a*/b* in Photoshop's scale, as in layer channels. + crate::pixels::lab16_chroma(&mut bytes, cc + 1, false); + } + let planes = crate::pixels::deinterleave(&bytes, cc + 1, sample, &invert); + let (w, h) = (r.width() as usize, r.height() as usize); + let mut slots = vec![None; 26]; + for (i, p) in planes.iter().enumerate() { + let slot = if i == cc { 25 } else { i }; + if let Some(s) = slots.get_mut(slot) { + *s = Some(encode(p, w, h)); + } + } + let mut item = filter_mask_item(placed, mask, bounds, sample); + item.rect = PsdRect { top: r.y0, left: r.x0, bottom: r.y1, right: r.x1 }; + item.slots = slots; + Some(item) +} + +fn filter_mask_item(placed: &str, mask: Option<&LayerMask>, bounds: GeomRect, sample: SampleType) -> FilterEffectsItem { + let fmt = PixelFormat::new(ColorMode::Grayscale, sample, false); + let area = match mask { + Some(m) => m.surface.content_bounds().union(&bounds), + None => bounds, + }; + let area = if area.is_empty() { GeomRect::new(0, 0, 1, 1) } else { area }; + let rect = PsdRect { top: area.y0, left: area.x0, bottom: area.y1, right: area.x1 }; + let (w, h) = (area.width() as usize, area.height() as usize); + let plane = match mask { + Some(m) => { + let s = if m.surface.format() == fmt { m.surface.clone() } else { m.surface.convert(fmt) }; + crate::pixels::deinterleave(&s.to_interleaved(area), 1, sample, &[false]).into_iter().next().unwrap_or_default() + } + None => { + let mut white = Vec::with_capacity(w * h * sample.bytes()); + for _ in 0..w * h { + crate::pixels::encode_be(1.0, sample, &mut white); + } + white + } + }; + let depth = crate::pixels::psd_depth(sample); + let compression = if sample == SampleType::F32 { Compression::ZipPrediction } else { Compression::Rle }; + let plane = EffectsPlane::encode(compression, &plane, w, h, depth) + .or_else(|_| EffectsPlane::encode(Compression::Raw, &plane, w, h, depth)) + .unwrap_or(EffectsPlane { compression: Compression::Raw, data: plane }); + FilterEffectsItem { id: placed.to_string(), version: 1, rect, depth: u32::from(depth), max_channels: 24, slots: vec![None; 26], mask: Some((rect, plane)) } +} + +/// A uuid-shaped id (8-4-4-4-12 hex digits) derived from `seed`: equal contents get equal ids, so +/// identical smart objects share one embedded file, as Photoshop's instances do. +pub fn uuid_from(seed: &[u8]) -> String { + let h = blake3::hash(seed); + let x = to_hex(&h.as_bytes()[..16]); + format!("{}-{}-{}-{}-{}", &x[..8], &x[8..12], &x[12..16], &x[16..20], &x[20..32]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sf(command: &str, params: J) -> SmartFilter { + SmartFilter { command: command.into(), params, blend: BlendMode::Normal, opacity: 1.0, visible: true } + } + + #[test] + fn modelled_filters_round_trip() { + let filters = vec![ + sf("filter.blur.gaussianBlur", json!({"radius": 4.0})), + sf("filter.blur.boxBlur", json!({"radius": 5.0})), + sf("filter.blur.motionBlur", json!({"angle": -20, "distance": 10.0})), + sf("filter.sharpen.unsharpMask", json!({"amount": 150.0, "radius": 2.0, "threshold": 0})), + sf("filter.noise.addNoise", json!({"amount": 12.0, "distribution": "uniform", "monochromatic": false, "seed": 1234})), + sf("filter.noise.median", json!({"radius": 3.0})), + sf("filter.other.highPass", json!({"radius": 3.0})), + sf("filter.other.maximum", json!({"radius": 2.0})), + sf("filter.other.minimum", json!({"radius": 2.0})), + sf("filter.pixelate.mosaic", json!({"cellSize": 8.0})), + sf("filter.stylize.emboss", json!({"angle": 135, "height": 3, "amount": 100})), + sf("image.adjustments.levels", json!({"inBlack": 20, "inWhite": 220, "gamma": 1.4, "red": {"inBlack": 0, "inWhite": 255, "gamma": 0.8}})), + sf("image.adjustments.curves", json!({"points": [[0.0, 0.0], [64.0, 40.0], [255.0, 255.0]], "blue": [[0.0, 20.0], [255.0, 235.0]]})), + sf("image.adjustments.shadowsHighlights", json!({})), + ]; + for mut f in filters { + f.blend = BlendMode::Overlay; + f.opacity = 0.75; + f.visible = false; + let d = item_for_filter(&f).unwrap(); + let bytes = d.to_bytes(); + let back = filter_from_item(&Descriptor::from_bytes(&bytes).unwrap()); + assert_eq!(back, f, "{}", f.command); + } + } + + #[test] + fn unknown_filters_keep_their_descriptor() { + let item = Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Twirl..."))) + .with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 100.0)).with("Md ", enumv("BlnM", "normal")))) + .with("enab", Value::Boolean(true)) + .with("Fltr", Value::Descriptor(Descriptor::new("Twrl").with("Angl", Value::Integer(50)))) + .with("filterID", Value::Integer(code(b"Twrl"))); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER); + assert_eq!(f.params["name"], "Twirl..."); + assert_eq!(item_for_filter(&f).unwrap(), item); + // Hidden and re-blended: only those keys change. + let mut g = f.clone(); + g.visible = false; + g.blend = BlendMode::Screen; + let back = filter_from_item(&item_for_filter(&g).unwrap()); + assert_eq!((back.visible, back.blend, &back.params), (false, BlendMode::Screen, &f.params)); + // A known filter with a setting we don't model is kept verbatim too. + let odd = Descriptor::new("filterFX").with("Fltr", Value::Descriptor(Descriptor::new("GsnB"))).with("filterID", Value::Integer(code(b"GsnB"))); + assert_eq!(filter_from_item(&odd).command, UNSUPPORTED_FILTER); + } + + #[test] + fn unwritable_filters_are_reported_not_fatal() { + let stack = FilterStack { + filters: vec![ + sf("filter.distort.twirl", json!({"angle": 50})), + sf("filter.blur.gaussianBlur", json!({"radius": 2.0})), + sf(UNSUPPORTED_FILTER, json!({"psd": "zz"})), + sf("image.adjustments.levels", json!({"cyan": {"gamma": 2.0}})), + sf("image.adjustments.shadowsHighlights", json!({"shadowAmount": 50})), + ], + ..Default::default() + }; + let mut w = Vec::new(); + let d = filter_fx(&stack, "L", &mut w); + assert_eq!(w.len(), 4, "{w:?}"); + let back = filter_stack(&d); + assert_eq!(back.filters, vec![stack.filters[1].clone()]); + } + + #[test] + fn placed_blocks_parse_back() { + let t = Affine { m: [0.5, 0.1, -0.1, 0.5, 10.0, 20.0] }; + let stack = + FilterStack { enabled: false, filters: vec![sf("filter.blur.gaussianBlur", json!({"radius": 3.0}))], mask_enabled: false, mask_linked: true }; + let mut w = Vec::new(); + let spec = PlacedSpec { + idnt: "id-1", + placed: "pl-2", + transform: t, + size: (64.0, 32.0), + dpi: 72.0, + warp: None, + filter_fx: Some(filter_fx(&stack, "L", &mut w)), + }; + let sold = sold_bytes(None, &spec, &mut w); + assert!(w.is_empty()); + assert_eq!(sold.len() % 4, 0); + let p = parse_sold(&sold).unwrap(); + assert_eq!((p.idnt.as_str(), p.placed.as_str()), ("id-1", "pl-2")); + for (a, b) in p.transform.m.iter().zip(t.m) { + assert!((a - b).abs() < 1e-9); + } + assert_eq!(p.stack, Some(stack)); + let tb = photocraft_psd::TaggedBlock::new(*b"SoLd", sold.clone()); + tb.check_structure().unwrap(); + let pl = photocraft_psd::TaggedBlock::new(*b"PlLd", plld_bytes(&spec, &mut w)); + pl.check_structure().unwrap(); + // A template keeps its other keys; an unmoved transform keeps its exact quad. + let mut tmpl = sold_descriptor(&sold).unwrap(); + tmpl.items.push((Id::new("extraKey"), Value::Integer(7))); + let again = sold_bytes(Some(&tmpl), &PlacedSpec { filter_fx: None, ..spec }, &mut w); + let d = sold_descriptor(&again).unwrap(); + assert_eq!(d.get("extraKey"), Some(&Value::Integer(7))); + assert!(d.get("filterFX").is_none()); + assert_eq!(d.get("Trnf"), tmpl.get("Trnf")); + } + + /// Corrupted placed-layer data and filter descriptors never panic: they parse to something + /// (unknown filters stay verbatim) or to nothing. + #[test] + fn mutated_placed_data_never_panics() { + let stack = FilterStack { + filters: vec![ + sf("filter.blur.gaussianBlur", json!({"radius": 3.0})), + sf("image.adjustments.curves", json!({"points": [[0.0, 0.0], [255.0, 255.0]]})), + sf("image.adjustments.levels", json!({"inBlack": 3, "inWhite": 250, "gamma": 1.1})), + ], + ..Default::default() + }; + let mut w = Vec::new(); + let spec = PlacedSpec { + idnt: "id", + placed: "pl", + transform: Affine::IDENTITY, + size: (8.0, 8.0), + dpi: 72.0, + warp: None, + filter_fx: Some(filter_fx(&stack, "L", &mut w)), + }; + let sold = sold_bytes(None, &spec, &mut w); + let mut state = 0x2545_F491_4F6C_DD1Du64; + let mut next = || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + for _ in 0..3000 { + let mut b = sold.clone(); + for _ in 0..1 + next() % 3 { + let i = (next() as usize) % b.len(); + b[i] = next() as u8; + } + if let Some(p) = parse_sold(&b) { + for f in p.stack.iter().flat_map(|s| &s.filters) { + let _ = item_for_filter(f); + } + let _ = sold_bytes(Some(&p.descriptor), &spec, &mut w); + } + } + } + + #[test] + fn warps_are_written_and_read_back() { + use photocraft_geom::warp::BezierMesh; + let bounds = [0.0, 0.0, 60.0, 30.0]; + let mut custom = BezierMesh::identity(bounds, 1, 1); + custom.points[15] = [67.0, 30.0]; + let mut multi = Warp::preset(WarpStyle::Custom, 0.0, bounds); + multi.mesh = Some(BezierMesh::identity(bounds, 2, 2)); + let mut arc = Warp::preset(WarpStyle::Arc, 40.0, bounds); + arc.vertical = true; + arc.h_distort = 10.0; + let mut c = Warp::preset(WarpStyle::Custom, 0.0, bounds); + c.mesh = Some(custom); + for (w, back) in [(Some(arc.clone()), Some(arc)), (Some(c.clone()), Some(c)), (Some(multi), None), (None, None)] { + let mut warnings = Vec::new(); + let spec = PlacedSpec { idnt: "i", placed: "p", transform: Affine::IDENTITY, size: (60.0, 30.0), dpi: 72.0, warp: w.as_ref(), filter_fx: None }; + let sold = sold_bytes(None, &spec, &mut warnings); + assert_eq!(crate::blocks::parse_placed_warp(b"SoLd", &sold), back); + assert_eq!(warnings.len(), usize::from(w.is_some() && back.is_none())); + } + } + + #[test] + fn hex_and_uuid() { + assert_eq!(from_hex(&to_hex(&[0, 1, 0xab, 0xff])), Some(vec![0, 1, 0xab, 0xff])); + assert_eq!(from_hex("abc"), None); + assert_eq!(from_hex("zz"), None); + let u = uuid_from(b"x"); + assert_eq!(u.len(), 36); + assert_eq!(u, uuid_from(b"x")); + assert_ne!(u, uuid_from(b"y")); + } +} diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/corpus.rs index 57f394c595..bb017a1074 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/corpus.rs @@ -156,7 +156,7 @@ const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-t /// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus), /// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/`). -const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 126, roundtrip_floor: 256 }; +const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 128, roundtrip_floor: 258 }; /// The corpus directory; a missing corpus fails the test. fn locate(src: &Source) -> PathBuf { diff --git a/crates/io/tests/psd_block_layout.rs b/crates/io/tests/psd_block_layout.rs index af4791812a..bb858633b5 100644 --- a/crates/io/tests/psd_block_layout.rs +++ b/crates/io/tests/psd_block_layout.rs @@ -37,7 +37,10 @@ fn plld(uuid: &str) -> Vec { fn sold(uuid: &str) -> Vec { let mut d = b"soLD".to_vec(); d.extend(4u32.to_be_bytes()); - let desc = Descriptor::new("null").with("Idnt", photocraft_psd::descriptor::Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul(uuid))); + use photocraft_psd::descriptor::Value; + // The strict check wants what Photoshop needs to place the layer: its file id and transform. + let quad = [0.0, 0.0, 4.0, 0.0, 4.0, 4.0, 0.0, 4.0].map(Value::Double).to_vec(); + let desc = Descriptor::new("null").with("Idnt", Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul(uuid))).with("Trnf", Value::List(quad)); d.extend(VersionedDescriptor::new(desc).to_bytes()); while !d.len().is_multiple_of(4) { d.push(0); diff --git a/crates/io/tests/smart_corpus.rs b/crates/io/tests/smart_corpus.rs new file mode 100644 index 0000000000..494a43071c --- /dev/null +++ b/crates/io/tests/smart_corpus.rs @@ -0,0 +1,223 @@ +//! Smart objects and smart filters through PSD export, over every corpus file that has them +//! (feature `corpus`; run with `cargo xtask test-corpus`). +//! +//! For each file: import → export → import must give back, layer for layer, a live smart object +//! (not pixels) with the same embedded file (byte-identical), transform, filter list, filter +//! switches and filter mask; every exported block re-parses strictly (`strict_block_errors`: +//! `SoLd` with its `filterFX`, `PlLd`, `lnk2`, `FEid`). Then two edits must survive the same way: +//! the first filter hidden at 40 % opacity in Multiply (the placed-layer blocks and the filter +//! cache are regenerated), and every source re-embedded from its bytes (new `lnk2` items, the old +//! ones dropped). +#![cfg(feature = "corpus")] + +mod common; + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use photocraft_doc::{Document, LayerContent, SmartObject, SmartSource}; + +fn collect(dir: &Path, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + collect(&p, out); + } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("psd") || e.eq_ignore_ascii_case("psb")) { + out.push(p); + } + } +} + +fn smarts(doc: &Document) -> Vec<(String, SmartObject)> { + doc.walk() + .into_iter() + .filter_map(|(_, _, l)| match &l.content { + LayerContent::Smart(sm) => Some((l.name.clone(), sm.clone())), + _ => None, + }) + .collect() +} + +fn source_file(doc: &Document, sm: &SmartObject) -> Option> { + match &sm.source { + SmartSource::Embedded { bytes, .. } => Some(bytes.to_vec()), + SmartSource::Linked { path } => photocraft_io::linked::find_linked_file(&doc.metadata, path).map(|f| f.bytes), + } +} + +/// Mask coverage over `area` (None = no mask: all ones). +fn mask_values(sm: &SmartObject, area: photocraft_geom::Rect) -> Vec { + let mut v = Vec::new(); + match &sm.filter_mask { + Some(m) => m.values_into(area, &mut v), + None => v.resize(area.width() as usize * area.height() as usize, 1.0), + } + v +} + +/// Compares the smart objects of `a` and `b`; returns problems. +fn compare(a: &Document, b: &Document, same_files: bool) -> Vec { + let (sa, sb) = (smarts(a), smarts(b)); + if sa.len() != sb.len() { + return vec![format!("{} smart objects became {}", sa.len(), sb.len())]; + } + let mut errs = Vec::new(); + for ((name, x), (_, y)) in sa.iter().zip(&sb) { + let fa = source_file(a, x); + let fb = source_file(b, y); + match (&fa, &fb) { + (Some(p), Some(q)) if same_files && p != q => errs.push(format!("{name}: embedded file changed")), + (Some(_), None) => errs.push(format!("{name}: embedded file lost")), + _ => {} + } + if x.smart_filters != y.smart_filters { + errs.push(format!("{name}: filters {:?} became {:?}", x.smart_filters, y.smart_filters)); + } + if x.filters_enabled != y.filters_enabled { + errs.push(format!("{name}: filters enabled changed")); + } + if x.transform.m.iter().zip(y.transform.m).any(|(p, q)| (p - q).abs() > 1e-6) { + errs.push(format!("{name}: transform {:?} became {:?}", x.transform, y.transform)); + } + if x.warp != y.warp { + errs.push(format!("{name}: warp changed")); + } + let area = a.bounds(); + if !x.smart_filters.is_empty() && mask_values(x, area) != mask_values(y, area) { + errs.push(format!("{name}: filter mask changed")); + } + if x.filter_mask.as_ref().map(|m| (m.enabled, m.linked)) != y.filter_mask.as_ref().map(|m| (m.enabled, m.linked)) { + errs.push(format!("{name}: filter mask switches changed")); + } + } + errs +} + +fn round_trip(doc: &Document) -> Result<(Document, Vec), String> { + let out = photocraft_io::export(doc, "psd", &Default::default()).map_err(|e| e.to_string())?; + if let Some(w) = out.warnings.iter().find(|w| w.contains("smart object written as pixels")) { + return Err(w.clone()); + } + let errs = common::strict_block_errors(&out.bytes); + if !errs.is_empty() { + return Err(format!("strict structure: {errs:?}")); + } + let back = photocraft_io::import("x.psd", &out.bytes).map_err(|e| e.to_string())?.document; + Ok((back, out.bytes)) +} + +fn edit_filters(doc: &mut Document) -> bool { + let mut any = false; + for id in doc.walk().into_iter().map(|(_, _, l)| l.id).collect::>() { + if let Some(l) = doc.layer_mut(id) + && let LayerContent::Smart(sm) = &mut l.content + && let Some(f) = sm.smart_filters.first_mut() + { + f.visible = !f.visible; + f.opacity = 0.4; + f.blend = photocraft_color::BlendMode::Multiply; + any = true; + } + } + any +} + +fn re_embed(doc: &mut Document) -> bool { + let meta = doc.metadata.clone(); + let mut any = false; + for id in doc.walk().into_iter().map(|(_, _, l)| l.id).collect::>() { + if let Some(l) = doc.layer_mut(id) + && let LayerContent::Smart(sm) = &mut l.content + && let SmartSource::Linked { path } = &sm.source + && let Some(f) = photocraft_io::linked::find_linked_file(&meta, path) + { + sm.source = SmartSource::Embedded { file_name: f.file_name, bytes: Arc::new(f.bytes) }; + sm.psd_raw = None; + l.psd_blocks.retain(|(k, _)| !matches!(k, b"SoLd" | b"PlLd" | b"SoLE")); + any = true; + } + } + any +} + +#[test] +fn smart_objects_round_trip_through_psd() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"); + assert!(root.join("photoshop").is_dir(), "{} is missing: run `cargo xtask corpus --all`", root.display()); + let mut files = Vec::new(); + for d in ["psd", "psd-tools", "photoshop"] { + collect(&root.join(d), &mut files); + } + files.sort(); + let (mut checked, mut objects, mut with_filters, mut edited, mut embedded) = (0, 0, 0, 0, 0); + let mut failures = Vec::new(); + for p in &files { + let name = p.strip_prefix(&root).unwrap_or(p).display().to_string(); + let bytes = std::fs::read(p).unwrap_or_default(); + let Ok(imp) = photocraft_io::import(&name, &bytes) else { continue }; + let doc = imp.document; + let n = smarts(&doc).len(); + if n == 0 { + continue; + } + checked += 1; + objects += n; + with_filters += smarts(&doc).iter().filter(|(_, s)| !s.smart_filters.is_empty()).count(); + let mut fail = |what: &str, e: String| failures.push(format!("{name} ({what}): {e}")); + match round_trip(&doc) { + Ok((back, _)) => compare(&doc, &back, true).into_iter().for_each(|e| fail("as imported", e)), + Err(e) => fail("as imported", e), + } + let mut ed = doc.clone(); + if edit_filters(&mut ed) { + edited += 1; + match round_trip(&ed) { + Ok((back, _)) => compare(&ed, &back, true).into_iter().for_each(|e| fail("filters edited", e)), + Err(e) => fail("filters edited", e), + } + } + let mut em = doc.clone(); + if re_embed(&mut em) { + embedded += 1; + match round_trip(&em) { + Ok((back, out)) => { + compare(&em, &back, true).into_iter().for_each(|e| fail("re-embedded", e)); + // The old files are gone: one lnk2 item per distinct source. + let f = photocraft_psd::PsdFile::from_bytes(&out).unwrap(); + let items: Vec = f + .global_blocks + .iter() + .filter(|b| matches!(&b.key, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE")) + .flat_map(|b| photocraft_io::linked::block_uuids(&b.data)) + .collect(); + let mut used: Vec = smarts(&back) + .iter() + .filter_map(|(_, s)| match &s.source { + SmartSource::Linked { path } => Some(path.clone()), + SmartSource::Embedded { .. } => None, + }) + .collect(); + used.sort(); + used.dedup(); + let mut items_sorted = items.clone(); + items_sorted.sort(); + if !used.iter().all(|u| items_sorted.contains(u)) || items_sorted.len() > used.len() { + fail("re-embedded", format!("lnk2 items {items:?} for sources {used:?}")); + } + } + Err(e) => fail("re-embedded", e), + } + } + eprintln!("{name:<70} {n:>3} smart objects"); + } + eprintln!( + "smart objects through PSD: {checked} files, {objects} smart objects ({with_filters} with filters); {edited} filter edits, {embedded} re-embeds; {} failures", + failures.len() + ); + for f in &failures { + eprintln!(" FAIL {f}"); + } + assert!(checked >= 30, "only {checked} corpus files with smart objects"); + assert!(failures.is_empty(), "{} failures", failures.len()); +} diff --git a/crates/psd/src/filter_effects.rs b/crates/psd/src/filter_effects.rs new file mode 100644 index 0000000000..a50213749b --- /dev/null +++ b/crates/psd/src/filter_effects.rs @@ -0,0 +1,274 @@ +//! Smart-filter caches: the global `FEid` / `FXid` ("Filter Effects") block. +//! +//! Layout (Adobe PSD spec, "Filter Effects", checked against Photoshop-authored files): +//! +//! ```text +//! u32 version (1..=3), u64 length of what follows, then until that length is used up: +//! Pascal identifier (the smart object's `placed` id in its `SoLd` descriptor, unpadded) +//! u32 item version (1), u64 item length, then inside the item: +//! rect (top, left, bottom, right), u32 depth, u32 max channels, +//! max channels + 2 slots: u32 written flag; if set u64 length, u16 compression, data +//! after the item: u8 flag; if set rect, u64 length, u16 compression, data +//! ``` +//! +//! The slots hold the unfiltered placed pixels (colour channels from slot 0, transparency in the +//! last slot) and the trailing image is the filter mask, in document coordinates. RLE row counts +//! are four bytes wide (PSB style) in every file version. + +use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes}; +use crate::error::{PsdError, Result}; +use crate::header::Version; +use crate::io::{Reader, WriteExt, read_pascal, write_pascal}; +use crate::layer::Rect; + +/// Most slots an item may declare (Photoshop writes 24 channels + 2). +const MAX_SLOTS: u32 = 64; + +/// One compressed plane of a filter-effects item. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectsPlane { + /// Compression of `data`. + pub compression: Compression, + /// Encoded data (without the compression field). + pub data: Vec, +} + +impl EffectsPlane { + /// Encodes one plane of `w × h` big-endian samples of `depth` bits (RLE uses four-byte counts). + pub fn encode(compression: Compression, decoded: &[u8], w: usize, h: usize, depth: u16) -> Result { + let layout = PlaneLayout { planes: 1, width: w, height: h, depth, version: Version::Psb }; + Ok(EffectsPlane { compression, data: encode_planes(compression, decoded, &layout)? }) + } + + /// Decodes into `w × h` big-endian samples of `depth` bits. A size the encoded data can't + /// possibly hold (PackBits expands at most 64×, zlib about 1032×) is refused before any + /// allocation, so a tiny corrupt block can't ask for gigabytes. + pub fn decode(&self, w: usize, h: usize, depth: u16) -> Result> { + let layout = PlaneLayout { planes: 1, width: w, height: h, depth, version: Version::Psb }; + let n = self.data.len() as u64; + let most = match self.compression { + Compression::Raw => n, + Compression::Rle => n.saturating_mul(64), + _ => n.saturating_mul(1032).saturating_add(64), + }; + if layout.total_bytes()? > most { + return Err(PsdError::invalid("filter effects: plane larger than its data can hold")); + } + decode_planes(self.compression, &self.data, &layout) + } +} + +/// The cache of one smart object's filters. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FilterEffectsItem { + /// The smart object's `placed` id. + pub id: String, + /// Item version (1). + pub version: u32, + /// Bounds of the cached planes. + pub rect: Rect, + /// Bits per sample. + pub depth: u32, + /// Declared channel count; there are `max_channels + 2` slots. + pub max_channels: u32, + /// The slots (`None` = not written). + pub slots: Vec>, + /// The filter mask: bounds and plane. + pub mask: Option<(Rect, EffectsPlane)>, +} + +impl FilterEffectsItem { + /// The decoded filter mask (8/16/32-bit big-endian samples) with its bounds. + pub fn decoded_mask(&self) -> Option<(Rect, Vec)> { + let (r, p) = self.mask.as_ref()?; + let (w, h) = r.size().ok()?; + let depth = u16::try_from(self.depth).ok()?; + Some((*r, p.decode(w, h, depth).ok()?)) + } +} + +/// A parsed `FEid`/`FXid` block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FilterEffects { + /// Block version (1..=3). + pub version: u32, + /// One item per smart object with filters. + pub items: Vec, +} + +fn read_rect(r: &mut Reader<'_>) -> Result { + Ok(Rect { top: r.i32()?, left: r.i32()?, bottom: r.i32()?, right: r.i32()? }) +} + +fn write_rect(out: &mut Vec, r: &Rect) { + out.put_i32(r.top); + out.put_i32(r.left); + out.put_i32(r.bottom); + out.put_i32(r.right); +} + +fn read_plane(r: &mut Reader<'_>) -> Result { + let len = r.u64()?; + if len < 2 { + return Err(PsdError::invalid("filter effects: plane shorter than its compression field")); + } + let mut sub = r.sub(len)?; + let compression = Compression::from_u16(sub.u16()?); + Ok(EffectsPlane { compression, data: sub.peek_rest().to_vec() }) +} + +fn write_plane(out: &mut Vec, p: &EffectsPlane) { + out.put_u64(p.data.len() as u64 + 2); + out.put_u16(p.compression.as_u16()); + out.put(&p.data); +} + +impl FilterEffects { + /// Parses block data. Trailing zero padding (up to 3 bytes) is allowed. + pub fn parse(data: &[u8]) -> Result { + let mut r = Reader::new(data); + let version = r.u32()?; + if !(1..=3).contains(&version) { + return Err(PsdError::invalid(format!("filter effects version {version}"))); + } + let len = r.u64()?; + let mut body = r.sub(len)?; + let rest = r.peek_rest(); + if rest.len() > 3 || rest.iter().any(|&b| b != 0) { + return Err(PsdError::invalid(format!("filter effects: {} unexpected bytes after the data", rest.len()))); + } + let mut items = Vec::new(); + while !body.is_empty() { + let id = String::from_utf8_lossy(&read_pascal(&mut body, 1)?).into_owned(); + let item_version = body.u32()?; + let item_len = body.u64()?; + let mut it = body.sub(item_len)?; + let rect = read_rect(&mut it)?; + let depth = it.u32()?; + let max_channels = it.u32()?; + if max_channels > MAX_SLOTS { + return Err(PsdError::LimitExceeded("filter effects: too many channels")); + } + let mut slots = Vec::with_capacity(max_channels as usize + 2); + for _ in 0..max_channels + 2 { + slots.push(if it.u32()? != 0 { Some(read_plane(&mut it)?) } else { None }); + } + if !it.is_empty() { + return Err(PsdError::invalid(format!("filter effects item {id}: {} unexpected bytes", it.remaining()))); + } + let mask = if body.is_empty() || body.u8()? == 0 { None } else { Some((read_rect(&mut body)?, read_plane(&mut body)?)) }; + items.push(FilterEffectsItem { id, version: item_version, rect, depth, max_channels, slots, mask }); + } + Ok(FilterEffects { version, items }) + } + + /// Serializes block data (without padding). + pub fn to_bytes(&self) -> Vec { + let mut body = Vec::new(); + for it in &self.items { + write_pascal(&mut body, it.id.as_bytes(), 1); + body.put_u32(it.version); + let at = body.begin_len(true); + write_rect(&mut body, &it.rect); + body.put_u32(it.depth); + body.put_u32(it.max_channels); + for i in 0..it.max_channels as usize + 2 { + match it.slots.get(i).and_then(Option::as_ref) { + Some(p) => { + body.put_u32(1); + write_plane(&mut body, p); + } + None => body.put_u32(0), + } + } + // A 64-bit length never overflows. + let _ = body.end_len(at, true); + match &it.mask { + Some((r, p)) => { + body.put_u8(1); + write_rect(&mut body, r); + write_plane(&mut body, p); + } + None => body.put_u8(0), + } + } + let mut out = Vec::with_capacity(body.len() + 12); + out.put_u32(self.version); + out.put_u64(body.len() as u64); + out.put(&body); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn item(id: &str, mask: bool) -> FilterEffectsItem { + let rect = Rect { top: 1, left: 2, bottom: 5, right: 6 }; + let plane = |v: u8| EffectsPlane::encode(Compression::Rle, &[v; 16], 4, 4, 8).unwrap(); + let mut slots = vec![None; 26]; + slots[0] = Some(plane(10)); + slots[25] = Some(plane(255)); + FilterEffectsItem { id: id.into(), version: 1, rect, depth: 8, max_channels: 24, slots, mask: mask.then(|| (rect, plane(128))) } + } + + #[test] + fn round_trips() { + let fx = FilterEffects { version: 3, items: vec![item("a-1", true), item("b-2", false)] }; + let bytes = fx.to_bytes(); + let back = FilterEffects::parse(&bytes).unwrap(); + assert_eq!(back, fx); + assert_eq!(back.items[0].decoded_mask().unwrap().1, vec![128; 16]); + assert_eq!(back.items[0].slots[0].as_ref().unwrap().decode(4, 4, 8).unwrap(), vec![10; 16]); + // Padding is allowed, garbage is not. + let mut padded = bytes.clone(); + padded.extend([0, 0]); + assert!(FilterEffects::parse(&padded).is_ok()); + padded.push(7); + assert!(FilterEffects::parse(&padded).is_err()); + } + + #[test] + fn mutations_never_panic() { + let bytes = FilterEffects { version: 3, items: vec![item("x", true), item("y", false)] }.to_bytes(); + let mut state = 0x9E37_79B9_7F4A_7C15u64; + let mut next = || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + for _ in 0..2000 { + let mut b = bytes.clone(); + for _ in 0..1 + next() % 4 { + let i = (next() as usize) % b.len(); + b[i] = next() as u8; + } + if let Ok(fx) = FilterEffects::parse(&b) { + for it in &fx.items { + let _ = it.decoded_mask(); + for p in it.slots.iter().flatten() { + let _ = p.decode(4, 4, 8); + } + } + let _ = fx.to_bytes(); + } + } + } + + #[test] + fn malformed_data_is_an_error() { + let bytes = FilterEffects { version: 3, items: vec![item("x", true)] }.to_bytes(); + for n in 0..bytes.len() { + assert!(FilterEffects::parse(&bytes[..n]).is_err(), "truncated at {n}"); + } + let mut bad = bytes.clone(); + bad[3] = 9; // version + assert!(FilterEffects::parse(&bad).is_err()); + let mut huge = bytes; + let at = 4 + 8 + 2 + 4 + 8 + 16 + 4; // max channels + huge[at..at + 4].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(FilterEffects::parse(&huge).is_err()); + } +} diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 90e9628f26..c5e489ebf2 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -47,6 +47,7 @@ pub mod compression; pub mod descriptor; pub mod error; pub mod file; +pub mod filter_effects; pub mod grd; pub mod header; pub mod image_data; diff --git a/crates/psd/src/tagged.rs b/crates/psd/src/tagged.rs index 80631f89ef..7ea003f120 100644 --- a/crates/psd/src/tagged.rs +++ b/crates/psd/src/tagged.rs @@ -253,7 +253,8 @@ impl TaggedBlock { b"SoLd" | b"SoLE" => { expect_kind(&mut r, b"soLD")?; let _version = r.u32()?; - crate::descriptor::VersionedDescriptor::read(&mut r)?; + let d = crate::descriptor::VersionedDescriptor::read(&mut r)?; + check_placed_descriptor(&d.descriptor).map_err(|e| PsdError::invalid(format!("{}: {e}", self.key_str())))?; } b"lfx2" => { let _effects_version = r.u32()?; @@ -266,11 +267,18 @@ impl TaggedBlock { if !matches!(item.get(..4), Some(b"liFD" | b"liFE" | b"liFA")) { return Err(PsdError::invalid(format!("{}: linked item of unknown type", self.key_str()))); } + if item.get(..4) == Some(b"liFD") { + check_embedded_item(item).map_err(|e| PsdError::invalid(format!("{}: embedded file: {e}", self.key_str())))?; + } // Items are padded to a multiple of 4. let pad = ((4 - len % 4) % 4).min(r.remaining() as u64); r.skip(pad as usize)?; } } + b"FEid" | b"FXid" => { + crate::filter_effects::FilterEffects::parse(d).map_err(|e| PsdError::invalid(format!("{}: {e}", self.key_str())))?; + return Ok(()); + } _ => return Ok(()), } let rest = r.peek_rest(); @@ -300,6 +308,56 @@ impl TaggedBlock { } } +/// Strict check of a `soLD` descriptor: the unique id and transform Photoshop needs to place the +/// layer, and a well-formed smart filter stack (`filterFX`: a `filterFXList` of `filterFX` +/// objects, each with a name, blend options, an enabled flag and a filter id). +fn check_placed_descriptor(d: &crate::descriptor::Descriptor) -> Result<()> { + use crate::descriptor::Value; + if !matches!(d.get("Idnt"), Some(Value::Text(_))) { + return Err(PsdError::invalid("no Idnt (unique id)")); + } + match d.get("Trnf") { + Some(Value::List(l)) if l.len() == 8 && l.iter().all(|v| matches!(v, Value::Double(_))) => {} + _ => return Err(PsdError::invalid("Trnf is not eight doubles")), + } + let Some(fx) = d.get("filterFX") else { return Ok(()) }; + let Value::Descriptor(fx) = fx else { return Err(PsdError::invalid("filterFX is not an object")) }; + let Some(Value::List(list)) = fx.get("filterFXList") else { return Err(PsdError::invalid("filterFX has no filterFXList")) }; + for (i, item) in list.iter().enumerate() { + let Value::Descriptor(f) = item else { return Err(PsdError::invalid(format!("filter {i} is not an object"))) }; + let ok = matches!(f.get("Nm "), Some(Value::Text(_))) + && matches!(f.get("blendOptions"), Some(Value::Descriptor(_))) + && matches!(f.get("enab"), Some(Value::Boolean(_))) + && matches!(f.get("filterID"), Some(Value::Integer(_))) + && f.get("Fltr").is_none_or(|v| matches!(v, Value::Descriptor(_))); + if !ok { + return Err(PsdError::invalid(format!("filter {i} lacks Nm/blendOptions/enab/filterID"))); + } + } + Ok(()) +} + +/// Strict check of one embedded (`liFD`) linked-layer item: type, version, unique id, file name, +/// file type and creator, data length, optional open descriptor, then the file data, which must +/// fit in the item (version 5+ fields follow it). +fn check_embedded_item(item: &[u8]) -> Result<()> { + let mut r = Reader::new(item); + r.skip(4)?; + let version = r.u32()?; + if !(1..=16).contains(&version) { + return Err(PsdError::invalid(format!("version {version}"))); + } + crate::io::read_pascal(&mut r, 1)?; + crate::io::read_unicode_units(&mut r)?; + r.skip(8)?; // file type, creator + let len = r.u64()?; + if r.u8()? != 0 { + crate::descriptor::VersionedDescriptor::read(&mut r)?; + } + r.bytes_u64(len)?; + Ok(()) +} + fn expect_kind(r: &mut Reader<'_>, kind: &'static [u8; 4]) -> Result<()> { let found = r.array::<4>()?; if &found != kind { diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index d7c3e9e85d..19650a4762 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -20,8 +20,14 @@ pub fn thumb_badge(ui: &egui::Ui, l: &Layer, thumb: Rect) { icons::paint(ui, r, "app-window", 9.0, t.icon); } -/// The display name of a smart filter (its command's label without the ellipsis). -fn filter_label(command: &str) -> String { +/// The display name of a smart filter (its command's label without the ellipsis). A Photoshop +/// filter PhotoCraft doesn't implement shows its own name and is marked as kept as is. +fn filter_label(f: &photocraft_doc::SmartFilter) -> String { + let command = f.command.as_str(); + if command == photocraft_engine::smart_cmds::UNSUPPORTED_FILTER { + let name = f.params.get("name").and_then(Value::as_str).unwrap_or("Photoshop filter"); + return format!("{} (kept, not editable)", name.trim_end_matches("...").trim_end_matches('…')); + } photocraft_engine::commands::find(command) .map_or_else(|| command.rsplit('.').next().unwrap_or(command).to_string(), |c| c.label.trim_end_matches('…').to_string()) } @@ -46,7 +52,7 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: } let (name, on) = match index { None => ("Smart Filters".to_string(), sm.filters_enabled), - Some(i) => (filter_label(&sm.smart_filters[i].command), sm.smart_filters[i].visible && sm.filters_enabled), + Some(i) => (filter_label(&sm.smart_filters[i]), sm.smart_filters[i].visible && sm.filters_enabled), }; let eye = Rect::from_min_size(pos2(rect.left() + 6.0, rect.center().y - 9.0), vec2(18.0, 18.0)); let eye_resp = ui.interact(eye, ui.id().with(("sf-eye", l.id.0, index)), Sense::click()); diff --git a/docs/architecture.md b/docs/architecture.md index 709ddc4f7a..df1f89af96 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -355,7 +355,13 @@ psd/src/ 3. Groups (including pass-through), clipping, blend-if, adjustment and fill layers. 4. Layer effects (lfx2). 5. Text layers: shaped with `text`; the original descriptors are kept for round-trip. - 6. Smart objects: embedded and linked. + 6. Smart objects: embedded and linked, live both ways (`io::smart_map`). Import reads the + placed-layer data (`SoLd`: file id, transform quad, warp), the smart filter stack + (`filterFX`: modelled filters become their PhotoCraft command and params; any other filter + stays verbatim and is listed as not editable) and the filter mask (global `FEid`). Export + writes `PlLd` + `SoLd`, embeds the source in `lnk2` (a `.pcraft` source becomes a PSB of the + nested document, written by our own PSD writer) and a filter cache with the unfiltered + pixels and the mask in `FEid`; unedited imported smart objects stay byte-identical. 7. Vector masks and shape layers. - **Testing:** - Round-trip byte-stability for unmodified files. diff --git a/docs/roadmap.md b/docs/roadmap.md index d0f171f6a7..de62b08c11 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -63,7 +63,7 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either | Menu wiring | 626/626 menu items dispatch a command (`parity.md`) | high but shallow | Says nothing about behaviour. | | PSD fidelity (rendering) | Corpus oracle 115/170 (68%): 30 differ, 26 have no usable reference, 1 import error | medium | Push to 170/170 under way (effects/strokes, multi-instance effects, 16/32-bit and colour modes, references for skipped files). | | PSD round trip | 169/169 re-import identically; every adjustment layer and blend mode round-trips | high (within corpus) | Floors in `crates/io/tests/corpus.rs`; raise, never lower. Corpora: `cargo xtask corpus --all` (ours: https://github.com/storytold/photocraft-corpus). | -| Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 256 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs` | low–medium | Fix from the failure map (`cargo xtask test-corpus -- --nocapture`). | +| Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. | | Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. | | UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157) | low–medium | Needs recurring visual QA with realistic documents. | | Tools | ~20 Photoshop tools missing: Pencil, Mixer Brush (tool), Patch, Content-Aware Move, Red Eye, Pattern Stamp, Art History Brush, Freeform/Curvature Pen, anchor tools, Direct Selection, Magnetic Lasso, single row/column marquee, Color Sampler, Perspective Crop, Rotate View, the Vertical Type tool (vertical layout itself landed, #199: toggle via Type › Orientation) and type masks, Frame | low–medium | Magic/Background Eraser added; live gradients in progress (#180). | @@ -109,7 +109,7 @@ Next: 2026-10-05 (`docs/releasing.md`); Windows code-signing material still needs to be obtained. 2. **Fidelity**: the PSD oracle (113/170). Modern Brightness/Contrast and grayscale Levels curves; chisel-soft / stroke-emboss bevel shapes; Photoshop's 8-bit blend rounding; non-Normal - modes in Lab documents; Photoshop smart filters in `SoLd`. + modes in Lab documents; smart-filter maths against Photoshop's (re-render oracle 5/30). 3. **GPU**: only Multichannel documents and regions over the texture limit fall back to the CPU. 4. **Vanishing Point, Camera Raw / Lens Correction, Face-Aware Liquify** (needs a landmark model). 5. **Panels**: Patterns, Styles, Glyphs, Character/Paragraph Styles, Timeline; Custom Shape tool. diff --git a/docs/scorecard.md b/docs/scorecard.md index 9e28004b8f..a9cc2cecae 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 10 | 44 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 0 not run, 10 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 0 | 6 | 21 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 126/256, round trip ≥ 256/256 | +| [File compatibility](#file-compatibility) | 1 | 8 | 18 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 128/256, round trip ≥ 258/256 | | [Tools](#tools) | 3 | 14 | 13 | | | [Workspace and UI](#workspace-and-ui) | 0 | 3 | 20 | settings that do nothing: 61 of 133 (target 0) | | [Type](#type) | 0 | 3 | 6 | | @@ -82,14 +82,14 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t |---|---|---:|---:|---:| | io corpus | `corpus/psd` | 170 | 146 | 169 | | psd-tools corpus | `corpus/psd-tools` | 309 | 229 | 307 | -| photoshop oracles | `corpus/photoshop` | 256 | 126 | 256 | +| photoshop oracles | `corpus/photoshop` | 256 | 128 | 258 | | Id | Target | Status | Issue | Note | |---|---|---|---|---| -| FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:415 detach_psd drops SoLd; psd_export.rs:276 re-emits imported raw only | -| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | psd_import.rs:259 smart_filters: Vec::new(); no filterFX in crates | +| FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded | +| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 14 filters mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) are left out of filterFX with a warning | | FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection | -| FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | missing | [#215](https://github.com/storytold/photocraft/issues/215) | add_smart_filter callers: filters, lens, distort, plug-ins only | +| FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | partial | [#215](https://github.com/storytold/photocraft/issues/215) | PSD Levels/Curves/Shadows-Highlights smart filters render (adjust_cmds.rs adjust_as_filter); Image › Adjustments on a smart object doesn't add one yet | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | | FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | doc lib.rs:312 SmartObject.transform is an Affine | | FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | missing | [#215](https://github.com/storytold/photocraft/issues/215) | No 37724 / ImageSourceData in crates | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 826ccb7adb..280be646d7 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -1,6 +1,6 @@ # Scorecard checklist: file compatibility (#215 formats, #216 measurement). # Corpus numbers come from crates/io/tests/corpus.rs, not from here. -# Statuses must be true on main. Verified against 4b53f62 on 2026-10-05. +# Statuses must be true on main. Verified against 4b53f62 on 2026-10-05 (smart objects: #215 PR). area = "file" title = "File compatibility" order = 2 @@ -8,16 +8,16 @@ order = 2 [[item]] id = "FILE-215-1" target = "Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels" -status = "missing" +status = "done" issue = 215 -note = "smart_cmds.rs:415 detach_psd drops SoLd; psd_export.rs:276 re-emits imported raw only" +note = "psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded" [[item]] id = "FILE-215-2" target = "Smart Filters survive PSD save and re-open (filterFX descriptor read and written)" -status = "missing" +status = "partial" issue = 215 -note = "psd_import.rs:259 smart_filters: Vec::new(); no filterFX in crates" +note = "smart_map.rs: 14 filters mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) are left out of filterFX with a warning" [[item]] id = "FILE-215-3" @@ -29,9 +29,9 @@ note = "smart_cmds.rs:382 mask only from the selection" [[item]] id = "FILE-215-4" target = "Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters" -status = "missing" +status = "partial" issue = 215 -note = "add_smart_filter callers: filters, lens, distort, plug-ins only" +note = "PSD Levels/Curves/Shadows-Highlights smart filters render (adjust_cmds.rs adjust_as_filter); Image › Adjustments on a smart object doesn't add one yet" [[item]] id = "FILE-215-5"