Merge approved PhotoCraft PR #5176: Play CopyCat, AJ and Line and Color action sets; keep patterns across restarts; Shift Edge scale

Port behavior and regression coverage into current modules; preserve current upstream behavior.
Ported mappings and smart Save into current modules, preserving bounded authorized playback; migrated legacy PAT by committing validated per-pattern assets before atomic authoritative index; preserved same-ID replacements and builtin edits; added temporary-store interruption, corruption, duplicate ID/name and restart regression coverage. Current all-target compilation passed before final ownership guard refinement; focused tests pending.
This commit is contained in:
FredApps
2026-10-29 05:15:28 +01:00
25 changed files with 1146 additions and 161 deletions
+20 -14
View File
@@ -35,7 +35,9 @@ pub struct RefineParams {
pub feather: f32,
/// 0–100.
pub contrast: f32,
/// −100–100. 100% moves the edge by `max(radius, 4)` pixels. Values between whole
/// −100–100. 100% moves the edge by its soft width: `max(radius, feather, 2 × smooth σ)`,
/// at least 1 px, so a hard edge moves a pixel (Photoshop's Shift Edge moves soft borders;
/// a band of several pixels around hard line art fills it in solid). Values between whole
/// pixels blend the two neighbouring shifts, so the slider does not jump.
pub shift_edge: f32,
}
@@ -60,9 +62,11 @@ impl RefineParams {
fn feather_sigma(&self) -> f32 {
self.feather.max(0.0) / 2.0
}
/// Signed shift in pixels, fractional. 100% is `max(radius, 4)` px, clamped to ±64.
/// Signed shift in pixels, fractional. 100% is the edge's soft width (at least 1 px),
/// clamped to ±64.
fn shift_amount(&self) -> f32 {
let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0);
let soft = self.radius.max(self.feather).max(2.0 * self.smooth_sigma()).max(1.0);
let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * soft;
if px.is_finite() { px.clamp(-64.0, 64.0) } else { 0.0 }
}
/// How many pixels the mask can move: the farther of the two integer radii a fractional
@@ -765,22 +769,24 @@ mod tests {
assert!(f[5 * w + 29] > 0.5 && f[5 * w + 29] < 0.8 && f[5 * w + 31] > 0.1);
let c = refine_buffer(None, &f, w, h, &RefineParams { contrast: 100.0, ..Default::default() });
assert!(c[5 * w + 29] == 1.0 && c[5 * w + 31] == 0.0);
// A hard edge (no radius, feather or smooth) moves 1 px at 100%, so 50% is half a
// pixel. The pixel just outside fades in instead of snapping on.
let grown = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 100.0, ..Default::default() });
assert_eq!(grown[5 * w + 33], 1.0);
assert_eq!(grown[5 * w + 34], 0.0);
let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() });
assert_eq!(shrunk[5 * w + 27], 1.0);
assert_eq!(shrunk[5 * w + 28], 0.0);
// Radius 0 scales 100% to 4 px, so 12.5% is half a pixel and 25% is one pixel.
// The pixel just outside a hard edge fades in instead of snapping on.
let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 12.5, ..Default::default() });
let one = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 25.0, ..Default::default() });
assert_eq!(m[5 * w + 30], 0.0);
assert_eq!(grown[5 * w + 30], 1.0);
assert_eq!(grown[5 * w + 31], 0.0);
let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -100.0, ..Default::default() });
assert_eq!(shrunk[5 * w + 28], 1.0);
assert_eq!(shrunk[5 * w + 29], 0.0);
let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 50.0, ..Default::default() });
assert!((half[5 * w + 30] - 0.5).abs() < 0.02, "half pixel {}", half[5 * w + 30]);
assert_eq!(one[5 * w + 30], 1.0);
assert_eq!(half[5 * w + 31], 0.0, "the blend must not jump a second pixel");
let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -12.5, ..Default::default() });
let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() });
assert!((pulled[5 * w + 29] - 0.5).abs() < 0.02, "half-pixel contract {}", pulled[5 * w + 29]);
// A softer edge moves farther: 100% is the feather, here 4 px.
let wide = refine_buffer(None, &m, w, h, &RefineParams { feather: 4.0, contrast: 100.0, shift_edge: 100.0, ..Default::default() });
assert_eq!(wide[5 * w + 33], 1.0);
assert_eq!(wide[5 * w + 34], 0.0);
let sm = refine_buffer(None, &m, w, h, &RefineParams { smooth: 50.0, ..Default::default() });
assert!(sm[5 * w + 10] == 1.0 && sm[5 * w + 50] == 0.0);
}
+6 -1
View File
@@ -496,6 +496,11 @@ fn noop_step(s: &Session, id: &str) -> bool {
id == "select.deselect" && s.active().is_some_and(|d| d.doc.selection.is_none())
}
pub const SAVE_CONTENTS: &str = "layer.smartObjects.saveContents";
pub fn saves_smart_contents(s: &Session, id: &str) -> bool {
id == "file.save" && s.is_enabled(SAVE_CONTENTS)
}
fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option<Value>) {
let mut ran = 0u64;
let mut failed = None;
@@ -514,7 +519,7 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option<V
ran += 1;
continue;
}
match playback_params(s, id, params).and_then(|p| s.execute(id, p)) {
match playback_params(s, id, params).and_then(|p| if saves_smart_contents(s, id) { s.execute(SAVE_CONTENTS, json!({})) } else { s.execute(id, p) }) {
Ok(value) => {
if let Some(error) = nested_failure(id, &value) {
failed = Some(json!({"step": i, "id": id, "error": error}));
@@ -5,6 +5,30 @@ fn pixel(s: &Session, layer: usize, x: i32, y: i32) -> Vec<f32> {
s.active().unwrap().doc.layers[layer].surface().unwrap().pixel(x, y)
}
/// CopyCat's shape: Edit Contents, change the contents, Save. The Save step has no file to
/// write: it updates the smart object, headless too.
#[test]
fn save_in_edit_contents_updates_the_smart_object() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
s.execute("layer.smartObjects.editContents", json!({})).unwrap();
let steps = vec![("edit.fill".to_string(), json!({"contents": "color", "color": "#0000ff"})), ("file.save".to_string(), json!({}))];
s.actions.list.push(Action::new("Edit and save", steps));
let played = s.execute("actions.play", json!({"action": "Edit and save"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
s.set_active(0);
let doc = &s.active().unwrap().doc;
let flat = crate::file_cmds::flattened(doc, doc.pixel_format());
let p = flat.pixel(2, 2);
assert!(p[0] < 0.01 && p[2] > 0.99, "the smart object shows the saved contents: {p:?}");
// With no Edit Contents document, Save stays the shell's (it needs the document's file).
s.actions.list.push(Action::new("Save", vec![("file.save".to_string(), json!({}))]));
assert!(s.execute("actions.play", json!({"action": "Save"})).unwrap().get("failed").is_some());
}
#[test]
fn deselect_with_nothing_selected_is_a_noop_in_playback() {
// #6744: imported actions often start with Deselect; with no selection Photoshop plays on.
+25 -3
View File
@@ -1,3 +1,4 @@
const DIFFUSE_MODE: &[(&str, &str)] = &[("Nrml", "normal"), ("DrkO", "darkenOnly"), ("LghO", "lightenOnly"), ("Anst", "anisotropic")];
use super::adjustments::{adjustment_by_class, adjustment_steps, levels_from, levels_to, shadows_highlights_from, shadows_highlights_to};
use super::descriptors::{desc, get_bool, get_ref, jbool, layer_ref, layer_target, ref_class};
use super::fields::{F, Kind, Mapped, fields_from, fields_to, none, step};
@@ -14,7 +15,7 @@ use super::steps_layer::{
STROKE, apply_comp_from, apply_comp_to, clear_from, clear_to, delete_from, delete_to, duplicate_from, duplicate_to, fill_from, fill_to, flip_from, flip_to,
move_from, move_to, play_from, play_to, rotate_from, rotate_to, select_from, select_to, stroke_to, visibility_from, visibility_to,
};
use super::steps_make::{make_from, make_to, set_from, set_to};
use super::steps_make::{locking_from, locking_to, make_from, make_to, set_from, set_to};
use super::tables::{NOISE, OFFSET_FILL, SMART_BLUR_MODE, SMART_BLUR_QUALITY, TRIM};
use super::{AtnEvent, D, Descriptor, STOP_MESSAGE, Value, json};
@@ -248,6 +249,7 @@ pub(super) const EVENTS: &[Event] = &[
),
ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::ExactLong), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]),
ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::ExactLong)]),
ev!("Dfs ", "diffuse", "Diffuse", "filter.stylize.diffuse", &[F("Md ", "mode", Kind::Enum("DfsM", DIFFUSE_MODE)), F("FlRs", "seed", Kind::Long)]),
Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to },
// Adjustments (Image › Adjustments).
Event {
@@ -385,6 +387,7 @@ pub(super) const EVENTS: &[Event] = &[
"layer.layerMask.hideSelection",
"file.new",
"file.newFromClipboard",
"history.newSnapshot",
],
from: make_from,
to: make_to,
@@ -408,6 +411,8 @@ pub(super) const EVENTS: &[Event] = &[
"layer.setAdjustment",
"layer.fill.pattern.set",
"layer.new.layerFromBackground",
"layer.layerStyle.blendingOptions",
"layer.smartFilter.blendingOptions",
],
from: set_from,
to: set_to,
@@ -436,8 +441,25 @@ pub(super) const EVENTS: &[Event] = &[
from: select_from,
to: select_to,
},
Event { code: "Dlt ", name: "delete", label: "Delete", ids: &["layer.delete", "edit.clear", "layer.layerMask.delete"], from: delete_from, to: delete_to },
Event { code: "", name: "clearEvent", label: "Clear", ids: &["select.editInQuickMaskMode"], from: clear_from, to: clear_to },
Event {
code: "Dlt ",
name: "delete",
label: "Delete",
ids: &["layer.delete", "edit.clear", "layer.layerMask.delete", "layer.smartFilter.deleteFilterMask"],
from: delete_from,
to: delete_to,
},
Event { code: "", name: "applyLocking", label: "Apply Locking", ids: &["layer.lockLayers"], from: locking_from, to: locking_to },
// Timeline › Make Frames from Layers: no frame animation in PhotoCraft, no document change.
Event {
code: "",
name: "animationFramesFromLayers",
label: "Make Frames from Layers",
ids: &[],
from: |_| Mapped::Unsupported { harmless: true },
to: |_, _| None,
},
Event { code: "", name: "clearEvent", label: "Clear", ids: &["select.editInQuickMaskMode", "history.clear"], from: clear_from, to: clear_to },
Event {
code: "Trnf",
name: "transform",
@@ -286,6 +286,8 @@ const REFINE_OUTPUT: &[(&str, &str)] = &[
("refineEdgeOutputNewLayerWithMask", "newLayerWithMask"),
("refineEdgeOutputNewDocument", "newDocument"),
("refineEdgeOutputNewDocumentWithMask", "newDocumentWithMask"),
// Newer Photoshop's name for Output To: Selection.
("selectionOutputToSelection", "selection"),
];
/// Filter Gallery effects (`GEfc` with the effect in `GEfk`): Photoshop's effect code, the
@@ -117,6 +117,15 @@ pub(super) fn delete_from(d: &Descriptor) -> Mapped {
// The targeted channel of a layer is its mask (what Photoshop records after making an
// adjustment layer over a selection and discarding the mask).
Some(r) if ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Ordinal(o) if o == "Trgt") => none("layer.layerMask.delete"),
// The mask Photoshop adds to a new fill or adjustment layer, or to a smart object's filters.
Some(r) if ref_class(r).as_deref() == Some("Chnl") => match target(r) {
Target::Ordinal(o) if o == "Msk" => none("layer.layerMask.delete"),
// Photoshop adds an empty filter mask with the first smart filter; PhotoCraft doesn't.
Target::Ordinal(o) if o == "filterMask" => step("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})),
_ => Mapped::Unsupported { harmless: false },
},
// Timeline frames: PhotoCraft has no frame animation, so there is nothing to delete.
Some(r) if ref_class(r).as_deref() == Some("animationClass") => Mapped::Unsupported { harmless: true },
Some(_) => Mapped::Unsupported { harmless: false },
}
}
@@ -131,6 +140,8 @@ pub(super) fn quick_mask_ref() -> D {
pub(super) fn clear_from(d: &Descriptor) -> Mapped {
match get_ref(d, "null").map(target) {
Some(Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": false})),
// History panel menu › Clear History.
Some(Target::Property(p)) if p == "HsSt" => none("history.clear"),
None => none("edit.clear"),
_ => Mapped::Unsupported { harmless: false },
}
@@ -139,6 +150,13 @@ pub(super) fn clear_from(d: &Descriptor) -> Mapped {
pub(super) fn clear_to(id: &str, p: &Value) -> Option<Descriptor> {
match id {
"select.editInQuickMaskMode" if jbool(p, "on") == Some(false) => Some(desc("null").with("null", quick_mask_ref())),
"history.clear" => Some(desc("null").with(
"null",
D::Reference(vec![
ReferenceItem::Property { class: class("Prpr"), key: code("HsSt") },
ReferenceItem::Enumerated { class: class("Dcmn"), type_id: code("Ordn"), value: code("Trgt") },
]),
)),
_ => None,
}
}
@@ -148,6 +166,8 @@ pub(super) fn delete_to(id: &str, p: &Value) -> Option<Descriptor> {
"edit.clear" => desc("null"),
"layer.delete" => desc("null").with("null", layer_ref(p)?),
"layer.layerMask.delete" => desc("null").with("null", target_ref("Chnl")),
"layer.smartFilter.deleteFilterMask" => desc("null")
.with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: Id::new("filterMask") }])),
_ => return None,
})
}
+172 -1
View File
@@ -136,6 +136,9 @@ fn new_document_from_descriptor(d: &Descriptor) -> Option<Mapped> {
Some(step("file.new", params))
}
/// New Snapshot's From: the full document, the layers merged, or the current layer.
const SNAPSHOT_FROM: &[(&str, &str)] = &[("FllD", "fullDocument"), ("MrgL", "mergedLayers"), ("CrnL", "currentLayer")];
pub(super) fn make_from(d: &Descriptor) -> Mapped {
if let Some(mapped) = duplicate_layer_from_descriptor(d) {
return mapped;
@@ -198,6 +201,24 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped {
None => step("layer.new.group", Value::Object(p)),
}
}
// History panel › New Snapshot (of the current state).
Some("SnpS") => {
let mut p = Map::new();
if let Some(n) = get_text(d, "Nm ") {
p.insert("name".into(), n.into());
}
match get_enum(d, "Usng").as_deref().map(|u| lookup(SNAPSHOT_FROM, u)) {
None => {}
Some(Some(f)) => {
p.insert("from".into(), f.into());
}
Some(None) => return Mapped::Unsupported { harmless: false },
}
if get_bool(d, "replaceExisting") == Some(true) {
p.insert("replaceExisting".into(), true.into());
}
step("history.newSnapshot", Value::Object(p))
}
_ => Mapped::Unsupported { harmless: false },
}
}
@@ -286,6 +307,18 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option<Descriptor> {
desc("null").with("Nw ", D::Class(class("Chnl"))).with("At ", at).with("Usng", enm("UsrM", lookup_back(MASK_USING, id)?))
}
"file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))),
"history.newSnapshot" => {
let current = D::Reference(vec![ReferenceItem::Property { class: class("HstS"), key: code("CrnH") }]);
let mut d = desc("null").with("null", class_ref("SnpS")).with("From", current);
if let Some(n) = jstr(p, "name") {
d = d.with("Nm ", text(n));
}
d = d.with("Usng", enm("HstS", lookup_back(SNAPSHOT_FROM, jstr(p, "from").unwrap_or("fullDocument"))?));
if jbool(p, "replaceExisting") == Some(true) {
d = d.with("replaceExisting", D::Boolean(true));
}
d
}
"file.new" => {
let mut n = desc("Dcmn");
if let Some(name) = jstr(p, "name") {
@@ -401,6 +434,56 @@ fn background_from(d: &Descriptor) -> Mapped {
steps(v)
}
/// The Layers panel lock buttons (`layerLocking`) ↔ `layer.lockLayers` params.
const LOCKS: &[(&str, &str)] = &[
("protectTransparency", "transparency"),
("protectComposite", "pixels"),
("protectPosition", "position"),
("protectArtboardAutonest", "artboard"),
("protectAll", "all"),
];
/// Blending Options' Advanced checkboxes ↔ `layer.layerStyle.blendingOptions` params.
const ADVANCED_BLENDING: &[(&str, &str)] = &[("blendClipped", "blendClippedLayersAsGroup"), ("transparencyShapesLayer", "transparencyShapesLayer")];
fn lock_params(locks: &Descriptor) -> Option<Map<String, Value>> {
let mut m = Map::new();
// "Unlock all" clears every lock.
if get_bool(locks, "protectNone") == Some(true) {
for (_, param) in LOCKS {
m.insert((*param).into(), false.into());
}
}
for (key, param) in LOCKS {
if let Some(b) = get_bool(locks, key) {
m.insert((*param).into(), b.into());
}
}
(!m.is_empty()).then_some(m)
}
/// `applyLocking`: a lock button in the Layers panel, on the target layer.
pub(super) fn locking_from(d: &Descriptor) -> Mapped {
let on_target = get_ref(d, "null").is_none_or(|r| ref_class(r).as_deref() == Some("Lyr") && matches!(target(r), Target::Ordinal(o) if o == "Trgt"));
match get_obj(d, "layerLocking").and_then(lock_params) {
Some(l) if on_target => step("layer.lockLayers", Value::Object(l)),
_ => Mapped::Unsupported { harmless: false },
}
}
pub(super) fn locking_to(_: &str, p: &Value) -> Option<Descriptor> {
let mut locks = desc("layerLocking");
for (key, param) in LOCKS {
if let Some(b) = jbool(p, param) {
locks = locks.with(key, D::Boolean(b));
}
}
if locks.items.is_empty() {
return None;
}
Some(desc("null").with("null", target_ref("Lyr ")).with("layerLocking", D::Descriptor(locks)))
}
pub(super) fn set_from(d: &Descriptor) -> Mapped {
let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } };
let unsupported = Mapped::Unsupported { harmless: false };
@@ -441,10 +524,65 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped {
if let Some(vis) = get_bool(t, "Vsbl") {
p.insert("visible".into(), vis.into());
}
// Lock buttons and Blending Options' Advanced checkboxes act on the target layer.
let targeted = !p.contains_key("layer");
if let Some(locks) = get_obj(t, "layerLocking") {
match lock_params(locks) {
Some(l) if targeted => v.push(("layer.lockLayers", Value::Object(l))),
_ => return unsupported,
}
}
let mut advanced = Map::new();
for (key, param) in ADVANCED_BLENDING {
if let Some(b) = get_bool(t, key) {
advanced.insert((*param).into(), b.into());
}
}
if !advanced.is_empty() {
if !targeted {
return unsupported;
}
v.push(("layer.layerStyle.blendingOptions", Value::Object(advanced)));
}
if p.keys().any(|k| k != "layer") {
v.push(("layer.setProps", Value::Object(p)));
}
if v.is_empty() { unsupported } else { steps(v) }
// The Layer Style dialog's OK on a layer without effects records only the effects'
// scale (`Lefx` = {Scl}); an empty `T` is an OK that changed nothing. Neither changes
// the document.
let no_effect = t.items.iter().all(|(k, val)| match val {
D::Descriptor(fx) if k.is("Lefx") => fx.items.iter().all(|(fk, _)| fk.is("Scl ")),
_ => false,
});
if !v.is_empty() {
steps(v)
} else if no_effect {
Mapped::Unsupported { harmless: true }
} else {
unsupported
}
}
// Layer › Smart Filter › Blending Options (`indx` counts the filters from 1, bottom first).
(Some("filterFX"), _) => {
let index = match r.first() {
Some(ReferenceItem::Index { index, .. }) if *index >= 1 => index - 1,
_ => return unsupported,
};
let on_target =
matches!(r.get(1..), Some(rest) if ref_class(rest).as_deref() == Some("Lyr") && matches!(target(rest), Target::Ordinal(o) if o == "Trgt"));
if !on_target {
return unsupported;
}
let Some(opts) = get_obj(d, "filterFX").and_then(|f| get_obj(f, "blendOptions")) else { return unsupported };
let mut p = json!({"index": index});
if let Some(o) = get_num(opts, "Opct") {
p["opacity"] = json!(o / 100.0);
}
if let Some(m) = get_enum(opts, "Md ") {
let Some(label) = blend_label(&m) else { return unsupported };
p["blend"] = json!(label);
}
step("layer.smartFilter.blendingOptions", p)
}
// A Pattern Fill layer's pattern (double-click its thumbnail).
(Some("contentLayer"), Target::Ordinal(o)) if o == "Trgt" => match get_obj(d, "T ") {
@@ -578,6 +716,39 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
}
desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(pattern_desc(p)?))
}
"layer.layerStyle.blendingOptions" => {
let o = p.as_object()?;
let mut t = desc("Lyr ");
for (key, param) in ADVANCED_BLENDING {
if let Some(b) = jbool(p, param) {
t = t.with(key, D::Boolean(b));
}
}
// Only the Advanced checkboxes export: Photoshop records the dialog's other options
// differently.
if t.items.is_empty() || t.items.len() != o.len() {
return None;
}
desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(t))
}
"layer.smartFilter.blendingOptions" => {
if p.get("layer").is_some() {
return None;
}
let index = u32::try_from(p.get("index")?.as_u64()?).ok()?.checked_add(1)?;
let mut opts = desc("blendOptions");
if let Some(o) = jnum(p, "opacity") {
opts = opts.with("Opct", pct(o * 100.0));
}
if let Some(m) = jstr(p, "blend") {
opts = opts.with("Md ", enm("BlnM", blend_ps(m)?));
}
let r = D::Reference(vec![
ReferenceItem::Index { class: class("filterFX"), index },
ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Trgt") },
]);
desc("null").with("null", r).with("filterFX", D::Descriptor(desc("filterFX").with("blendOptions", D::Descriptor(opts))))
}
"layer.setAdjustment" => {
// The adjustment kind isn't in the params: Photoshop needs it, so only steps that
// name one (`kind`) export.
+10
View File
@@ -704,6 +704,7 @@ fn every_mapped_command_exports_and_imports_back() {
("edit.undo", json!({})),
("edit.redo", json!({})),
("layer.select", json!({"layer": "YELLOW", "mode": "remove"})),
("layer.select", json!({"layer": "Or paste photocopy texture here", "mode": "range"})),
("layer.translate", json!({"dx": 0.36, "dy": -1.08, "units": "pt"})),
("select.loadSelection", json!({"channel": "targeted"})),
("layer.newFillLayer.pattern", json!({"pattern": "Debaser-Distressed-Dark", "scale": 50, "angle": 0, "link": true})),
@@ -711,6 +712,15 @@ fn every_mapped_command_exports_and_imports_back() {
("image.mode.bitmap", json!({"method": "threshold"})),
("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true})),
("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false})),
// COPYCAT, AJ_Actions2023 and the Line and Color tutorial set.
("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})),
("layer.smartFilter.blendingOptions", json!({"index": 1, "opacity": 0.5, "blend": "Darken"})),
("layer.layerStyle.blendingOptions", json!({"blendClippedLayersAsGroup": false, "transparencyShapesLayer": false})),
("layer.createClippingMask", json!({})),
("layer.lockLayers", json!({"transparency": true})),
("filter.stylize.diffuse", json!({"mode": "normal", "seed": 321654})),
("history.clear", json!({})),
("history.newSnapshot", json!({"name": "CopyCat - action result", "from": "fullDocument", "replaceExisting": true})),
("layerComp.apply", json!({"comp": "Comp 1"})),
];
for (id, params) in &cases {
+43 -1
View File
@@ -130,6 +130,16 @@ fn name_param(cmd: &str, p: &Value) -> Result<Option<String>> {
}
}
/// History panel menu › Clear History: drops every state of the active document but its current
/// one, keeping the snapshots (Edit › Purge › Histories clears every open document). Undoable in
/// Photoshop only by its own Undo; here, as with Purge, it is not.
fn clear_history(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
st.history.clear();
st.coalesce = None;
Ok(Value::Null)
}
/// History panel › Create new snapshot (the camera button): a named copy of the document,
/// "Snapshot N" by default. `from` is the New Snapshot dialog's From: the full document (default),
/// its layers merged into one, or only the active layer. Not a history state.
@@ -163,6 +173,10 @@ fn new_snapshot(s: &mut Session, p: &Value) -> Result<Value> {
let name = name.unwrap_or_else(|| next_snapshot_name(st));
let id = st.next_snapshot;
st.next_snapshot = id.saturating_add(1);
// An action's New Snapshot with "replace existing": a snapshot of that name gives way.
if p.get("replaceExisting").and_then(Value::as_bool) == Some(true) {
st.snapshots.retain(|snap| snap.name != name);
}
st.snapshots.push(Snapshot { id, name: name.clone(), doc, layers });
Ok(json!({ "snapshot": id, "name": name }))
}
@@ -284,11 +298,21 @@ pub fn specs() -> Vec<CommandSpec> {
label: "New Snapshot",
menu: &[],
shortcut: None,
params: r##"{"name":str?="Snapshot N","from":"fullDocument"|"mergedLayers"|"currentLayer"="fullDocument"} → {snapshot,name}: a named copy of the document for the session (not a history state)"##,
params: r##"{"name":str?="Snapshot N","from":"fullDocument"|"mergedLayers"|"currentLayer"="fullDocument","replaceExisting":bool=false (drop snapshots of the same name first)} → {snapshot,name}: a named copy of the document for the session (not a history state)"##,
enabled: has_doc,
run: new_snapshot,
journal: true,
},
CommandSpec {
id: "history.clear",
label: "Clear History",
menu: &[],
shortcut: None,
params: "{} (the active document's states; snapshots stay)",
enabled: has_doc,
run: clear_history,
journal: true,
},
CommandSpec {
id: "history.selectSnapshot",
label: "Select Snapshot",
@@ -442,6 +466,24 @@ mod tests {
assert_eq!(snapshots(&s), json!([first, {"id": 1, "name": "Snapshot 1"}, {"id": 2, "name": "Before green"}]));
}
#[test]
fn replace_existing_snapshot_and_clear_history() {
let mut s = session();
s.execute("history.newSnapshot", json!({"name": "Result"})).unwrap();
s.execute("history.newSnapshot", json!({"name": "Other"})).unwrap();
s.execute("history.newSnapshot", json!({"name": "Result", "replaceExisting": true})).unwrap();
assert_eq!(snapshots(&s), json!([{"id": 2, "name": "Other"}, {"id": 3, "name": "Result"}]));
let pixel = top_pixel(&s);
s.execute("history.clear", json!({})).unwrap();
assert_eq!(entries(&s).len(), 1, "only the current state is left");
assert!(!s.active().unwrap().history.can_undo());
assert_eq!(top_pixel(&s), pixel, "the document is unchanged");
assert_eq!(snapshots(&s).as_array().map(Vec::len), Some(2), "snapshots stay");
// Nothing left to clear is not an error (an action may clear twice).
s.execute("history.clear", json!({})).unwrap();
assert!(Session::new().execute("history.clear", json!({})).is_err(), "no document");
}
#[test]
fn select_snapshot_returns_to_it_as_one_undoable_state() {
let mut s = session();
+12 -4
View File
@@ -2,9 +2,13 @@ use super::enabled::check_all_unlocked;
use super::layer_params::{layer_param, mask_at_depth};
use super::{Document, EngineError, Layer, LayerContent, LayerId, LayerMask, Result, Session, Value, json, pixels};
/// Bring to Front / Send to Back of a layer already there: nothing to do, which isn't an error
/// (a Photoshop action plays it without complaint).
const ALREADY_THERE: &str = "the layer is already at the front or back";
pub(super) fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result<Value> {
let id = layer_param(s, p)?;
s.edit("Arrange", |doc, _| {
let edited = s.edit("Arrange", |doc, _| {
// The Background stays at the bottom: it doesn't move and nothing goes below it.
let bg = crate::background_cmds::background_id(doc);
if bg == Some(id) {
@@ -29,12 +33,16 @@ pub(super) fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result<Value> {
moved = true;
}
if !moved {
return Err(EngineError::Other("layer can't move further".into()));
let end = matches!(delta, i32::MAX | i32::MIN);
return Err(EngineError::Other(if end { ALREADY_THERE } else { "layer can't move further" }.into()));
}
crate::clip_group::settle(doc, &[id], &before, false);
Ok(())
})?;
Ok(Value::Null)
});
match edited {
Err(EngineError::Other(m)) if m == ALREADY_THERE => Ok(json!({"moved": false})),
r => r.map(|_| Value::Null),
}
}
/// Add (`Some`) or remove (`None`) a layer mask. Adding acts on every selected layer that can take
+58 -16
View File
@@ -25,17 +25,21 @@ pub struct PatternLibrary {
/// Bumped when a pattern's pixels are replaced under the same id (an imported `.pat` whose
/// id is already in the library), which its id, name and size don't show.
pub pixels_rev: u64,
/// Saved patterns held as a small preview until used, by id: their full size. The preset
/// store reads the pixels ([`full`]); everything that paints goes through [`resolve`].
pub stored: std::collections::HashMap<String, (u32, u32)>,
}
impl Default for PatternLibrary {
fn default() -> Self {
PatternLibrary { items: builtin(), pixels_rev: 0 }
PatternLibrary { items: builtin(), pixels_rev: 0, stored: Default::default() }
}
}
impl PatternLibrary {
/// Add `pat`, or replace the pattern with its id (marking the pixels changed).
pub fn upsert(&mut self, pat: Pattern) {
self.stored.remove(&pat.id);
match self.items.iter_mut().find(|q| q.id == pat.id) {
Some(q) => {
*q = pat;
@@ -132,24 +136,59 @@ fn has_pattern(s: &Session) -> std::result::Result<(), String> {
if s.patterns.items.is_empty() && s.active().is_none_or(|d| d.doc.patterns.is_empty()) { Err("no patterns".into()) } else { Ok(()) }
}
/// Finds a pattern by id or name: the active document's first, then the library. An empty key
/// picks the first library pattern (Photoshop's default).
pub fn resolve(s: &Session, key: &str) -> Option<Pattern> {
if key.is_empty() {
// The Patterns panel's selection, else the first library pattern.
return crate::presets::patterns::current(s).or(s.patterns.items.first()).cloned();
/// `p` with its full pixels: a saved pattern held as a preview is read from the preset store.
pub fn full(s: &Session, p: &Pattern) -> std::result::Result<Pattern, String> {
if !s.patterns.stored.contains_key(&p.id) {
return Ok(p.clone());
}
if let Some(d) = s.active()
let st = s.preset_store.as_ref().ok_or_else(|| format!("pattern \"{}\" is not loaded: no preset store", p.display_name()))?;
let mut f = st.load_pattern(&p.id).map_err(|e| format!("pattern \"{}\" could not be loaded: {e}", p.display_name()))?;
f.name = p.name.clone();
Ok(f)
}
/// The library pattern `key` names (the panel's selection or the first for an empty key).
fn library_pattern<'a>(s: &'a Session, key: &str) -> Option<&'a Pattern> {
if key.is_empty() {
return crate::presets::patterns::current(s).or(s.patterns.items.first());
}
photocraft_doc::pattern::find(&s.patterns.items, key, key)
}
/// The id of the pattern [`resolve`] finds, without reading a saved pattern's pixels: for checks
/// and lookups that don't paint.
pub fn resolve_id(s: &Session, key: &str) -> Option<String> {
if !key.is_empty()
&& let Some(d) = s.active()
&& let Some(p) = photocraft_doc::pattern::find(&d.doc.patterns, key, key)
{
return Some(p.id.clone());
}
library_pattern(s, key).map(|p| p.id.clone())
}
/// Finds a pattern by id or name: the active document's first, then the library. An empty key
/// picks the first library pattern (Photoshop's default). `None` also when a saved pattern's
/// pixels can't be read (see [`resolve_param`] for the reason).
pub fn resolve(s: &Session, key: &str) -> Option<Pattern> {
if !key.is_empty()
&& let Some(d) = s.active()
&& let Some(p) = photocraft_doc::pattern::find(&d.doc.patterns, key, key)
{
return Some(p.clone());
}
photocraft_doc::pattern::find(&s.patterns.items, key, key).cloned()
full(s, library_pattern(s, key)?).ok()
}
pub(crate) fn resolve_param(s: &Session, cmd: &str, p: &Value) -> Result<Pattern> {
let key = p.get("pattern").and_then(Value::as_str).unwrap_or("");
resolve(s, key).ok_or_else(|| bad(cmd, format!("no pattern \"{key}\" (see pattern.list)")))
if let Some(pat) = resolve(s, key) {
return Ok(pat);
}
match library_pattern(s, key).map(|lp| full(s, lp)) {
Some(Err(e)) => Err(bad(cmd, e)),
_ => Err(bad(cmd, format!("no pattern \"{key}\" (see pattern.list)"))),
}
}
/// Copies `pat` into the document's patterns unless it is already there (by id).
@@ -174,10 +213,10 @@ pub fn resolve_effect(s: &Session, fx: photocraft_doc::Effect) -> Result<(photoc
}
}
fn info(p: &Pattern, in_doc: bool, in_library: bool) -> Value {
fn info(p: &Pattern, size: (u32, u32), in_doc: bool, in_library: bool) -> Value {
let f = p.surface.format();
json!({
"id": p.id, "name": p.display_name(), "storedName": p.name, "width": p.width, "height": p.height,
"id": p.id, "name": p.display_name(), "storedName": p.name, "width": size.0, "height": size.1,
"mode": format!("{:?}", f.mode), "depth": format!("{:?}", f.sample), "alpha": f.alpha,
"inDocument": in_doc, "inLibrary": in_library,
})
@@ -185,8 +224,9 @@ fn info(p: &Pattern, in_doc: bool, in_library: bool) -> Value {
fn list(s: &mut Session, _: &Value) -> Result<Value> {
let doc_pats: Vec<Pattern> = s.active().map(|d| d.doc.patterns.clone()).unwrap_or_default();
let mut out: Vec<Value> = s.patterns.items.iter().map(|p| info(p, doc_pats.iter().any(|d| d.id == p.id), true)).collect();
out.extend(doc_pats.iter().filter(|d| !s.patterns.items.iter().any(|p| p.id == d.id)).map(|p| info(p, true, false)));
let size = |p: &Pattern| s.patterns.stored.get(&p.id).copied().unwrap_or((p.width, p.height));
let mut out: Vec<Value> = s.patterns.items.iter().map(|p| info(p, size(p), doc_pats.iter().any(|d| d.id == p.id), true)).collect();
out.extend(doc_pats.iter().filter(|d| !s.patterns.items.iter().any(|p| p.id == d.id)).map(|p| info(p, (p.width, p.height), true, false)));
Ok(Value::Array(out))
}
@@ -250,7 +290,7 @@ fn define(s: &mut Session, p: &Value) -> Result<Value> {
};
let pat = Pattern::new(name, surf, r.width(), r.height());
let id = pat.id.clone();
let out = info(&pat, false, true);
let out = info(&pat, (pat.width, pat.height), false, true);
if !s.patterns.items.iter().any(|q| q.id == id) {
s.patterns.items.push(pat);
}
@@ -285,6 +325,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = library_index(s, "pattern.delete", p)?;
let pat = s.patterns.items.remove(i);
s.patterns.stored.remove(&pat.id);
Ok(json!({"deleted": pat.id}))
}
@@ -300,6 +341,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let mut ids = Vec::new();
for pat in pats {
ids.push(pat.id.clone());
s.patterns.stored.remove(&pat.id);
s.patterns.upsert(pat);
}
Ok(json!({"imported": ids, "count": ids.len()}))
@@ -312,7 +354,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
Some(keys) => {
keys.iter().filter_map(Value::as_str).map(|k| resolve(s, k).ok_or_else(|| bad(cmd, format!("no pattern \"{k}\"")))).collect::<Result<_>>()?
}
None => s.patterns.items.clone(),
None => s.patterns.items.iter().map(|p| full(s, p).map_err(|e| bad(cmd, e))).collect::<Result<_>>()?,
};
let bytes = photocraft_io::pattern_map::write_pat(&pats).map_err(EngineError::Other)?;
crate::file_cmds::write_file(path, &bytes)?;
@@ -21,7 +21,7 @@ const STREAM_PHASE_Y: u64 = 102;
pub(super) fn enabled(s: &Session) -> std::result::Result<(), String> {
has_pixel_layer(s)?;
if crate::pattern_cmds::resolve(s, "").is_none() { Err("no pattern selected".into()) } else { Ok(()) }
if crate::pattern_cmds::resolve_id(s, "").is_none() { Err("no pattern selected".into()) } else { Ok(()) }
}
/// A parsed `paint.patternStamp`: shared by the command and its live preview, so both paint the
+23 -6
View File
@@ -102,6 +102,10 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> {
fn has_smart_filters(s: &Session) -> std::result::Result<(), String> {
if active_smart(s)?.smart_filters.is_empty() { Err("the smart object has no smart filters".into()) } else { Ok(()) }
}
/// Delete Filter Mask with `ifPresent` needs only a smart object.
pub(crate) fn smart_layer_active(s: &Session) -> std::result::Result<(), String> {
active_smart(s).map(|_| ())
}
fn has_filter_mask(s: &Session) -> std::result::Result<(), String> {
active_smart(s)?.filter_mask.as_ref().map(|_| ()).ok_or_else(|| "the smart object has no filter mask".into())
}
@@ -265,12 +269,25 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
spec!("layer.smartFilter.deleteFilterMask", "Delete Filter Mask", SF, r##"{"layer":id?}"##, has_filter_mask, |s, p| {
edit_filters(s, p, "Delete Filter Mask", |sm| {
sm.filter_mask = None;
Ok(())
})
}),
spec!(
"layer.smartFilter.deleteFilterMask",
"Delete Filter Mask",
SF,
r##"{"layer":id?,"ifPresent":bool=false (true: a smart object without a filter mask is left as it is, as a Photoshop action's step needs where Photoshop added the mask by default)}"##,
has_filter_mask,
|s, p| {
if p.get("ifPresent").and_then(Value::as_bool) == Some(true) {
let id = layer_param(s, p)?;
if smart(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?.filter_mask.is_none() {
return Ok(Value::Null);
}
}
edit_filters(s, p, "Delete Filter Mask", |sm| {
sm.filter_mask = None;
Ok(())
})
}
),
spec!(
"layer.smartFilter.disableFilterMask",
"Disable Filter Mask",
@@ -420,6 +420,13 @@ fn selection_becomes_the_filter_mask() {
s.execute("layer.smartFilter.deleteFilterMask", json!({})).unwrap();
assert!(active_smart(&s).filter_mask.is_none());
assert!(!s.is_enabled("layer.smartFilter.deleteFilterMask"));
// A Photoshop action deletes the mask Photoshop adds by default: with `ifPresent`, no mask
// is no step and no error. A layer that isn't a smart object still refuses.
let steps = s.active().unwrap().history.past_len();
assert_eq!(s.execute("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})).unwrap(), Value::Null);
assert_eq!(s.active().unwrap().history.past_len(), steps);
s.execute("layer.new.layer", json!({})).unwrap();
assert!(s.execute("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})).is_err());
}
/// #5619: deleting (or dragging away) the last smart filter removes the filter mask with it, so
@@ -451,8 +451,9 @@ fn refine_edge_outputs_to_a_new_document() {
#[test]
fn refine_edge_shift_and_contrast() {
let mut s = blurred_edge_selected();
// A hard selection moves 1 px at 100%: no band of several pixels.
s.execute("select.refineEdge", json!({"shiftEdge": 100})).unwrap();
assert!(cov(&s, 42, 20) > 0.99);
assert!(cov(&s, 40, 20) > 0.99 && cov(&s, 41, 20) < 0.01);
s.execute("edit.undo", json!({})).unwrap();
s.execute("select.refineEdge", json!({"feather": 8, "contrast": 100})).unwrap();
assert!(cov(&s, 38, 20) == 1.0 && cov(&s, 42, 20) == 0.0);
+61 -104
View File
@@ -35,14 +35,17 @@
//! changes asynchronously. Gradient presets, including imported `.grd` groups, are small and persist with
//! the preferences document (`presets.gradients`).
//!
//! The pattern library (imported `.pat` and defined patterns, not the built-ins) lives there too:
//! `patterns.json` plus one `.pat` file per pattern, see [`patterns`].
//!
//! The Actions list (`actions.json`, see [`crate::actions_cmds`]) lives in the same directory.
//! A missing file is an empty list. A corrupt or oversized file is skipped with a warning.
//! Headless sessions have no store, so their actions stay in memory.
//!
//! So do the patterns the user defined or imported (`patterns.pat`, Photoshop's own pattern file
//! format, #9317): the whole user library in its order, rewritten when its ids, names or order
//! change. The built-in patterns are regenerated on start and never written. Like `actions.json`,
//! a file that fails to load is warned about and never overwritten.
//! Patterns use `patterns.json` and lazily loaded per-pattern assets. On first startup a
//! valid legacy `patterns.pat` is migrated by writing assets before the atomic index commit.
//! Legacy and corrupt data are preserved; an existing index is authoritative even when corrupt.
//! A failed migration leaves the legacy library usable and retries at the next startup.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::Read;
@@ -54,6 +57,9 @@ use serde::{Deserialize, Serialize};
use crate::Session;
mod patterns;
pub use patterns::{Loaded as LoadedPattern, MAX_PATTERN_BYTES, PATTERNS_FILE, PREVIEW_SIDE as PATTERN_PREVIEW_SIDE};
/// Group file extension.
pub const GROUP_EXT: &str = "pcbrushes";
/// Tip file extension (inside [`TIPS_DIR`]).
@@ -73,7 +79,7 @@ pub const ACTIONS_FILE: &str = "actions.json";
/// Largest Actions file read or written.
pub const MAX_ACTIONS_BYTES: u64 = 16 << 20;
/// The user's pattern library (`.pat`).
pub const PATTERNS_FILE: &str = "patterns.pat";
pub const LEGACY_PATTERNS_FILE: &str = "patterns.pat";
/// Largest pattern library read or written.
pub const MAX_PATTERNS_BYTES: u64 = 256 << 20;
/// The whole store never grows beyond this; further groups are skipped with a warning.
@@ -562,13 +568,14 @@ pub struct PresetStore {
actions_defaults_version: u32,
/// `actions.json` exists but failed to load: left on disk untouched, so it is never written.
actions_skipped: bool,
/// [`patterns_key`] of the user patterns last written (or loaded).
patterns_key: u64,
/// `patterns.pat` exists but failed to load: left on disk untouched, so it is never written.
patterns_skipped: bool,
/// The pattern files and index (`patterns.json`).
patterns: patterns::PatternFiles,
warnings: Vec<String>,
/// Full tips loaded for brushes in use (behind a lock so `&Session` paths can load).
cache: Mutex<TipCache>,
/// The stored pattern last read in full, so painting with it again doesn't re-read its file
/// (a texture can be tens of megabytes). One: the pattern in use.
pattern_cache: Mutex<Option<photocraft_doc::Pattern>>,
}
/// A loaded store: the presets on disk plus what the session should hide.
@@ -584,8 +591,8 @@ pub struct Opened {
pub warnings: Vec<String>,
/// Recorded actions (`actions.json`). Empty when the file is missing or unreadable.
pub actions: Vec<crate::actions_cmds::Action>,
/// User patterns (`patterns.pat`), in library order. Empty when the file is missing or unreadable.
pub patterns: Vec<photocraft_doc::Pattern>,
/// Imported and defined patterns (`patterns.json`), in library order; large ones as previews.
pub patterns: Vec<patterns::Loaded>,
}
/// Load a store (any thread; the desktop app does this in the background at start).
@@ -662,9 +669,7 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
let loaded_actions = load_actions(backend.as_ref(), &mut warnings);
let actions_skipped = loaded_actions.is_none();
let (actions, actions_defaults_version) = loaded_actions.map(|f| (f.actions, f.defaults_version)).unwrap_or_default();
let loaded_patterns = load_patterns(backend.as_ref(), &mut warnings);
let patterns_skipped = loaded_patterns.is_none();
let patterns = loaded_patterns.unwrap_or_default();
let (patterns, pattern_files) = patterns::load(backend.as_ref(), &mut warnings);
let mut store = PresetStore {
backend,
synced_rev: None,
@@ -675,10 +680,10 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
actions_rev: 0,
actions_defaults_version,
actions_skipped,
patterns_key: patterns_key(&patterns, 0),
patterns_skipped,
patterns: pattern_files,
warnings: Vec::new(),
cache: Mutex::new(TipCache { limit: TIP_CACHE_BYTES, ..Default::default() }),
pattern_cache: Mutex::new(None),
};
let mut presets = Vec::new();
for (file, size, g) in parsed {
@@ -737,40 +742,6 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
Opened { store, presets, hidden_builtins, order, warnings, actions, patterns }
}
/// `patterns.pat`: a missing file is no patterns. Anything else unreadable is a warning.
/// The saved patterns; `None` when `patterns.pat` exists but could not be loaded.
fn load_patterns(backend: &dyn PresetBackend, warnings: &mut Vec<String>) -> Option<Vec<photocraft_doc::Pattern>> {
let bytes = match backend.read(PATTERNS_FILE, MAX_PATTERNS_BYTES) {
Ok(b) => b,
Err(e) if missing_file(&e) => return Some(Vec::new()),
Err(e) => {
warnings.push(format!("patterns: {PATTERNS_FILE} skipped: {e}"));
return None;
}
};
match photocraft_io::pattern_map::read_pat(&bytes) {
Ok(p) => Some(p),
Err(e) => {
warnings.push(format!("patterns: {PATTERNS_FILE} skipped: {e}"));
None
}
}
}
/// What decides whether the user patterns need rewriting: their ids, names, sizes and order, and
/// the library's [`pixels_rev`](crate::pattern_cmds::PatternLibrary::pixels_rev) (pixels replaced
/// under the same id).
fn patterns_key(patterns: &[photocraft_doc::Pattern], pixels_rev: u64) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
for p in patterns {
(&p.id, &p.name, p.width, p.height).hash(&mut h);
}
patterns.len().hash(&mut h);
pixels_rev.hash(&mut h);
h.finish()
}
/// `actions.json`: a missing file is an empty list. Anything else unreadable is a warning.
/// The saved actions; `None` when `actions.json` exists but could not be loaded.
fn load_actions(backend: &dyn PresetBackend, warnings: &mut Vec<String>) -> Option<crate::actions_cmds::ActionsFile> {
@@ -1100,6 +1071,29 @@ impl PresetStore {
Ok(())
}
/// Write the pattern library's changes (see [`patterns`]).
pub fn sync_patterns(&mut self, library: &crate::pattern_cmds::PatternLibrary) {
let changed = self.patterns.pixels_rev != library.pixels_rev;
patterns::sync(self.backend.as_ref(), &mut self.patterns, library, &mut self.warnings);
let cache = self.pattern_cache.get_mut().unwrap_or_else(PoisonError::into_inner);
if changed || cache.as_ref().is_some_and(|p| !self.patterns.index.iter().any(|e| e.id == p.id)) {
*cache = None;
}
}
/// The full pixels of a stored pattern the library holds as a preview: the last one read is
/// kept, so only switching patterns reads a file.
pub fn load_pattern(&self, id: &str) -> Result<photocraft_doc::Pattern, String> {
let mut cache = self.pattern_cache.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(p) = cache.as_ref().filter(|p| p.id == id) {
return Ok(p.clone());
}
let entry = self.patterns.index.iter().find(|e| e.id == id).ok_or_else(|| format!("pattern {id} is not in the preset store"))?;
let p = patterns::load_full(self.backend.as_ref(), entry)?;
*cache = Some(p.clone());
Ok(p)
}
/// Write the Actions list when `rev` differs from the last load or write.
/// An oversized list is warned once and not retried. An I/O error is warned and retried
/// on the next command.
@@ -1133,49 +1127,6 @@ impl PresetStore {
}
}
impl PresetStore {
/// Write the user patterns (the library minus the built-ins) when they changed since the last
/// load or write. An oversized library is warned once per change and not written. An I/O
/// error is warned and retried on the next command.
pub fn sync_patterns(&mut self, library: &[photocraft_doc::Pattern], pixels_rev: u64) {
let builtins = crate::pattern_cmds::builtin();
let user: Vec<photocraft_doc::Pattern> = library.iter().filter(|p| !crate::pattern_cmds::is_pristine_builtin(p, &builtins)).cloned().collect();
let key = patterns_key(&user, pixels_rev);
if self.patterns_key == key {
return;
}
self.patterns_key = key;
if self.patterns_skipped {
self.warnings.push(format!("Couldn't save patterns: {PATTERNS_FILE} could not be read, so it was left unchanged"));
return;
}
if user.is_empty() {
if let Err(e) = self.backend.remove(PATTERNS_FILE) {
self.warnings.push(format!("Couldn't save patterns: {e}"));
// Try again after the next command.
self.patterns_key = key.wrapping_add(1);
}
return;
}
let bytes = match photocraft_io::pattern_map::write_pat(&user) {
Ok(b) => b,
Err(e) => {
self.warnings.push(format!("Couldn't save patterns: {e}"));
return;
}
};
if bytes.len() as u64 > MAX_PATTERNS_BYTES {
self.warnings.push(format!("patterns: the pattern library is too large to save ({} MB limit)", MAX_PATTERNS_BYTES >> 20));
return;
}
if let Err(e) = self.backend.write(PATTERNS_FILE, &bytes) {
self.warnings.push(format!("Couldn't save patterns: {e}"));
// Try again after the next command.
self.patterns_key = key.wrapping_add(1);
}
}
}
fn builtin_names() -> &'static [String] {
static NAMES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
NAMES.get_or_init(|| photocraft_paint::presets::builtin().into_iter().map(|p| p.name).collect())
@@ -1193,21 +1144,27 @@ impl Session {
/// deleted built-ins, then sync. Returns the load warnings.
pub fn attach_preset_store(&mut self, opened: Opened) -> Vec<String> {
let Opened { store, presets, hidden_builtins, order, mut warnings, actions: disk, patterns } = opened;
// Saved patterns join the library after the built-ins, and a saved built-in (replaced by
// an import or renamed) takes the shipped one's place; a pattern defined or imported
// before the store finished loading keeps its place (and the same id isn't added twice).
let builtins = crate::pattern_cmds::builtin();
for p in patterns {
match self.patterns.items.iter_mut().find(|q| q.id == p.id) {
Some(q) if crate::pattern_cmds::is_pristine_builtin(q, &builtins) => *q = p,
// The earlier copy wins; other pixels than the saved ones must still be written,
// which its id, name and size don't show.
for l in patterns {
let id = l.pattern.id.clone();
match self.patterns.items.iter_mut().find(|q| q.id == id) {
Some(q) if crate::pattern_cmds::is_pristine_builtin(q, &builtins) => {
*q = l.pattern;
if let Some(size) = l.full {
self.patterns.stored.insert(id, size);
}
}
Some(q) => {
if q.surface != p.surface {
if q.surface != l.pattern.surface {
self.patterns.pixels_rev += 1;
}
}
None => self.patterns.items.push(p),
None => {
if let Some(size) = l.full {
self.patterns.stored.insert(id, size);
}
self.patterns.items.push(l.pattern);
}
}
}
let lib = &mut self.tools.presets;
@@ -1280,7 +1237,7 @@ impl Session {
}
}
st.sync_actions(&self.actions.list, actions_rev);
st.sync_patterns(&self.patterns.items, self.patterns.pixels_rev);
st.sync_patterns(&self.patterns);
}
}
+380
View File
@@ -0,0 +1,380 @@
//! The pattern library in the preset store: imported (`.pat`) and defined patterns survive a
//! restart, as Photoshop keeps its Patterns panel.
//!
//! - `pattern-<hash>.pat`: one pattern per file (a one-pattern Photoshop `.pat`), written once
//! when the pattern enters the library and deleted when it leaves it;
//! - `pattern-<hash>.preview.pat`: the same pattern shrunk to at most [`PREVIEW_SIDE`] px, for
//! patterns larger than that;
//! - `patterns.json`: the library order, names and full sizes (renaming rewrites only this file).
//!
//! Opening the store reads the index and the previews, never a large pattern's pixels: texture
//! libraries run to gigabytes (Debaser's colour libraries hold twenty 4500 × 4500 px patterns per
//! file). Such a pattern joins the library as its preview, with its full size in
//! [`crate::pattern_cmds::PatternLibrary::stored`], and its pixels are read when it is used
//! ([`super::PresetStore::load_pattern`]).
//!
//! Built-in patterns are never written. A pattern file or index that fails to load is left on
//! disk untouched and never overwritten or deleted; a missing index is an empty library.
use std::collections::HashSet;
use photocraft_doc::Pattern;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde::{Deserialize, Serialize};
use super::{PresetBackend, missing_file};
use crate::pattern_cmds::PatternLibrary;
/// Library order and names.
pub const PATTERNS_FILE: &str = "patterns.json";
/// Largest `patterns.json` read or written.
pub const MAX_PATTERN_INDEX_BYTES: u64 = 4 << 20;
/// Largest pattern file read or written.
pub const MAX_PATTERN_BYTES: u64 = 256 << 20;
/// Most patterns loaded.
pub const MAX_PATTERNS: usize = 10_000;
/// Patterns larger than this (either side) are loaded as a preview of this size until used.
pub const PREVIEW_SIDE: u32 = 128;
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub(super) struct Entry {
pub id: String,
pub name: String,
pub file: String,
#[serde(default)]
pub width: u32,
#[serde(default)]
pub height: u32,
}
#[derive(Serialize, Deserialize)]
struct IndexFile {
version: u32,
patterns: Vec<Entry>,
}
/// The store's view of the pattern files it owns.
#[derive(Default)]
pub(super) struct PatternFiles {
/// The index as last loaded or written.
pub index: Vec<Entry>,
/// `patterns.json` exists but failed to load: never written.
pub index_skipped: bool,
/// Pattern files that failed to load: never written or deleted.
pub skipped: HashSet<String>,
/// Patterns that couldn't be written (too large, I/O error): not retried this session, so
/// one failure is one warning.
pub failed: HashSet<String>,
pub pixels_rev: u64,
pub pending_removals: HashSet<String>,
}
/// A stored pattern as loaded: the pattern (a preview when `full` is set) and its full size.
pub struct Loaded {
pub pattern: Pattern,
pub full: Option<(u32, u32)>,
}
fn hash(id: &str) -> u64 {
id.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3))
}
/// The file a pattern is stored in (its id hashed: ids are UUIDs or content hashes, but may hold
/// any character).
fn file_name(id: &str) -> String {
format!("pattern-{:016x}.pat", hash(id))
}
fn preview_name(id: &str) -> String {
format!("pattern-{:016x}.preview.pat", hash(id))
}
#[cfg(test)]
pub(super) fn file_name_for_test(id: &str) -> String {
file_name(id)
}
fn is_large(width: u32, height: u32) -> bool {
width.max(height) > PREVIEW_SIDE
}
/// `p` shrunk to at most [`PREVIEW_SIDE`] px (nearest sample: a swatch, not a resample).
fn preview(p: &Pattern) -> Pattern {
let (w, h) = (p.width.max(1), p.height.max(1));
let step = f64::from(w.max(h)) / f64::from(PREVIEW_SIDE);
let pw = ((f64::from(w) / step).round() as u32).clamp(1, PREVIEW_SIDE);
let ph = ((f64::from(h) / step).round() as u32).clamp(1, PREVIEW_SIDE);
let fmt = p.surface.format();
let mut data = Vec::with_capacity(pw as usize * ph as usize * fmt.channels());
for y in 0..ph {
for x in 0..pw {
let sx = ((f64::from(x) * step) as u32).min(w - 1);
let sy = ((f64::from(y) * step) as u32).min(h - 1);
data.extend(p.surface.pixel(sx as i32, sy as i32));
}
}
let mut surface = Surface::new(fmt);
surface.write_region(Rect::new(0, 0, pw as i32, ph as i32), &data);
Pattern { id: p.id.clone(), name: p.name.clone(), width: pw, height: ph, surface }
}
fn read_one(backend: &dyn PresetBackend, file: &str) -> Result<Pattern, String> {
let mut v = photocraft_io::pattern_map::read_pat(&backend.read(file, MAX_PATTERN_BYTES)?)?;
if v.is_empty() { Err("no pattern in the file".to_string()) } else { Ok(v.swap_remove(0)) }
}
/// A stored pattern's full pixels (named and identified as in `entry`).
pub(super) fn load_full(backend: &dyn PresetBackend, entry: &Entry) -> Result<Pattern, String> {
let mut p = read_one(backend, &entry.file)?;
p.id = entry.id.clone();
p.name = entry.name.clone();
Ok(p)
}
/// Load the stored patterns, in library order: small ones whole, large ones as previews.
pub(super) fn load(backend: &dyn PresetBackend, warnings: &mut Vec<String>) -> (Vec<Loaded>, PatternFiles) {
let mut files = PatternFiles::default();
let bytes = match backend.read(PATTERNS_FILE, MAX_PATTERN_INDEX_BYTES) {
Ok(b) => b,
Err(e) if missing_file(&e) => return migrate_legacy(backend, warnings),
Err(e) => {
warnings.push(format!("patterns: {PATTERNS_FILE} skipped: {e}"));
files.index_skipped = true;
return (Vec::new(), files);
}
};
let index = match serde_json::from_slice::<IndexFile>(&bytes) {
Ok(f) if f.version == 1 => f.patterns,
Ok(f) => {
warnings.push(format!("patterns: {PATTERNS_FILE} skipped: unsupported version {}", f.version));
files.index_skipped = true;
return (Vec::new(), files);
}
Err(e) => {
warnings.push(format!("patterns: {PATTERNS_FILE} skipped: {e}"));
files.index_skipped = true;
return (Vec::new(), files);
}
};
let mut ids = HashSet::new();
let valid = index.len() <= MAX_PATTERNS && index.iter().all(|e| e.file == file_name(&e.id) && ids.insert(e.id.clone()));
if !valid {
warnings
.push("patterns: invalid/duplicate index entries; preserve files and recover patterns.json before saving (legacy PAT is not reimported)".into());
files.index_skipped = true;
return (Vec::new(), files);
}
let mut patterns = Vec::new();
let mut seen = HashSet::new();
for e in index.into_iter().take(MAX_PATTERNS) {
// Only generated names: an edited index can't reach other files.
if e.file != file_name(&e.id) || !seen.insert(e.id.clone()) {
warnings.push(format!("patterns: entry \"{}\" skipped: bad file name", e.name));
continue;
}
let loaded = if is_large(e.width, e.height) {
// The preview; without one (a failed write), the full file once to make it.
match read_one(backend, &preview_name(&e.id)) {
Ok(mut p) => {
p.id = e.id.clone();
p.name = e.name.clone();
Ok(Loaded { pattern: p, full: Some((e.width, e.height)) })
}
Err(_) => load_full(backend, &e).map(|p| Loaded { pattern: preview(&p), full: Some((p.width, p.height)) }),
}
} else {
load_full(backend, &e).map(|p| Loaded { pattern: p, full: None })
};
match loaded {
Ok(l) => {
patterns.push(l);
files.index.push(e);
}
Err(err) => {
warnings.push(format!("patterns: {} (\"{}\") skipped: {err}", e.file, e.name));
files.skipped.insert(e.file.clone());
// Kept in the index, so a later write doesn't forget the file.
files.index.push(e);
}
}
}
(patterns, files)
}
/// Write what changed: new patterns' files (and previews), the index when the order or a name
/// changed, and delete the files of patterns that left the library. Failures are warnings.
pub(super) fn sync(backend: &dyn PresetBackend, files: &mut PatternFiles, library: &PatternLibrary, warnings: &mut Vec<String>) {
files.pending_removals.retain(|f| match backend.remove(f) {
Ok(()) => false,
Err(e) => {
warnings.push(format!("Couldn't delete pattern file {f}: {e}"));
true
}
});
let builtins = crate::pattern_cmds::builtin();
let pixels_changed = files.pixels_rev != library.pixels_rev;
let mut want: Vec<Entry> = library
.items
.iter()
.filter(|p| !crate::pattern_cmds::is_pristine_builtin(p, &builtins))
.map(|p| {
let (width, height) = library.stored.get(&p.id).copied().unwrap_or((p.width, p.height));
Entry { id: p.id.clone(), name: p.name.clone(), file: file_name(&p.id), width, height }
})
.collect();
// Unreadable files stay listed (at the end) until the library takes their pattern back.
for e in &files.index {
if files.skipped.contains(&e.file) && !want.iter().any(|w| w.id == e.id) {
want.push(e.clone());
}
}
if want == files.index && !pixels_changed {
return;
}
if files.index_skipped {
warnings.push(format!("Couldn't save patterns: {PATTERNS_FILE} could not be read, so it was left unchanged"));
files.index = want;
return;
}
let have: HashSet<&str> = files.index.iter().map(|e| e.id.as_str()).collect();
let mut written = Vec::with_capacity(want.len());
for e in want {
// Already on disk (a preview in the library included: its pixels never left the disk).
if (have.contains(e.id.as_str()) && !pixels_changed) || files.skipped.contains(&e.file) || library.stored.contains_key(&e.id) {
written.push(e);
continue;
}
let Some(p) = library.items.iter().find(|p| p.id == e.id) else { continue };
match write_pattern(backend, p, &e, have.contains(e.id.as_str())) {
Ok(()) => written.push(e),
Err(err) => {
warnings.push(err);
files.failed.insert(e.id);
return;
}
}
}
if written.len() > MAX_PATTERNS {
warnings.push("patterns: index count limit exceeded".into());
return;
}
let index = IndexFile { version: 1, patterns: written };
let bytes = match serde_json::to_vec_pretty(&index) {
Ok(b) => b,
Err(e) => {
warnings.push(format!("Couldn't save patterns: {e}"));
return;
}
};
if bytes.len() as u64 > MAX_PATTERN_INDEX_BYTES {
warnings.push("patterns: index byte limit exceeded".into());
return;
}
if let Err(e) = backend.write(PATTERNS_FILE, &bytes) {
warnings.push(format!("Couldn't save patterns: {e}"));
return;
}
// Files of patterns that left the library (written by an earlier index) are deleted only
// once the new index no longer names them.
let keep: HashSet<&str> = index.patterns.iter().map(|e| e.file.as_str()).collect();
for e in &files.index {
if !keep.contains(e.file.as_str()) && !files.skipped.contains(&e.file) {
for f in [e.file.clone(), preview_name(&e.id)] {
if let Err(err) = backend.remove(&f) {
warnings.push(format!("Couldn't delete pattern file {f}: {err}"));
files.pending_removals.insert(f);
}
}
}
}
files.index = index.patterns;
files.pixels_rev = library.pixels_rev;
}
fn write_pattern(backend: &dyn PresetBackend, p: &Pattern, e: &Entry, owned: bool) -> Result<(), String> {
let bytes = photocraft_io::pattern_map::write_pat(std::slice::from_ref(p)).map_err(|err| format!("Couldn't save pattern \"{}\": {err}", p.name))?;
if bytes.len() as u64 > MAX_PATTERN_BYTES {
return Err(format!("pattern \"{}\" is too large to save ({} MB limit)", p.name, MAX_PATTERN_BYTES >> 20));
}
if is_large(p.width, p.height) {
// The preview first: a full file without one still loads (by making it).
let small = photocraft_io::pattern_map::write_pat(&[preview(p)]).map_err(|err| format!("Couldn't save pattern \"{}\": {err}", p.name))?;
write_asset(backend, &preview_name(&e.id), &small, owned).map_err(|err| format!("Couldn't save pattern \"{}\": {err}", p.name))?;
}
write_asset(backend, &e.file, &bytes, owned).map_err(|err| format!("Couldn't save pattern \"{}\": {err}", p.name))
}
/// The index itself is the atomic migration commit. Never consult stale PAT when it exists,
/// including when corrupt. An interrupted asset write leaves the legacy bytes authoritative.
fn migrate_legacy(backend: &dyn PresetBackend, warnings: &mut Vec<String>) -> (Vec<Loaded>, PatternFiles) {
let mut files = PatternFiles::default();
let bytes = match backend.read(super::LEGACY_PATTERNS_FILE, super::MAX_PATTERNS_BYTES) {
Ok(b) => b,
Err(e) if missing_file(&e) => return (Vec::new(), files),
Err(e) => {
warnings.push(format!("patterns: legacy file preserved: {e}"));
files.index_skipped = true;
return (Vec::new(), files);
}
};
let patterns = match photocraft_io::pattern_map::read_pat(&bytes) {
Ok(p) => p,
Err(e) => {
warnings.push(format!("patterns: legacy file preserved: {e}"));
files.index_skipped = true;
return (Vec::new(), files);
}
};
let mut ids = HashSet::new();
let mut names = HashSet::new();
if patterns.len() > MAX_PATTERNS || patterns.iter().any(|p| !ids.insert(p.id.clone()) || !names.insert(file_name(&p.id))) {
warnings.push("patterns: legacy migration refused duplicate IDs, file hash collision or count limit; legacy preserved".into());
files.index_skipped = true;
return (patterns.into_iter().map(|pattern| Loaded { pattern, full: None }).collect(), files);
}
let migration = (|| -> Result<(), String> {
let entries: Vec<Entry> =
patterns.iter().map(|p| Entry { id: p.id.clone(), name: p.name.clone(), file: file_name(&p.id), width: p.width, height: p.height }).collect();
let index = serde_json::to_vec_pretty(&IndexFile { version: 1, patterns: entries.clone() }).map_err(|e| e.to_string())?;
if index.len() as u64 > MAX_PATTERN_INDEX_BYTES {
return Err("index exceeds size limit".into());
}
for (p, e) in patterns.iter().zip(&entries) {
// Never overwrite unknown files, even if a name collides. Identical orphan assets
// from an interrupted migration can be reused; the legacy file stays untouched.
let full = photocraft_io::pattern_map::write_pat(std::slice::from_ref(p))?;
if full.len() as u64 > MAX_PATTERN_BYTES {
return Err("pattern exceeds size limit".into());
}
write_migration_asset(backend, &e.file, &full)?;
if is_large(p.width, p.height) {
let small = photocraft_io::pattern_map::write_pat(&[preview(p)])?;
write_migration_asset(backend, &preview_name(&p.id), &small)?;
}
}
// Atomic backend write last: no partial or failed migration can become authoritative.
backend.write(PATTERNS_FILE, &index)?;
Ok(())
})();
match migration {
Ok(()) => load(backend, warnings),
Err(e) => {
warnings.push(format!("patterns: migration not committed, legacy remains usable: {e}"));
files.index_skipped = true;
(patterns.into_iter().map(|pattern| Loaded { pattern, full: None }).collect(), files)
}
}
}
fn write_migration_asset(backend: &dyn PresetBackend, name: &str, bytes: &[u8]) -> Result<(), String> {
match backend.read(name, MAX_PATTERN_BYTES) {
Ok(existing) if existing == bytes => Ok(()),
Ok(_) => Err(format!("unrecognized existing asset {name} preserved")),
Err(e) if missing_file(&e) => backend.write(name, bytes),
Err(e) => Err(format!("existing asset {name} preserved: {e}")),
}
}
fn write_asset(backend: &dyn PresetBackend, name: &str, bytes: &[u8], owned: bool) -> Result<(), String> {
if owned { backend.write(name, bytes) } else { write_migration_asset(backend, name, bytes) }
}
+259 -4
View File
@@ -704,6 +704,129 @@ fn an_actions_file_that_failed_to_load_is_not_overwritten() {
assert!(!s.preset_store.as_mut().unwrap().take_warnings().is_empty());
}
/// A large pattern opens as a small preview (texture libraries run to gigabytes) and is read at
/// full size when it is used; its preview file goes with it on delete.
#[test]
fn large_patterns_load_on_use() {
let dir = TempDir::new("patterns-large");
let (mut s, _) = session(&dir);
s.execute("file.new", json!({"width": 300, "height": 200})).unwrap();
s.execute("edit.fill", json!({"color": "#ffffff"})).unwrap();
s.execute("select.rect", json!({"x": 250, "y": 150, "width": 50, "height": 50})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute("edit.definePattern", json!({"name": "Big"})).unwrap();
assert_eq!(dir.files().iter().filter(|f| f.ends_with(".preview.pat")).count(), 1);
let (mut t, w) = session(&dir);
assert!(w.is_empty(), "{w:?}");
let held = t.patterns.items.iter().find(|p| p.name == "Big").unwrap();
assert!(held.width <= PATTERN_PREVIEW_SIDE && held.height <= PATTERN_PREVIEW_SIDE, "held as a preview: {}x{}", held.width, held.height);
let listed = t.execute("pattern.list", json!({})).unwrap();
let big = listed.as_array().unwrap().iter().find(|p| p["name"] == "Big").unwrap();
assert_eq!((big["width"].as_u64(), big["height"].as_u64()), (Some(300), Some(200)), "the list reports the full size");
// Checks that only need the pattern's id (Pattern Stamp's enabled state) don't read its file.
let id = t.patterns.items.iter().find(|p| p.name == "Big").unwrap().id.clone();
let file = dir.0.join(patterns::file_name_for_test(&id));
let moved = file.with_extension("moved");
std::fs::rename(&file, &moved).unwrap();
assert_eq!(crate::pattern_cmds::resolve_id(&t, "Big"), Some(id.clone()));
assert!(crate::pattern_cmds::resolve(&t, "Big").is_none(), "the full pixels come from the file");
std::fs::rename(&moved, &file).unwrap();
let full = crate::pattern_cmds::resolve(&t, "Big").unwrap();
assert_eq!((full.width, full.height), (300, 200));
assert!(full.surface.pixel(280, 180)[1] < 0.01 && full.surface.pixel(10, 10)[1] > 0.99, "full pixels");
// Once read, it is kept: using it again doesn't read the file.
std::fs::rename(&file, &moved).unwrap();
assert_eq!(crate::pattern_cmds::resolve(&t, "Big").map(|p| p.width), Some(300));
std::fs::rename(&moved, &file).unwrap();
// A Pattern Fill layer gets the full pattern into the document.
t.execute("file.new", json!({"width": 40, "height": 40})).unwrap();
t.execute("layer.newFillLayer.pattern", json!({"pattern": "Big"})).unwrap();
let doc_pat = &t.active().unwrap().doc.patterns[0];
assert_eq!((doc_pat.width, doc_pat.height), (300, 200));
// Exporting the library writes full pixels too.
let out = dir.0.join("export.pat");
t.execute("pattern.export", json!({"path": out.to_string_lossy()})).unwrap();
let back = photocraft_io::pattern_map::read_pat(&std::fs::read(&out).unwrap()).unwrap();
assert!(back.iter().any(|p| p.name == "Big" && p.width == 300));
std::fs::remove_file(&out).unwrap();
t.execute("pattern.delete", json!({"pattern": "Big"})).unwrap();
assert!(!dir.files().iter().any(|f| f.starts_with("pattern-")), "{:?}", dir.files());
}
/// A corrupt pattern file is skipped with a warning and left on disk; the others load, and later
/// saves never delete or overwrite it. A corrupt index is never overwritten.
#[test]
fn corrupt_pattern_files_are_skipped_and_kept() {
let dir = TempDir::new("patterns-corrupt");
let (mut s, _) = session(&dir);
define_pattern(&mut s, "Grit", "#202020", 8);
define_pattern(&mut s, "Paper", "#f0e0c0", 12);
let grit = s.patterns.items.iter().find(|p| p.name == "Grit").unwrap().id.clone();
let file = dir.0.join(patterns::file_name_for_test(&grit));
std::fs::write(&file, b"not a pattern").unwrap();
let (mut t, w) = session(&dir);
assert!(w.iter().any(|w| w.contains("Grit")), "{w:?}");
assert_eq!(user_patterns(&t), [("Paper".to_string(), 12)]);
define_pattern(&mut t, "Ink", "#000000", 4);
assert_eq!(std::fs::read(&file).unwrap(), b"not a pattern", "the unreadable file is left alone");
let index = std::fs::read_to_string(dir.0.join(PATTERNS_FILE)).unwrap();
assert!(index.contains("Grit") && index.contains("Ink"), "{index}");
std::fs::write(dir.0.join(PATTERNS_FILE), b"{").unwrap();
let (mut u, w) = session(&dir);
assert!(w.iter().any(|w| w.contains(PATTERNS_FILE)), "{w:?}");
assert!(user_patterns(&u).is_empty());
define_pattern(&mut u, "Late", "#808080", 4);
assert_eq!(std::fs::read(dir.0.join(PATTERNS_FILE)).unwrap(), b"{", "an unreadable index is never overwritten");
}
/// Imported and defined patterns survive a restart: one file each, written once, plus the index;
/// a rename rewrites only the index, a delete removes the file.
#[test]
fn patterns_survive_a_restart() {
let dir = TempDir::new("patterns");
let (mut s, w) = session(&dir);
assert!(w.is_empty(), "{w:?}");
define_pattern(&mut s, "Grit", "#202020", 8);
define_pattern(&mut s, "Paper", "#f0e0c0", 12);
let pattern_files = |d: &TempDir| d.files().into_iter().filter(|f| f.starts_with("pattern-")).collect::<Vec<_>>();
assert_eq!(pattern_files(&dir).len(), 2);
assert!(dir.files().contains(&PATTERNS_FILE.to_string()));
assert!(!dir.files().iter().any(|f| f.contains("Checkerboard")), "built-ins are never written");
let (mut t, w) = session(&dir);
assert!(w.is_empty(), "{w:?}");
assert_eq!(user_patterns(&t), [("Grit".to_string(), 8), ("Paper".to_string(), 12)]);
// The pixels come back too.
let paper = t.patterns.items.iter().find(|p| p.name == "Paper").unwrap();
let px = paper.surface.pixel(3, 3);
assert!((px[0] - 240.0 / 255.0).abs() < 0.01, "{px:?}");
let before = std::fs::metadata(dir.0.join(&pattern_files(&dir)[0])).unwrap().modified().unwrap();
t.execute("pattern.rename", json!({"pattern": "Grit", "name": "Toner"})).unwrap();
assert_eq!(std::fs::metadata(dir.0.join(&pattern_files(&dir)[0])).unwrap().modified().unwrap(), before, "a rename rewrites only the index");
t.execute("pattern.delete", json!({"pattern": "Paper"})).unwrap();
assert_eq!(pattern_files(&dir).len(), 1, "the deleted pattern's file is gone");
let (u, _) = session(&dir);
assert_eq!(user_patterns(&u), [("Toner".to_string(), 8)]);
}
fn define_pattern(s: &mut Session, name: &str, color: &str, width: u32) {
s.execute("file.new", json!({"width": width, "height": 6})).unwrap();
s.execute("edit.fill", json!({"color": color})).unwrap();
s.execute("edit.definePattern", json!({"name": name})).unwrap();
}
fn user_patterns(s: &Session) -> Vec<(String, u32)> {
let builtins: Vec<String> = crate::pattern_cmds::builtin().into_iter().map(|p| p.id).collect();
s.patterns.items.iter().filter(|p| !builtins.contains(&p.id)).map(|p| (p.name.clone(), p.width)).collect()
}
/// #9317: a defined or imported pattern survives a restart, as in Photoshop; the built-in
/// patterns are never written, and deleting the last user pattern removes the file.
#[test]
@@ -719,7 +842,13 @@ fn user_patterns_survive_a_restart() {
assert!(dir.files().contains(&PATTERNS_FILE.to_string()), "{:?}", dir.files());
// Only the user's pattern is in the file.
let bytes = std::fs::read(dir.0.join(PATTERNS_FILE)).unwrap();
let saved = photocraft_io::pattern_map::read_pat(&bytes).unwrap();
let index: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let saved: Vec<photocraft_doc::Pattern> = index["patterns"]
.as_array()
.unwrap()
.iter()
.map(|e| photocraft_io::pattern_map::read_pat(&std::fs::read(dir.0.join(e["file"].as_str().unwrap())).unwrap()).unwrap().remove(0))
.collect();
assert_eq!(saved.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Green Tile"]);
drop(s);
@@ -736,7 +865,10 @@ fn user_patterns_survive_a_restart() {
assert!(u.patterns.items.iter().any(|p| p.id == id && p.name == "Moss"));
t.execute("pattern.delete", json!({"pattern": id})).unwrap();
t.sync_preset_store();
assert!(!dir.files().contains(&PATTERNS_FILE.to_string()), "{:?}", dir.files());
assert!(
serde_json::from_slice::<serde_json::Value>(&std::fs::read(dir.0.join(PATTERNS_FILE)).unwrap()).unwrap()["patterns"].as_array().unwrap().is_empty(),
"empty authoritative index prevents resurrection"
);
}
#[test]
@@ -827,7 +959,13 @@ fn a_failed_patterns_removal_is_retried() {
backend.fail.store(false, std::sync::atomic::Ordering::SeqCst);
s.execute("file.new", json!({"width": 4, "height": 4})).unwrap();
s.sync_preset_store();
assert!(!backend.mem.files.lock().unwrap().contains_key(PATTERNS_FILE), "the removal is retried");
assert!(!backend.mem.files.lock().unwrap().keys().any(|f| f.starts_with("pattern-")), "owned asset removal is retried");
assert!(
serde_json::from_slice::<serde_json::Value>(backend.mem.files.lock().unwrap().get(PATTERNS_FILE).unwrap()).unwrap()["patterns"]
.as_array()
.unwrap()
.is_empty()
);
}
/// #9318 review: an imported `.pat` that replaces a built-in pattern's pixels (same id) is saved
@@ -840,7 +978,13 @@ fn an_imported_builtin_replacement_survives_a_restart() {
let builtin = s.patterns.items[0].id.clone();
s.execute("pattern.import", json!({"data": pat_bytes(&builtin, [0.9, 0.1, 0.4, 1.0])})).unwrap();
s.sync_preset_store();
let saved = photocraft_io::pattern_map::read_pat(mem.files.lock().unwrap().get(PATTERNS_FILE).expect("written")).unwrap();
let index: serde_json::Value = serde_json::from_slice(mem.files.lock().unwrap().get(PATTERNS_FILE).expect("written")).unwrap();
let saved: Vec<photocraft_doc::Pattern> = index["patterns"]
.as_array()
.unwrap()
.iter()
.map(|e| photocraft_io::pattern_map::read_pat(mem.files.lock().unwrap().get(e["file"].as_str().unwrap()).unwrap()).unwrap().remove(0))
.collect();
assert_eq!(saved.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(), [builtin.as_str()], "only the replaced built-in");
let mut t = Session::new();
t.attach_preset_store(open(Box::new(mem.clone())));
@@ -870,3 +1014,114 @@ fn an_early_same_id_import_wins_and_is_saved() {
u.attach_preset_store(open(Box::new(mem.clone())));
assert_eq!(u.patterns.items.iter().find(|p| p.id == id).unwrap().surface, early, "the early import's pixels were saved");
}
#[derive(Clone)]
struct MigrationFail {
mem: MemBackend,
fail_name: String,
}
impl PresetBackend for MigrationFail {
fn list(&self) -> Result<Vec<(String, u64)>, String> {
self.mem.list()
}
fn read(&self, n: &str, m: u64) -> Result<Vec<u8>, String> {
self.mem.read(n, m)
}
fn write(&self, n: &str, b: &[u8]) -> Result<(), String> {
if n == self.fail_name { Err("injected migration write failure".into()) } else { self.mem.write(n, b) }
}
fn remove(&self, n: &str) -> Result<(), String> {
self.mem.remove(n)
}
}
fn legacy_store() -> (MemBackend, Vec<u8>, String) {
let mem = MemBackend::default();
let id = "6f1c2c1e-0000-4000-8000-000000005176".to_string();
let bytes = photocraft_paint::tile::b64_decode(&pat_bytes(&id, [0.2, 0.6, 0.8, 1.0])).unwrap();
mem.write(LEGACY_PATTERNS_FILE, &bytes).unwrap();
(mem, bytes, id)
}
#[test]
fn legacy_migration_commits_last_and_never_resurrects_renames_or_deletions() {
let (mem, old, id) = legacy_store();
let mut s = Session::new();
assert!(s.attach_preset_store(open(Box::new(mem.clone()))).is_empty());
assert!(mem.read(PATTERNS_FILE, 1 << 20).is_ok());
assert_eq!(mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(), old);
s.execute("pattern.rename", json!({"pattern":id,"name":"Migrated"})).unwrap();
s.sync_preset_store();
let mut t = Session::new();
t.attach_preset_store(open(Box::new(mem.clone())));
assert_eq!(t.patterns.items.iter().find(|p| p.id == id).unwrap().name, "Migrated");
t.execute("pattern.delete", json!({"pattern":id})).unwrap();
t.sync_preset_store();
for _ in 0..2 {
let mut u = Session::new();
u.attach_preset_store(open(Box::new(mem.clone())));
assert!(!u.patterns.items.iter().any(|p| p.id == id));
}
assert_eq!(mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(), old);
}
#[test]
fn interrupted_migration_preserves_legacy_and_reuses_only_identical_orphans() {
for fail_index in [false, true] {
let (mem, old, id) = legacy_store();
let file = patterns::file_name_for_test(&id);
let fail_name = if fail_index { PATTERNS_FILE.to_string() } else { file.clone() };
let opened = open(Box::new(MigrationFail { mem: mem.clone(), fail_name }));
assert!(!opened.warnings.is_empty());
assert!(opened.patterns.iter().any(|p| p.pattern.id == id), "legacy remains usable");
assert!(mem.read(PATTERNS_FILE, 1 << 20).is_err(), "no commit marker on failure");
assert_eq!(mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(), old);
let retry = open(Box::new(mem.clone()));
assert!(retry.warnings.is_empty(), "{:?}", retry.warnings);
assert!(retry.patterns.iter().any(|p| p.pattern.id == id));
}
let (mem, old, id) = legacy_store();
let file = patterns::file_name_for_test(&id);
mem.write(&file, b"unknown content").unwrap();
let opened = open(Box::new(mem.clone()));
assert!(!opened.warnings.is_empty());
assert_eq!(mem.read(&file, 100).unwrap(), b"unknown content");
assert!(mem.read(PATTERNS_FILE, 1 << 20).is_err());
assert_eq!(mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(), old);
}
#[test]
fn malformed_authoritative_index_never_falls_back_to_stale_legacy() {
let (mem, _, id) = legacy_store();
drop(open(Box::new(mem.clone())));
for bytes in [b"{".as_slice(), b"{\"version\":1,\"patterns\":[{\"id\":\"x\",\"name\":\"bad\",\"file\":\"../secret\"}]}".as_slice()] {
mem.write(PATTERNS_FILE, bytes).unwrap();
let opened = open(Box::new(mem.clone()));
assert!(!opened.warnings.is_empty());
assert!(opened.patterns.is_empty());
assert_eq!(mem.read(PATTERNS_FILE, 1 << 20).unwrap(), bytes);
assert!(!opened.patterns.iter().any(|p| p.pattern.id == id));
}
let mem = MemBackend::default();
mem.write(LEGACY_PATTERNS_FILE, b"broken PAT").unwrap();
let opened = open(Box::new(mem.clone()));
assert!(!opened.warnings.is_empty());
assert!(mem.read(PATTERNS_FILE, 1 << 20).is_err());
assert_eq!(mem.read(LEGACY_PATTERNS_FILE, 20).unwrap(), b"broken PAT");
}
#[test]
fn migration_preserves_duplicate_names_but_refuses_duplicate_ids() {
let (mem, _, id) = legacy_store();
let bytes = mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap();
let mut pats = photocraft_io::pattern_map::read_pat(&bytes).unwrap();
let mut other = pats[0].clone();
other.id = "6f1c2c1e-0000-4000-8000-000000005177".into();
pats.push(other);
mem.write(LEGACY_PATTERNS_FILE, &photocraft_io::pattern_map::write_pat(&pats).unwrap()).unwrap();
let opened = open(Box::new(mem.clone()));
assert!(opened.warnings.is_empty());
assert_eq!(opened.patterns.len(), 2);
let mem = MemBackend::default();
pats[1].id = id;
let old = photocraft_io::pattern_map::write_pat(&pats).unwrap();
mem.write(LEGACY_PATTERNS_FILE, &old).unwrap();
let opened = open(Box::new(mem.clone()));
assert!(!opened.warnings.is_empty());
assert!(mem.read(PATTERNS_FILE, 1 << 20).is_err());
assert_eq!(mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(), old);
}
+1 -1
View File
@@ -162,7 +162,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
}
"move" => {
let key = req_str(p, "preset", CMD)?;
let id = crate::pattern_cmds::resolve(s, key).map(|q| q.id).ok_or_else(|| bad(CMD, format!("no pattern \"{key}\"")))?;
let id = crate::pattern_cmds::resolve_id(s, key).ok_or_else(|| bad(CMD, format!("no pattern \"{key}\"")))?;
let mut q = p.clone();
q["preset"] = json!(id);
edit_groups(&mut s.presets.pattern_groups, "move", &q, CMD)?
+3
View File
@@ -140,6 +140,9 @@ impl Session {
if spec.id == "layer.new.layerFromBackground" && params.get("ifPresent").and_then(Value::as_bool) == Some(true) {
return self.active().map(|_| ()).ok_or_else(|| "no document open".into());
}
if spec.id == "layer.smartFilter.deleteFilterMask" && params.get("ifPresent").and_then(Value::as_bool) == Some(true) {
return smart_cmds::smart_layer_active(self);
}
channel_cmds::mask_target_enabled(self, spec.id, params).unwrap_or_else(|| (spec.enabled)(self))
}
+4
View File
@@ -593,6 +593,10 @@ fn arrange_and_group() {
let names: Vec<String> = s.active().unwrap().doc.layers.iter().map(|l| l.name.clone()).collect();
assert_eq!(names, ["Background", "B", "A"]);
assert!(s.execute("layer.arrange.bringForward", json!({})).is_err());
// Bring to Front of the front layer does nothing: no error (an action plays it), no step.
let steps = s.active().unwrap().history.past_len();
assert_eq!(s.execute("layer.arrange.bringToFront", json!({})).unwrap(), json!({"moved": false}));
assert_eq!(s.active().unwrap().history.past_len(), steps);
s.execute("layer.groupLayers", json!({})).unwrap();
let doc = s.execute("document.inspect", json!({})).unwrap();
assert_eq!(doc["layers"][0]["kind"], "Group");
+4 -1
View File
@@ -3285,7 +3285,10 @@ fn integration_adjustment_fill_opacity_masks_and_hdr_match_cpu() {
layer.fill_opacity = fill;
layer.opacity = opacity;
let mut mask = LayerMask::reveal_all();
mask.surface = photocraft_raster::Surface::with_default(PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::F32, alpha: false }, &[coverage]);
mask.surface = photocraft_raster::Surface::with_default(
PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::F32, alpha: false },
&[coverage],
);
layer.mask = Some(mask);
d.layers = vec![base, layer];
check(&mut g, &d, &format!("combined {mode:?} fill={fill} opacity={opacity} mask={coverage} clipped={clipped}"));
@@ -65,8 +65,8 @@ pub(crate) fn open_new(app: &mut PhotocraftApp) -> Result<u64, String> {
return Err(why);
}
let st = app.session.active().ok_or("no document open")?;
let pattern = photocraft_engine::pattern_cmds::resolve(&app.session, "").ok_or("no patterns")?;
let values = json!({"pattern": pattern.id, "scale": 100.0, "angle": 0.0, "link": true, "phase": [0.0, 0.0]});
let pattern = photocraft_engine::pattern_cmds::resolve_id(&app.session, "").ok_or("no patterns")?;
let values = json!({"pattern": pattern, "scale": 100.0, "angle": 0.0, "link": true, "phase": [0.0, 0.0]});
let f = fields(cmds::CREATE, json!({"document": st.doc.id.0}), values);
Ok(app.ui.open_dialog(crate::state::DialogKind::Command, f))
}
+2
View File
@@ -168,6 +168,8 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option<Value>)
let target = app.session.active().map(|d| d.doc.id);
let result = if id == "actions.play" {
play(app, p)
} else if actions_cmds::saves_smart_contents(&app.session, id) {
app.session.execute(actions_cmds::SAVE_CONTENTS, json!({})).map_err(|e| e.to_string())
} else if actions_cmds::shell_save_command(id) {
// Write synchronously, so the next step doesn't meet a document locked by a
// background save.
@@ -47,7 +47,11 @@ pub fn gradients_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// ------------------------------------------------------------------ Patterns
pub fn patterns_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
photocraft_engine::presets::patterns::sync(&mut app.session);
// Until the preset store has loaded the saved patterns, their folder entries would look
// deleted: don't prune them yet.
if app.services.preset_store.is_none() {
photocraft_engine::presets::patterns::sync(&mut app.session);
}
let lib = app.session.patterns.items.clone();
let groups: Vec<GroupView> = app
.session