mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-10 12:22:22 +00:00
Merge PR #10998: Actions: map recorded Scale Effects and Create Layers
This commit is contained in:
commit
545d447c10
8 files changed
+671
-3
No files matched your search
@@ -41,6 +41,7 @@ mod steps_doc;
|
||||
mod steps_gallery;
|
||||
mod steps_image;
|
||||
mod steps_layer;
|
||||
mod steps_layer_style;
|
||||
mod steps_make;
|
||||
|
||||
mod steps_stylize;
|
||||
|
||||
@@ -23,6 +23,7 @@ use super::steps_layer::{
|
||||
move_from, move_to, paste_effects_from, paste_effects_to, play_from, play_to, rotate_from, rotate_to, select_from, select_to, stroke_to, visibility_from,
|
||||
visibility_to,
|
||||
};
|
||||
use super::steps_layer_style::{scale_from, style_to};
|
||||
use super::steps_make::{locking_from, locking_to, make_from, make_to, set_from, set_to};
|
||||
use super::steps_other::{DE_INTERLACE, HSB_HSL, bare_from, bare_to, custom_from, custom_to, enums_from, enums_to};
|
||||
use super::tables::{NOISE, OFFSET_FILL, SMART_BLUR_MODE, SMART_BLUR_QUALITY, TRIM};
|
||||
@@ -764,6 +765,7 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
},
|
||||
Event { code: "Dplc", name: "duplicate", label: "Duplicate", ids: &["layer.duplicate", "image.duplicate"], from: duplicate_from, to: duplicate_to },
|
||||
// Layers.
|
||||
Event { code: "", name: "scaleEffectsEvent", label: "Scale Effects", ids: &["layer.layerStyle.scaleEffects"], from: scale_from, to: style_to },
|
||||
target_event!("reverse", "Reverse", "layer.arrange.reverse"),
|
||||
target_event!("selectLinkedLayers", "Select Linked Layers", "layer.selectLinkedLayers"),
|
||||
target_event!("disableLayerStyle", "Clear Layer Style", "layer.layerStyle.clear"),
|
||||
@@ -798,6 +800,7 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
ids: &[
|
||||
"type.convertToShape",
|
||||
"layer.new.layer",
|
||||
"layer.layerStyle.createLayer",
|
||||
"layer.new.group",
|
||||
"layer.groupLayers",
|
||||
"layer.duplicate",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! The measured Scale Effects event and Make-from-layer-effects form.
|
||||
use super::descriptors::{PCT, class, class_ref, desc, get_ref, jstr, pct, ref_class};
|
||||
use super::fields::{Mapped, step};
|
||||
use super::{D, Descriptor, Id, ReferenceItem, Value, json};
|
||||
|
||||
const SCALE: &str = "layer.layerStyle.scaleEffects";
|
||||
pub(super) const CREATE: &str = "layer.layerStyle.createLayer";
|
||||
const SOURCE: &str = "_atnLayerStyleDescriptor";
|
||||
|
||||
/// Only documented keys, once each: unknown or repeated settings cannot be discarded.
|
||||
fn keys(d: &Descriptor, expected: &[&str]) -> bool {
|
||||
d.items.len() == expected.len() && expected.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() == 1)
|
||||
}
|
||||
|
||||
/// The dialog records whole percentages; native fractional/clamped values are not guessed.
|
||||
fn whole_percent(value: f64) -> bool {
|
||||
value.is_finite() && (1.0..=1000.0).contains(&value) && value.fract() == 0.0
|
||||
}
|
||||
|
||||
/// The native Create Layers operation has only the measured implicit active-layer target.
|
||||
fn create_descriptor() -> Descriptor {
|
||||
desc("null").with("null", class_ref("Lyr ")).with(
|
||||
"Usng",
|
||||
D::Reference(vec![
|
||||
ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") },
|
||||
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse the exact recorded values and reference chain, keeping unsupported forms complete.
|
||||
fn parameters(id: &str, d: &Descriptor) -> Option<Value> {
|
||||
if id == SCALE {
|
||||
if !keys(d, &["Scl "]) {
|
||||
return None;
|
||||
}
|
||||
let D::UnitFloat { unit, value } = d.get("Scl ")? else { return None };
|
||||
return (*unit == PCT && whole_percent(*value)).then(|| json!({"scale":value}));
|
||||
}
|
||||
if id != CREATE || !keys(d, &["null", "Usng"]) {
|
||||
return None;
|
||||
}
|
||||
let [ReferenceItem::Class(layer)] = get_ref(d, "null")? else { return None };
|
||||
let [ReferenceItem::Property { class: property, key }, ReferenceItem::Enumerated { class: target, type_id, value }] = get_ref(d, "Usng")? else {
|
||||
return None;
|
||||
};
|
||||
(layer.class_id.is("Lyr ") && property.class_id.is("Prpr") && key.is("Lefx") && target.class_id.is("Lyr ") && type_id.is("Ordn") && value.is("Trgt"))
|
||||
.then(|| json!({}))
|
||||
}
|
||||
|
||||
/// Build supported native values explicitly; an absent scale uses the native default of 100.
|
||||
fn descriptor(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
let object = p.as_object()?;
|
||||
if object.keys().any(|key| key != SOURCE && !(id == SCALE && key == "scale")) {
|
||||
return None;
|
||||
}
|
||||
match id {
|
||||
SCALE => {
|
||||
let value = match p.get("scale") {
|
||||
None => 100.0,
|
||||
Some(v) => v.as_f64()?,
|
||||
};
|
||||
whole_percent(value).then(|| desc("null").with("Scl ", pct(value)))
|
||||
}
|
||||
CREATE => Some(create_descriptor()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve wire identity/order while unchanged, without making it a native option.
|
||||
fn from(id: &str, d: &Descriptor) -> Mapped {
|
||||
let Some(mut p) = parameters(id, d) else { return Mapped::Unsupported { harmless: false } };
|
||||
if descriptor(id, &p).as_ref() != Some(d) {
|
||||
p[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into();
|
||||
}
|
||||
step(id, p)
|
||||
}
|
||||
|
||||
/// Import Scale Effects only when the whole recorded percentage is represented.
|
||||
pub(super) fn scale_from(d: &Descriptor) -> Mapped {
|
||||
from(SCALE, d)
|
||||
}
|
||||
|
||||
/// Find typed effects references in parsed values; opaque data and class names are not references.
|
||||
fn has_style_reference(value: &D) -> bool {
|
||||
let mut pending = vec![value];
|
||||
while let Some(value) = pending.pop() {
|
||||
match value {
|
||||
D::Reference(parts) => {
|
||||
if parts.iter().any(|part| matches!(part, ReferenceItem::Property { key, .. } if key.is("Lefx"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
D::Descriptor(d) | D::GlobalObject(d) => pending.extend(d.items.iter().map(|(_, value)| value)),
|
||||
D::List(values) => pending.extend(values),
|
||||
D::ObjectArray(array) => pending.extend(array.body.items.iter().map(|(_, value)| value)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Intercept layer-style Make intent before generic Make can turn it into a blank layer.
|
||||
pub(super) fn create_from_descriptor(d: &Descriptor) -> Option<Mapped> {
|
||||
let layer_make = get_ref(d, "null").and_then(ref_class).as_deref() == Some("Lyr");
|
||||
let style_intent = d.items.iter().any(|(item_key, value)| {
|
||||
// Deep inspection belongs to Layer Make; other Make classes retain direct-reference handling.
|
||||
let style_reference = (layer_make || matches!(value, D::Reference(_))) && has_style_reference(value);
|
||||
if item_key.is("Usng") {
|
||||
// A layer Make may omit Usng, or use the ordinary layer-options object. Other
|
||||
// shapes must not be discarded as if they were an empty layer-options object.
|
||||
style_reference || (layer_make && !matches!(value, D::Descriptor(u) if u.class_id.is("Lyr ")))
|
||||
} else {
|
||||
// A miskeyed effects reference is still style intent, not an ordinary blank layer.
|
||||
layer_make && style_reference
|
||||
}
|
||||
});
|
||||
style_intent.then(|| from(CREATE, d))
|
||||
}
|
||||
|
||||
/// Validate preserved data before fallback; supported edits export their current canonical values.
|
||||
pub(super) fn style_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
let canonical = descriptor(id, p)?;
|
||||
if p.get(SOURCE).is_none() {
|
||||
return Some(canonical);
|
||||
}
|
||||
let source = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, SOURCE)?)?).ok()?;
|
||||
let original = descriptor(id, ¶meters(id, &source)?)?;
|
||||
Some(if original == canonical { source } else { canonical })
|
||||
}
|
||||
@@ -179,6 +179,9 @@ fn type_shape_descriptor() -> Descriptor {
|
||||
/// Import Make events only when their geometry and options can be represented faithfully,
|
||||
/// including the exact target-text-to-content-layer references.
|
||||
pub(super) fn make_from(d: &Descriptor) -> Mapped {
|
||||
if let Some(mapped) = super::steps_layer_style::create_from_descriptor(d) {
|
||||
return mapped;
|
||||
}
|
||||
if d.items == type_shape_descriptor().items {
|
||||
return none("type.convertToShape");
|
||||
}
|
||||
@@ -311,6 +314,9 @@ fn recorded_percent(opacity: f64) -> f64 {
|
||||
/// Write a creation step, refusing native options the target descriptor cannot carry; Type to
|
||||
/// Shape exports only without native options.
|
||||
pub(super) fn make_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
if id == super::steps_layer_style::CREATE {
|
||||
return super::steps_layer_style::style_to(id, p);
|
||||
}
|
||||
if id == "type.convertToShape" {
|
||||
return p.as_object().filter(|p| p.is_empty()).map(|_| type_shape_descriptor());
|
||||
}
|
||||
|
||||
@@ -755,6 +755,8 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
("layer.createClippingMask", json!({})),
|
||||
("layer.ungroupLayers", json!({})),
|
||||
("layer.layerStyle.copyLayerStyle", json!({})),
|
||||
("layer.layerStyle.scaleEffects", json!({"scale": 200})),
|
||||
("layer.layerStyle.createLayer", json!({})),
|
||||
("layer.layerStyle.pasteLayerStyle", json!({"allowGroups": true})),
|
||||
("layer.setExpanded", json!({"all": true, "expanded": false})),
|
||||
("layerComp.apply", json!({"comp": "Original"})),
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
//! Measured layer-style action steps retain their settings and use the existing native commands.
|
||||
use photocraft_doc::{Document, Effect, LayerId};
|
||||
use photocraft_engine::{
|
||||
Session,
|
||||
actions_cmds::Action,
|
||||
atn_cmds::{export, import},
|
||||
};
|
||||
use photocraft_psd::{
|
||||
atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem},
|
||||
descriptor::{Class, Descriptor, Id, MAX_DEPTH, ObjectArray, ReferenceItem, UnicodeString, Value as D},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const SCALE: &str = "layer.layerStyle.scaleEffects";
|
||||
const CREATE: &str = "layer.layerStyle.createLayer";
|
||||
|
||||
fn class(id: &str) -> Class {
|
||||
Class { name: UnicodeString::default(), class_id: Id::new(id) }
|
||||
}
|
||||
|
||||
fn scale(value: f64) -> Descriptor {
|
||||
Descriptor::new("null").with("Scl ", D::UnitFloat { unit: *b"#Prc", value })
|
||||
}
|
||||
|
||||
fn create() -> Descriptor {
|
||||
Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("Lyr "))])).with(
|
||||
"Usng",
|
||||
D::Reference(vec![
|
||||
ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") },
|
||||
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
fn item(id: &str, descriptor: Descriptor) -> AtnItem {
|
||||
let (event, name) = if id == SCALE { (AtnEvent::name("scaleEffectsEvent"), "Scale Effects") } else { (AtnEvent::Code(*b"Mk "), "Make") };
|
||||
AtnItem { event, dictionary: name.as_bytes().to_vec(), descriptor: Some(descriptor), ..Default::default() }
|
||||
}
|
||||
|
||||
fn file(items: Vec<AtnItem>) -> AtnFile {
|
||||
AtnFile {
|
||||
name: UnicodeString::new_nul("S"),
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn action(items: Vec<AtnItem>) -> Action {
|
||||
let loaded = import(&atn::write(&file(items)).unwrap()).unwrap();
|
||||
assert!(loaded.unsupported.is_empty());
|
||||
loaded.actions.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
fn exported(id: &str, params: Value) -> Option<AtnItem> {
|
||||
let a = Action::new("A", vec![(id.into(), params)]);
|
||||
let bytes = export("S", &[&a], &[], false).ok()?.0;
|
||||
atn::parse(&bytes).ok()?.actions.into_iter().next()?.items.into_iter().next()
|
||||
}
|
||||
|
||||
/// Wire bytes also compare preserved NaN payloads, whose floating values are not reflexively equal.
|
||||
fn same_item_wire(mut actual: AtnItem, mut expected: AtnItem) {
|
||||
assert_eq!(actual.descriptor.take().map(|d| d.to_bytes()), expected.descriptor.take().map(|d| d.to_bytes()));
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
/// The direct control must actually scale the known one-pixel shadow, not merely accept the call.
|
||||
fn scaled_shadow(s: &Session, source: LayerId, percent: f64) {
|
||||
let shadow = s
|
||||
.active()
|
||||
.unwrap()
|
||||
.doc
|
||||
.layer(source)
|
||||
.unwrap()
|
||||
.effects
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|effect| match effect {
|
||||
Effect::DropShadow(shadow) => Some(shadow),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let factor = percent as f32 / 100.0;
|
||||
assert_eq!((shadow.distance, shadow.size), (factor, factor));
|
||||
}
|
||||
|
||||
/// A small painted source with two effects makes a real conversion differ from a blank layer.
|
||||
fn fixture(depth: u32) -> (Session, LayerId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":8,"height":8,"depth":depth,"background":"transparent"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name":"Source"})).unwrap();
|
||||
let source = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("Synthetic pixels", |doc, _| {
|
||||
let pixels: Vec<f32> =
|
||||
(0..8).flat_map(|y| (0..8).flat_map(move |x| if (2..6).contains(&x) && (2..6).contains(&y) { [0.2, 0.5, 0.9, 1.0] } else { [0.0; 4] })).collect();
|
||||
let bounds = doc.bounds();
|
||||
doc.layer_mut(source).unwrap().surface_mut().unwrap().write_region(bounds, &pixels);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("layer.layerStyle.dropShadow", json!({"blend":"normal","distance":1,"size":1,"opacity":60})).unwrap();
|
||||
s.execute("layer.layerStyle.colorOverlay", json!({"color":"#ff8000","opacity":50,"add":true})).unwrap();
|
||||
(s, source)
|
||||
}
|
||||
|
||||
/// Compare each complete raster and its style/stack state without assuming cross-session IDs.
|
||||
fn equivalent(actual: &Session, expected: &Session) {
|
||||
let a = &actual.active().unwrap().doc;
|
||||
let b = &expected.active().unwrap().doc;
|
||||
assert_eq!(a.layers.len(), b.layers.len());
|
||||
assert_eq!(a.selection, b.selection);
|
||||
for (a, b) in a.layers.iter().zip(&b.layers) {
|
||||
assert_eq!((&a.name, a.clipped, a.blend, a.opacity), (&b.name, b.clipped, b.blend, b.opacity));
|
||||
assert_eq!(a.effects, b.effects);
|
||||
let (a, b) = (a.surface().unwrap(), b.surface().unwrap());
|
||||
assert_eq!(a.content_bounds(), b.content_bounds());
|
||||
let bounds = a.content_bounds().union(&b.content_bounds());
|
||||
assert_eq!(a.read_region(bounds), b.read_region(bounds));
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo exactly the native control's history edits, then redo the complete document state.
|
||||
fn undo_redo(s: &mut Session, before: &Document, after: &Document, edits: usize) {
|
||||
for _ in 0..edits {
|
||||
assert!(s.undo());
|
||||
}
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), before);
|
||||
for _ in 0..edits {
|
||||
assert!(s.redo());
|
||||
}
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after);
|
||||
}
|
||||
|
||||
/// Playback must produce actual effect layers, not the blank layer the old Make branch creates.
|
||||
#[test]
|
||||
fn create_layers_playback_matches_native_effect_layers_and_undo_at_each_depth() {
|
||||
for depth in [8, 16, 32] {
|
||||
let (mut native, native_source) = fixture(depth);
|
||||
let native_before = native.active().unwrap().doc.clone();
|
||||
native.execute(CREATE, json!({})).unwrap();
|
||||
let native_after = native.active().unwrap().doc.clone();
|
||||
assert_eq!(native_after.layers.len(), native_before.layers.len() + 2);
|
||||
assert!(native_after.layer(native_source).unwrap().effects.items.is_empty());
|
||||
undo_redo(&mut native, &native_before, &native_after, 1);
|
||||
|
||||
let (mut recorded, source) = fixture(depth);
|
||||
let before = recorded.active().unwrap().doc.clone();
|
||||
let past = recorded.active().unwrap().history.past_len();
|
||||
recorded.actions.list.push(action(vec![item(CREATE, create())]));
|
||||
let result = recorded.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(result["ran"], 1);
|
||||
assert!(result.get("failed").is_none(), "{depth}: {result}");
|
||||
let after = recorded.active().unwrap().doc.clone();
|
||||
assert_eq!(after.layers.len(), before.layers.len() + 2, "{depth}: Create Layers must split two effects, not create one blank layer");
|
||||
assert!(after.layer(source).unwrap().effects.items.is_empty());
|
||||
let at = after.layers.iter().position(|l| l.id == source).unwrap();
|
||||
assert!(after.layers[at - 1].name.ends_with("Drop Shadow") && !after.layers[at - 1].clipped);
|
||||
assert!(after.layers[at + 1].name.ends_with("Color Overlay") && after.layers[at + 1].clipped);
|
||||
assert_eq!(recorded.active().unwrap().history.past_len(), past + 1);
|
||||
equivalent(&recorded, &native);
|
||||
undo_redo(&mut recorded, &before, &after, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// The recorded whole-percent limits and interior values match native effect scaling at all depths.
|
||||
#[test]
|
||||
fn scale_effects_playback_matches_native_settings_and_undo_at_each_depth() {
|
||||
for depth in [8, 16, 32] {
|
||||
for percent in [1.0, 100.0, 200.0, 1000.0] {
|
||||
let (mut native, native_source) = fixture(depth);
|
||||
let native_past = native.active().unwrap().history.past_len();
|
||||
native.execute(SCALE, json!({"scale":percent})).unwrap();
|
||||
scaled_shadow(&native, native_source, percent);
|
||||
let edits = native.active().unwrap().history.past_len() - native_past;
|
||||
let (mut recorded, _) = fixture(depth);
|
||||
let before = recorded.active().unwrap().doc.clone();
|
||||
let past = recorded.active().unwrap().history.past_len();
|
||||
recorded.actions.list.push(action(vec![item(SCALE, scale(percent))]));
|
||||
let result = recorded.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(result["ran"], 1);
|
||||
assert!(result.get("failed").is_none(), "{depth}/{percent}: {result}");
|
||||
let after = recorded.active().unwrap().doc.clone();
|
||||
assert_eq!(recorded.active().unwrap().history.past_len(), past + edits);
|
||||
equivalent(&recorded, &native);
|
||||
undo_redo(&mut recorded, &before, &after, edits);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scaling then splitting keeps the same native result and two separately reversible edits.
|
||||
#[test]
|
||||
fn scale_then_create_layers_matches_native_sequence() {
|
||||
let (mut native, _) = fixture(16);
|
||||
native.execute(SCALE, json!({"scale":200})).unwrap();
|
||||
native.execute(CREATE, json!({})).unwrap();
|
||||
let (mut recorded, _) = fixture(16);
|
||||
let before = recorded.active().unwrap().doc.clone();
|
||||
let past = recorded.active().unwrap().history.past_len();
|
||||
recorded.actions.list.push(action(vec![item(SCALE, scale(200.0)), item(CREATE, create())]));
|
||||
let result = recorded.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(result["ran"], 2);
|
||||
assert!(result.get("failed").is_none());
|
||||
assert_eq!(recorded.active().unwrap().history.past_len(), past + 2);
|
||||
let after = recorded.active().unwrap().doc.clone();
|
||||
equivalent(&recorded, &native);
|
||||
undo_redo(&mut recorded, &before, &after, 2);
|
||||
}
|
||||
|
||||
/// Unedited wire identity/order/flags survive; a supported scale edit exports its new value.
|
||||
#[test]
|
||||
fn layer_style_descriptors_roundtrip_and_edited_scale_does_not_revive_source() {
|
||||
for (id, mut d) in [(SCALE, scale(100.0)), (CREATE, create())] {
|
||||
d.name = UnicodeString::new_nul("Recorded style step");
|
||||
d.class_id = Id::Str(b"null".to_vec());
|
||||
d.items.reverse();
|
||||
let mut source = item(id, d);
|
||||
source.enabled = false;
|
||||
source.with_dialog = true;
|
||||
source.dialog_options = 2;
|
||||
let a = action(vec![source.clone()]);
|
||||
assert_eq!(a.steps[0].0, id);
|
||||
let bytes = export("S", &[&a], &[], false).unwrap().0;
|
||||
let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items;
|
||||
assert_eq!(saved.len(), 1);
|
||||
same_item_wire(saved.into_iter().next().unwrap(), source);
|
||||
let mut damaged = a.steps[0].1.clone();
|
||||
damaged["_atnLayerStyleDescriptor"] = json!("not base64");
|
||||
assert!(exported(id, damaged).is_none());
|
||||
}
|
||||
let mut d = scale(100.0);
|
||||
d.name = UnicodeString::new_nul("Original");
|
||||
let mut a = action(vec![item(SCALE, d)]);
|
||||
a.steps[0].1["scale"] = json!(200);
|
||||
for skip in [false, true] {
|
||||
let (bytes, skipped) = export("S", &[&a], &[], skip).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(scale(200.0)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Native defaults are explicit; unrepresented targets, options and damaged metadata cannot vanish.
|
||||
#[test]
|
||||
fn layer_style_native_exports_are_strict_and_make_defaults_explicit() {
|
||||
assert_eq!(exported(SCALE, json!({})).unwrap().descriptor, Some(scale(100.0)));
|
||||
let mut canonical_create = create();
|
||||
// The shared canonical class constructor writes nul-terminated display names;
|
||||
// imported empty names remain separate wire data in the round-trip fixtures.
|
||||
for (_, value) in &mut canonical_create.items {
|
||||
let D::Reference(parts) = value else { panic!("fixture must contain references") };
|
||||
for part in parts {
|
||||
let class = match part {
|
||||
ReferenceItem::Class(class) | ReferenceItem::Property { class, .. } | ReferenceItem::Enumerated { class, .. } => class,
|
||||
_ => panic!("fixture must contain class/property/target references"),
|
||||
};
|
||||
class.name = UnicodeString::new_nul("");
|
||||
}
|
||||
}
|
||||
assert_eq!(exported(CREATE, json!({})).unwrap().descriptor, Some(canonical_create));
|
||||
for percent in [1, 100, 200, 1000] {
|
||||
assert_eq!(exported(SCALE, json!({"scale":percent})).unwrap().descriptor, Some(scale(f64::from(percent))));
|
||||
}
|
||||
for value in [json!(0), json!(1001), json!(1.5), Value::Null, json!("100"), json!(true)] {
|
||||
assert!(exported(SCALE, json!({"scale":value})).is_none());
|
||||
}
|
||||
for id in [SCALE, CREATE] {
|
||||
for key in ["layer", "layers", "target", "unknown"] {
|
||||
let mut p = json!({});
|
||||
p[key] = json!(1);
|
||||
assert!(exported(id, p).is_none(), "{id}/{key}");
|
||||
}
|
||||
let invalid = if id == SCALE { scale(100.0) } else { create() }.with("extra", D::Boolean(true));
|
||||
let mut p = if id == SCALE { json!({"scale":200}) } else { json!({}) };
|
||||
p["_atnLayerStyleDescriptor"] = json!(photocraft_paint::tile::b64_encode(&invalid.to_bytes()));
|
||||
assert!(exported(id, p).is_none());
|
||||
}
|
||||
assert!(exported(CREATE, json!({"scale":100})).is_none());
|
||||
}
|
||||
|
||||
/// Malformed style references remain whole and cannot fall through to ordinary blank-layer creation.
|
||||
#[test]
|
||||
fn layer_style_malformed_records_stay_kept_without_an_edit() {
|
||||
let mut cases = vec![(SCALE, Descriptor::new("null")), (CREATE, Descriptor::new("null").with("Usng", create().items[1].1.clone()))];
|
||||
for value in [D::Integer(100), D::Double(100.0), D::Boolean(true), D::UnitFloat { unit: *b"#Pxl", value: 100.0 }] {
|
||||
cases.push((SCALE, Descriptor::new("null").with("Scl ", value)));
|
||||
}
|
||||
for value in [0.0, 1001.0, 1.5, f64::NAN, f64::INFINITY] {
|
||||
cases.push((SCALE, scale(value)));
|
||||
}
|
||||
for (id, d) in [(SCALE, scale(100.0)), (CREATE, create())] {
|
||||
cases.push((id, d.clone().with("extra", D::Boolean(true))));
|
||||
let mut duplicate = d.clone();
|
||||
duplicate.items.push(duplicate.items[0].clone());
|
||||
cases.push((id, duplicate));
|
||||
}
|
||||
for null in [
|
||||
D::Integer(1),
|
||||
D::Reference(vec![ReferenceItem::Class(class("Chnl"))]),
|
||||
D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }]),
|
||||
] {
|
||||
let mut d = create();
|
||||
d.items[0].1 = null;
|
||||
cases.push((CREATE, d));
|
||||
}
|
||||
for using in [
|
||||
D::Integer(1),
|
||||
D::Boolean(false),
|
||||
D::Descriptor(Descriptor::new("unknown")),
|
||||
D::Reference(vec![]),
|
||||
D::Reference(vec![
|
||||
ReferenceItem::Property { class: class("Prpr"), key: Id::new("wrong") },
|
||||
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
|
||||
]),
|
||||
D::Reference(vec![
|
||||
ReferenceItem::Property { class: class("wrong"), key: Id::new("Lefx") },
|
||||
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
|
||||
]),
|
||||
D::Reference(vec![
|
||||
ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") },
|
||||
ReferenceItem::Enumerated { class: class("Dcmn"), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
|
||||
]),
|
||||
D::Reference(vec![
|
||||
ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") },
|
||||
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Al ") },
|
||||
]),
|
||||
D::Reference(vec![
|
||||
ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") },
|
||||
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("wrong"), value: Id::new("Trgt") },
|
||||
]),
|
||||
D::Reference(vec![
|
||||
ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") },
|
||||
ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("Source") },
|
||||
]),
|
||||
D::Reference(vec![
|
||||
ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") },
|
||||
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
|
||||
ReferenceItem::Class(class("Lyr ")),
|
||||
]),
|
||||
] {
|
||||
let mut d = create();
|
||||
d.items[1].1 = using;
|
||||
cases.push((CREATE, d));
|
||||
}
|
||||
for (id, d) in cases {
|
||||
let source = item(id, d);
|
||||
let loaded = import(&atn::write(&file(vec![source.clone()])).unwrap()).unwrap();
|
||||
assert_eq!(loaded.unsupported.len(), 1, "{id}: {source:?}");
|
||||
assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep");
|
||||
let bytes = export("S", &[&loaded.actions[0]], &[], false).unwrap().0;
|
||||
let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items;
|
||||
assert_eq!(saved.len(), 1);
|
||||
same_item_wire(saved.into_iter().next().unwrap(), source);
|
||||
let (mut s, _) = fixture(8);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
s.actions.list.extend(loaded.actions);
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(result["ran"], 0);
|
||||
assert!(result.get("failed").is_some());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past);
|
||||
}
|
||||
}
|
||||
|
||||
/// A miskeyed effects reference must be kept, rather than playing as an ordinary blank layer.
|
||||
#[test]
|
||||
fn miskeyed_effects_reference_stays_whole_without_a_blank_layer_edit() {
|
||||
let mut d = create();
|
||||
d.items[1].0 = Id::new("Usin");
|
||||
let source = item(CREATE, d);
|
||||
let loaded = import(&atn::write(&file(vec![source.clone()])).unwrap()).unwrap();
|
||||
let (mut s, _) = fixture(8);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
s.actions.list.extend(loaded.actions.clone());
|
||||
let result = s.execute("actions.play", json!({"action": 0})).unwrap();
|
||||
// Play before checking import classification: the old path actually creates a blank layer.
|
||||
assert!(
|
||||
s.active().unwrap().doc == before,
|
||||
"miskeyed Lefx reference changed the document: layers {} -> {}, history {} -> {}",
|
||||
before.layers.len(),
|
||||
s.active().unwrap().doc.layers.len(),
|
||||
past,
|
||||
s.active().unwrap().history.past_len()
|
||||
);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past);
|
||||
assert_eq!(result["ran"], 0);
|
||||
assert!(result.get("failed").is_some());
|
||||
assert_eq!(loaded.unsupported.len(), 1);
|
||||
assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep");
|
||||
let (bytes, skipped) = export("S", &[&loaded.actions[0]], &[], false).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items;
|
||||
assert_eq!(saved.len(), 1);
|
||||
same_item_wire(saved.into_iter().next().unwrap(), source);
|
||||
}
|
||||
|
||||
/// A normal layer Make, including class-only Make, still creates a blank layer without splitting fx.
|
||||
#[test]
|
||||
fn ordinary_layer_make_remains_a_legitimate_control() {
|
||||
for using in [None, Some(D::Descriptor(Descriptor::new("Lyr ").with("Nm ", D::Text(UnicodeString::new_nul("Ordinary")))))] {
|
||||
let mut d = Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("Lyr "))]));
|
||||
if let Some(u) = using {
|
||||
d = d.with("Usng", u);
|
||||
}
|
||||
let a = action(vec![item(CREATE, d)]);
|
||||
assert_eq!(a.steps[0].0, "layer.new.layer");
|
||||
let (mut s, source) = fixture(8);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
s.actions.list.push(a);
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(result["ran"], 1);
|
||||
assert!(result.get("failed").is_none());
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
assert_eq!(after.layers.len(), before.layers.len() + 1);
|
||||
assert_eq!(after.layer(source), before.layer(source));
|
||||
undo_redo(&mut s, &before, &after, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every typed container must retain nested effects intent without changing the document or history.
|
||||
#[test]
|
||||
fn nested_effects_references_stay_whole_without_a_blank_layer_edit() {
|
||||
let reference = create().items.pop().unwrap().1;
|
||||
let nested = Descriptor::new("Lyr ").with("Nm ", D::Text(UnicodeString::new_nul("Before"))).with("data", reference.clone());
|
||||
let mut deepest = reference.clone();
|
||||
// Root descriptor depth0, Usng value depth1, options descriptor depth2, data value depth3.
|
||||
for _ in 3..MAX_DEPTH {
|
||||
deepest = D::List(vec![D::Boolean(false), deepest]);
|
||||
}
|
||||
let cases = [
|
||||
("reference", reference.clone()),
|
||||
("descriptor", D::Descriptor(nested.clone())),
|
||||
("global object", D::GlobalObject(nested.clone())),
|
||||
("list", D::List(vec![D::Boolean(false), reference])),
|
||||
("object array", D::ObjectArray(ObjectArray { prefix: 16, body: nested.clone() })),
|
||||
(
|
||||
"mixed containers",
|
||||
D::List(vec![D::Boolean(false), D::GlobalObject(Descriptor::new("Lyr ").with("data", D::ObjectArray(ObjectArray { prefix: 16, body: nested })))]),
|
||||
),
|
||||
("parser depth limit", deepest),
|
||||
];
|
||||
for (label, value) in cases {
|
||||
for key in ["Usng", "Usin"] {
|
||||
let d = Descriptor::new("null")
|
||||
.with("null", D::Reference(vec![ReferenceItem::Class(class("Lyr "))]))
|
||||
.with(key, D::Descriptor(Descriptor::new("Lyr ").with("Nm ", D::Text(UnicodeString::new_nul("Ordinary"))).with("data", value.clone())));
|
||||
let source = item(CREATE, d);
|
||||
let loaded = import(&atn::write(&file(vec![source.clone()])).unwrap()).unwrap();
|
||||
let (mut s, _) = fixture(8);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
s.actions.list.extend(loaded.actions.clone());
|
||||
let result = s.execute("actions.play", json!({"action": 0})).unwrap();
|
||||
// Playback precedes classification so the baseline exposes the actual blank-layer edit.
|
||||
assert!(
|
||||
s.active().unwrap().doc == before,
|
||||
"{label}/{key}: nested Lefx changed the document: layers {} -> {}, history {} -> {}",
|
||||
before.layers.len(),
|
||||
s.active().unwrap().doc.layers.len(),
|
||||
past,
|
||||
s.active().unwrap().history.past_len()
|
||||
);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past, "{label}/{key}");
|
||||
assert_eq!(result["ran"], 0, "{label}/{key}");
|
||||
assert!(result.get("failed").is_some(), "{label}/{key}");
|
||||
assert_eq!(loaded.unsupported.len(), 1, "{label}/{key}");
|
||||
assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep", "{label}/{key}");
|
||||
let (bytes, skipped) = export("S", &[&loaded.actions[0]], &[], false).unwrap();
|
||||
assert!(skipped.is_empty(), "{label}/{key}");
|
||||
let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items;
|
||||
assert_eq!(saved.len(), 1, "{label}/{key}");
|
||||
same_item_wire(saved.into_iter().next().unwrap(), source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Native layer options still play with Lefx as a name or in synthetic opaque metadata.
|
||||
#[test]
|
||||
fn ordinary_layer_make_native_export_matches_native_playback() {
|
||||
let params = json!({"name": "Lefx", "opacity": 0.375, "mode": "Multiply", "clipToPrevious": true});
|
||||
let opaque = D::List(vec![D::RawData(create().to_bytes()), D::Alias(b"Lefx".to_vec()), D::Path(b"Lefx".to_vec()), D::Text(UnicodeString::new_nul("Lefx"))]);
|
||||
for metadata in [None, Some(opaque)] {
|
||||
let mut source = exported("layer.new.layer", params.clone()).unwrap();
|
||||
if let Some(value) = metadata {
|
||||
let D::Descriptor(options) = &mut source.descriptor.as_mut().unwrap().items[1].1 else { panic!("native layer options") };
|
||||
options.items.push((Id::new("data"), value));
|
||||
}
|
||||
let a = action(vec![source]);
|
||||
assert_eq!(a.steps[0], ("layer.new.layer".into(), params.clone()));
|
||||
let (mut native, _) = fixture(8);
|
||||
native.execute("layer.new.layer", params.clone()).unwrap();
|
||||
let (mut recorded, source) = fixture(8);
|
||||
let before = recorded.active().unwrap().doc.clone();
|
||||
let past = recorded.active().unwrap().history.past_len();
|
||||
recorded.actions.list.push(a);
|
||||
let result = recorded.execute("actions.play", json!({"action": 0})).unwrap();
|
||||
assert_eq!(result["ran"], 1);
|
||||
assert!(result.get("failed").is_none());
|
||||
let after = recorded.active().unwrap().doc.clone();
|
||||
assert_eq!(after.layers.len(), before.layers.len() + 1);
|
||||
assert_eq!(after.layer(source), before.layer(source));
|
||||
assert_eq!(recorded.active().unwrap().history.past_len(), past + 1);
|
||||
equivalent(&recorded, &native);
|
||||
undo_redo(&mut recorded, &before, &after, 1);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ mod affinity;
|
||||
mod artboard_export_names;
|
||||
mod atn_crystallize_cell_size;
|
||||
mod atn_direct_adjustments;
|
||||
mod atn_layer_style;
|
||||
mod atn_newer_events;
|
||||
mod atn_reference_forms;
|
||||
mod atn_ripple_amount;
|
||||
|
||||
+23
-3
@@ -99,7 +99,7 @@ the reference application.
|
||||
| `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` |
|
||||
| `LnsF` | lensFlare | Lens Flare | `filter.render.lensFlare` |
|
||||
| `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` |
|
||||
| `Mk ` | make | Make | `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot`, `type.convertToShape` |
|
||||
| `Mk ` | make | Make | `layer.layerStyle.createLayer`, `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot`, `type.convertToShape` |
|
||||
| (string ID only) | animationFramesFromLayers | Make Frames from Layers | |
|
||||
| (string ID only) | matchColor | Match Color | `image.adjustments.matchColor` |
|
||||
| `Mxm ` | maximum | Maximum | `filter.other.maximum` |
|
||||
@@ -150,6 +150,7 @@ the reference application.
|
||||
| `Shw ` | show | Show | `layer.showLayers`, `layer.showOnly` |
|
||||
| `Smlr` | similar | Similar | `select.similar` |
|
||||
| (string ID only) | selectSky | Sky | `select.sky` |
|
||||
| (string ID only) | scaleEffectsEvent | Scale Effects | `layer.layerStyle.scaleEffects` |
|
||||
| `SmrB` | smartBlur | Smart Blur | `filter.blur.smartBlur` |
|
||||
| (string ID only) | smartSharpen | Smart Sharpen | `filter.sharpen.smartSharpen` |
|
||||
| `Smth` | smoothness | Smooth | `select.modify.smooth` |
|
||||
@@ -247,6 +248,27 @@ The separate `CropPhotos0001` helper remains a complete kept Photoshop step.
|
||||
Playback stops at that enabled helper before subsequent duplicate/crop steps can
|
||||
run. This covers the parent event, not complete recordings with generated helpers.
|
||||
|
||||
### Layer Style menu recordings
|
||||
|
||||
`scaleEffectsEvent` maps to `layer.layerStyle.scaleEffects` with the recorded `Scl `
|
||||
whole percentage in `#Prc`, from 1 through 1000. A missing recorded value is not
|
||||
given a guessed default. Native export makes its effective default of 100 explicit;
|
||||
fractional, out-of-range and unrepresented options refuse export.
|
||||
|
||||
Create Layer(s) maps Make (`Mk ` / `make`) to `layer.layerStyle.createLayer` when
|
||||
`null` is the `Lyr ` class reference and `Usng` is the `Lefx` property of the target
|
||||
layer (`Prpr.Lefx`, `Lyr .Ordn.Trgt`). It has no recorded operation settings.
|
||||
The existing native command creates the effect layers. The warning dialog is not
|
||||
an extra action parameter. Explicit native layer IDs have no measured export form.
|
||||
Malformed references stay complete kept steps, rather than creating a blank layer;
|
||||
ordinary layer creation still uses its layer-options object or class-only form.
|
||||
|
||||
Both mappings retain descriptor identity, order and step flags while unchanged.
|
||||
A supported scale edit exports its new value canonically; damaged preservation
|
||||
metadata remains refused. These two rows are the implemented subset of #10723;
|
||||
the other menu recordings remain separate work. Playback reuses native history
|
||||
and rendering; this does not claim Photoshop pixel equivalence.
|
||||
|
||||
### Settings retained by mapped events
|
||||
|
||||
- Transform keeps its recorded `FTcs` reference point. Show with `TglO` true maps to
|
||||
@@ -524,8 +546,6 @@ layer reference, `Ordn.Trgt`); mapping them needs only the event name.
|
||||
|
||||
| Menu | Recorded step | Keys |
|
||||
|---|---|---|
|
||||
| Layer > Layer Style > Scale Effects | `scaleEffectsEvent` | `Scl ` percent, whole number 1..1000 (the dialog clamps) |
|
||||
| Layer > Layer Style > Create Layer(s) | Make, `null` = class `Lyr `, `Usng` = reference to the `Lefx` property of the target layer | none; the dialog first warns that some effects cannot be reproduced with layers (OK / Cancel) |
|
||||
| Layer > New > Frame from Layers | Make, `null` = class `framedGroupSection`, `From` = layer **by name**, `layerSectionStart`, `layerSectionEnd` long, `Nm ` text | the dialog also shows width and height, which are not recorded |
|
||||
| Layer > New > Artboard from Layers | Make, `null` = class `artboardSection`, `From` = layer target, `layerSectionStart`, `layerSectionEnd`, `Nm ` | |
|
||||
| Layer > New > Artboard | Make, `null` = class `artboardSection`, `layerSectionStart`, `layerSectionEnd`, `Nm `, `artboardRect` (`Top `, `Left`, `Btom`, `Rght` doubles, pixels) | |
|
||||
|
||||
Reference in new issue
Block a user