mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-15 03:36:48 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4717de1398 |
No files matched your search
Generated
+3
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -204,9 +204,30 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
// ---------- commands ----------
|
||||
|
||||
/// Image › Adjustments run as a smart filter (`image.adjustments.<kind>` 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<Surface> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String>) -> 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<Option<Surface>> {
|
||||
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<Value> {
|
||||
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,
|
||||
|
||||
@@ -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<PathBuf>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
|
||||
@@ -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=<dir>` 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<String> {
|
||||
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::<String>::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::<String>::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::<String>::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<String> = 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()]);
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<BlendMode> {
|
||||
pub(crate) fn blend_from_id(v: &[u8]) -> Option<BlendMode> {
|
||||
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) }
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+97
-7
@@ -94,23 +94,42 @@ pub fn find_linked_file(meta: &Metadata, uuid: &str) -> Option<LinkedFile> {
|
||||
.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<u8> {
|
||||
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<u16> = 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<u16> = 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<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The uuid of one linked-layer item (any type), if its header is readable.
|
||||
fn item_uuid(item: &[u8]) -> Option<String> {
|
||||
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<String>, &[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<Vec<u8>> {
|
||||
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<String> {
|
||||
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());
|
||||
|
||||
+371
-4
@@ -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<photocraft_doc::LayerComp>, Option<photocraft_doc::LayerComp>)>,
|
||||
/// 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<photocraft_doc::PsdGlobalBlock>,
|
||||
/// uuids of the files in the preserved linked-layer blocks.
|
||||
known: std::collections::HashSet<String>,
|
||||
/// uuids the layers reference.
|
||||
used: std::collections::HashSet<String>,
|
||||
/// Unreferenced preserved files may be dropped (every smart layer's file id is known).
|
||||
prune_ok: bool,
|
||||
/// Files to embed.
|
||||
add: Vec<crate::linked::LinkedFile>,
|
||||
/// Sources converted so far, by content hash (`Err` = can't be embedded).
|
||||
converted: std::collections::HashMap<[u8; 32], Result<Source, String>>,
|
||||
/// Embedded documents decoded while converting them, by uuid (for the filter caches).
|
||||
docs: std::collections::HashMap<String, Document>,
|
||||
/// Source composites (document pixel format, source space) by uuid; `None` = unreadable.
|
||||
composites: std::collections::HashMap<String, Option<(std::sync::Arc<Surface>, photocraft_geom::Rect)>>,
|
||||
/// Preserved `FEid` items (by placed id).
|
||||
old_fx: Vec<photocraft_psd::filter_effects::FilterEffectsItem>,
|
||||
/// Placed ids whose preserved `FEid` item stays.
|
||||
keep_fx: std::collections::HashSet<String>,
|
||||
/// Regenerated `FEid` items.
|
||||
new_fx: Vec<photocraft_psd::filter_effects::FilterEffectsItem>,
|
||||
}
|
||||
|
||||
/// `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<u8> {
|
||||
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<photocraft_doc::PsdGlobalBlock>) -> Vec<photocraft_doc::PsdGlobalBlock> {
|
||||
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<u8> = 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<u8>)>, key: &[u8; 4], data: Vec<u8>, 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<crate::smart_map::Placed>, raw: &mut Vec<([u8; 4], Vec<u8>)>) {
|
||||
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<Source, String> {
|
||||
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<Surface>, 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<Source, String> {
|
||||
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<Source, String> {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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
|
||||
|
||||
@@ -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<photocraft_text::engine_data::Value>,
|
||||
/// Smart-filter caches from the global `FEid`/`FXid` blocks (filter masks, by placed id).
|
||||
pub filter_effects: Vec<photocraft_psd::filter_effects::FilterEffectsItem>,
|
||||
}
|
||||
|
||||
fn doc_mode(m: PsdMode) -> Option<ColorMode> {
|
||||
@@ -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<LayerMask> {
|
||||
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<photocraft_psd::BlendMode>) {
|
||||
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<String>) {
|
||||
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;
|
||||
|
||||
@@ -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<SmartFilter>,
|
||||
/// `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<FilterStack>,
|
||||
}
|
||||
|
||||
fn text_of(d: &Descriptor, key: &str) -> Option<String> {
|
||||
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<Descriptor> {
|
||||
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<Placed> {
|
||||
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<f64> {
|
||||
num(d.get(key)).filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
fn int(d: &Descriptor, key: &str) -> Option<i32> {
|
||||
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<Vec<&Descriptor>> {
|
||||
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<J> {
|
||||
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<i32> {
|
||||
match v {
|
||||
Value::Integer(i) => Some(*i),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn curves_params(f: &Descriptor) -> Option<J> {
|
||||
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<Vec<J>> = 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<Vec<u8>> {
|
||||
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<Value, String> {
|
||||
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<Vec<Value>> = 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<Option<Descriptor>, 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<Descriptor, String> {
|
||||
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<String>) -> 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<Descriptor>,
|
||||
}
|
||||
|
||||
/// 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<String>) -> 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<u8>) -> Vec<u8> {
|
||||
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<String>) -> Vec<u8> {
|
||||
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<String>) -> Vec<u8> {
|
||||
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<Affine> {
|
||||
let Some(Value::List(pts)) = d.get("Trnf") else { return None };
|
||||
let sz = get_desc(d, "Sz ")?;
|
||||
let p: Vec<f64> = 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<Option<LayerMask>, 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<FilterEffectsItem> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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/<mode><bits>`).
|
||||
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 {
|
||||
|
||||
@@ -37,7 +37,10 @@ fn plld(uuid: &str) -> Vec<u8> {
|
||||
fn sold(uuid: &str) -> Vec<u8> {
|
||||
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);
|
||||
|
||||
@@ -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<PathBuf>) {
|
||||
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<Vec<u8>> {
|
||||
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<f32> {
|
||||
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<String> {
|
||||
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<u8>), 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::<Vec<_>>() {
|
||||
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::<Vec<_>>() {
|
||||
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<String> = 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<String> = 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());
|
||||
}
|
||||
@@ -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<u8>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<Vec<u8>> {
|
||||
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<Option<EffectsPlane>>,
|
||||
/// 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<u8>)> {
|
||||
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<FilterEffectsItem>,
|
||||
}
|
||||
|
||||
fn read_rect(r: &mut Reader<'_>) -> Result<Rect> {
|
||||
Ok(Rect { top: r.i32()?, left: r.i32()?, bottom: r.i32()?, right: r.i32()? })
|
||||
}
|
||||
|
||||
fn write_rect(out: &mut Vec<u8>, 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<EffectsPlane> {
|
||||
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<u8>, 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<Self> {
|
||||
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<u8> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
+5
-5
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user