mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-07 10:54:10 +00:00
Integration: real recorded Convert to Profile and Paste steps play, sparse parallel dither (#10704)
- Convert to Profile: the reference application records `sdwM` -1 for RGB targets (`convertToProfile T="ProPhoto RGB" Inte=AClr Dthr=true Fltt=false rasterizePlaced=false sdwM=-1`). The PR accepted only 2, so every recorded step became a kept step that fails at playback. Accept and write -1. - Paste: an ordinary Edit > Paste records `AntA Anno` + `As Pxel` (Paste in Place adds `inPlace true`). `As Pxel` was routed through the explicit vector mode (no layer recreation, no mask/channel targets) and Paste in Place became a kept step. `As Pxel` now keeps the ordinary Paste / Paste in Place route. - Dither: transparent layers stay sparse (only stored tiles are dithered) and tiles run in parallel. 24 MP 8-bit, one layer: opaque 1576 -> 101 ms; transparent with one stored tile 1670 -> 5 ms and 384 -> 1 stored tiles. - Image Size: a recorded step without `scaleStyles` (Fit Image) gained `scaleStyles false` on save; the key is now written only when styles scale. Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
072a87e7b0
commit
8a0f959d29
7 files changed
+210
-60
No files matched your search
@@ -427,7 +427,12 @@ pub(super) fn image_size_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
if let Some(i) = jstr(p, "resample").filter(|r| *r != "none") {
|
||||
d = d.with("Intr", enm("Intp", lookup_back(INTERPOLATION, i)?));
|
||||
}
|
||||
Some(d.with("scaleStyles", D::Boolean(jbool(p, "scaleStyles").unwrap_or(true))))
|
||||
// A recorded step without the key keeps style sizes, so only scaling needs the key: an
|
||||
// Image Size step recorded without it saves back unchanged.
|
||||
if jbool(p, "scaleStyles").unwrap_or(true) {
|
||||
d = d.with("scaleStyles", D::Boolean(true));
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
|
||||
pub(super) fn crop_from(d: &Descriptor) -> Mapped {
|
||||
@@ -486,19 +491,28 @@ pub(super) fn stop_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
pub(super) fn paste_from(d: &Descriptor) -> Mapped {
|
||||
// As is a class, not text or an enum; unknown forms must not fall back to raster paste.
|
||||
if let Some(value) = d.get("As ") {
|
||||
// `Pxel` is what an ordinary Paste and Paste in Place record; it keeps the ordinary route
|
||||
// (layer recreation, mask and channel targets, in place). Only a vector form is explicit.
|
||||
let mode = match value {
|
||||
D::Class(value) if value.class_id.is("Pxel") => "pixels",
|
||||
D::Class(value) if value.class_id.is("Path") => "path",
|
||||
D::Class(value) if value.class_id.is("shapeClass") => "shape",
|
||||
D::Class(value) if value.class_id.is("Pxel") => None,
|
||||
D::Class(value) if value.class_id.is("Path") => Some("path"),
|
||||
D::Class(value) if value.class_id.is("shapeClass") => Some("shape"),
|
||||
_ => return Mapped::Unsupported { harmless: false },
|
||||
};
|
||||
if !matches!(d.get("inPlace"), None | Some(D::Boolean(false))) || d.get("null").is_some() {
|
||||
let in_place = match d.get("inPlace") {
|
||||
None => false,
|
||||
Some(D::Boolean(value)) => *value,
|
||||
Some(_) => return Mapped::Unsupported { harmless: false },
|
||||
};
|
||||
if (in_place && mode.is_some()) || d.get("null").is_some() {
|
||||
return Mapped::Unsupported { harmless: false };
|
||||
}
|
||||
if d.get("AntA").is_some_and(|v| !matches!(v, D::Enumerated { type_id, value } if type_id.is("Annt") && value.is("Anno"))) {
|
||||
return Mapped::Unsupported { harmless: false };
|
||||
}
|
||||
return step("edit.paste", json!({"as": mode}));
|
||||
if let Some(mode) = mode {
|
||||
return step("edit.paste", json!({"as": mode}));
|
||||
}
|
||||
}
|
||||
none(if get_bool(d, "inPlace") == Some(true) { "edit.pasteSpecial.pasteInPlace" } else { "edit.paste" })
|
||||
}
|
||||
@@ -574,8 +588,9 @@ pub(super) fn profile_from(d: &Descriptor, id: &str) -> Mapped {
|
||||
let Some(profile) = name.as_deref().and_then(|n| lookup(PROFILES, n)) else { return Mapped::Unsupported { harmless: false } };
|
||||
let mut p = json!({"profile": profile});
|
||||
if id == "edit.convertToProfile" {
|
||||
// All mapped PROFILES are RGB. Other radio values need their own target mapping.
|
||||
if !matches!(d.get("sdwM"), None | Some(D::Integer(2))) {
|
||||
// All mapped PROFILES are RGB; the reference application records -1 for these RGB
|
||||
// targets. Other values need their own target mapping.
|
||||
if !matches!(d.get("sdwM"), None | Some(D::Integer(-1))) {
|
||||
return Mapped::Unsupported { harmless: false };
|
||||
}
|
||||
for (key, param) in [("Dthr", "dither"), ("Fltt", "flatten"), ("rasterizePlaced", "rasterizePlaced")] {
|
||||
@@ -597,7 +612,7 @@ pub(super) fn profile_from(d: &Descriptor, id: &str) -> Mapped {
|
||||
step(id, p)
|
||||
}
|
||||
|
||||
/// Export effective native flags explicitly, deriving the RGB radio value from the mapped target.
|
||||
/// Export effective native flags explicitly, with the `sdwM` value recorded for RGB targets.
|
||||
pub(super) fn profile_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
let name = lookup_back(PROFILES, jstr(p, "profile")?)?;
|
||||
let mut d = desc("null").with("null", target_ref("Dcmn"));
|
||||
@@ -609,7 +624,7 @@ pub(super) fn profile_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
.with("T ", text(name))
|
||||
.with("Inte", enm("Inte", lookup_back(INTENT, jstr(p, "intent").unwrap_or("relative"))?))
|
||||
.with("MpBl", D::Boolean(jbool(p, "bpc").unwrap_or(true)))
|
||||
.with("sdwM", D::Integer(2));
|
||||
.with("sdwM", D::Integer(-1));
|
||||
for (key, param) in [("Dthr", "dither"), ("Fltt", "flatten"), ("rasterizePlaced", "rasterizePlaced")] {
|
||||
let value = match p.get(param) {
|
||||
None | Some(Value::Null) => false,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Opt-in 8-bit profile quantization. The ordinary CMS buffer paths stay unchanged.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
|
||||
use photocraft_raster::Tile;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use super::{
|
||||
ColorMode, Document, EngineError, Intent, PixelFormat, Profile, Result, SampleType, Surface, Transform, cms_err, convert_color, convert_document,
|
||||
@@ -50,12 +54,29 @@ fn tile_origin(c: TileCoord) -> Result<(i32, i32)> {
|
||||
Ok((x as i32, y as i32))
|
||||
}
|
||||
|
||||
/// A transparent default stays transparent: its converted default already stands for the absent
|
||||
/// tiles, and dither noise under alpha 0 is invisible, so only stored tiles are dithered.
|
||||
fn sparse(s: &Surface) -> bool {
|
||||
let sf = s.format();
|
||||
sf.alpha && s.default_pixel().get(sf.mode.color_channels()).is_some_and(|a| *a == 0.0)
|
||||
}
|
||||
|
||||
/// The tiles the dithered output stores: every in-canvas tile (unless the surface is sparse)
|
||||
/// plus every stored tile.
|
||||
fn dither_tiles(s: &Surface, bounds: Rect) -> Vec<TileCoord> {
|
||||
if sparse(s) {
|
||||
return s.tiles().map(|(c, _)| *c).collect();
|
||||
}
|
||||
let outside = s.tiles().map(|(c, _)| *c).filter(|c| !in_canvas_tiles(*c, bounds));
|
||||
bounds.tiles().chain(outside).collect()
|
||||
}
|
||||
|
||||
/// Price every additional output tile before allocating any; distant tiles add no bounding box.
|
||||
fn output_bytes(s: &Surface, to: PixelFormat, bounds: Rect) -> Result<u128> {
|
||||
let mut count = u128::from(bounds.tile_count());
|
||||
let mut count = if sparse(s) { 0 } else { u128::from(bounds.tile_count()) };
|
||||
for (c, _) in s.tiles() {
|
||||
tile_origin(*c)?;
|
||||
if !in_canvas_tiles(*c, bounds) {
|
||||
if sparse(s) || !in_canvas_tiles(*c, bounds) {
|
||||
count = count.checked_add(1).ok_or_else(|| bad("dither output size overflowed"))?;
|
||||
}
|
||||
}
|
||||
@@ -78,46 +99,53 @@ fn quantize(value: f32, threshold: f64) -> u8 {
|
||||
}
|
||||
|
||||
/// Stream the finite canvas and stored deviations, retaining the undithered infinite default.
|
||||
/// Tiles are independent (the noise depends on pixel coordinates only) and run in parallel.
|
||||
fn surface(s: &Surface, to: PixelFormat, t: &Transform, bounds: Rect) -> Result<Surface> {
|
||||
let (ss, ds) = (s.channels(), to.channels());
|
||||
let (ni, no) = (t.inputs(), t.outputs());
|
||||
let ds = to.channels();
|
||||
let mut converted_default = [0.0; 16];
|
||||
t.convert_f32(&s.default_pixel(), ss, &mut converted_default[..ds], ds, true);
|
||||
t.convert_f32(&s.default_pixel(), s.channels(), &mut converted_default[..ds], ds, true);
|
||||
let mut out = Surface::with_default(to, &converted_default[..ds]);
|
||||
let tiles = dither_tiles(s, bounds).par_iter().map(|c| dither_tile(s, &out, t, bounds, *c).map(|tile| (*c, Arc::new(tile)))).collect::<Result<Vec<_>>>()?;
|
||||
out.put_tiles(tiles);
|
||||
out.prune();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One output tile of [`surface`], starting from the converted default of `out`.
|
||||
fn dither_tile(s: &Surface, out: &Surface, t: &Transform, bounds: Rect, c: TileCoord) -> Result<Tile> {
|
||||
let (ss, ds) = (s.channels(), out.channels());
|
||||
let (ni, no) = (t.inputs(), t.outputs());
|
||||
let default = s.default_bytes();
|
||||
let outside = s.tiles().map(|(c, _)| *c).filter(|c| !in_canvas_tiles(*c, bounds));
|
||||
for c in bounds.tiles().chain(outside) {
|
||||
let (x0, y0) = tile_origin(c)?;
|
||||
let source = s.tile(c).map(|tile| tile.bytes());
|
||||
let dest = out.try_tile_mut(c).map_err(|e| EngineError::Other(format!("could not allocate dither output: {e}")))?;
|
||||
for (index, dp) in dest.bytes_mut().chunks_exact_mut(ds).enumerate() {
|
||||
let x = x0 + (index % TILE_SIZE as usize) as i32;
|
||||
let y = y0 + (index / TILE_SIZE as usize) as i32;
|
||||
let sp = match source {
|
||||
Some(bytes) => bytes.get(index * ss..index * ss + ss).ok_or_else(|| bad("a stored tile has invalid sample storage"))?,
|
||||
None => default,
|
||||
};
|
||||
// A redundant default tile outside the canvas must match the implicit plane.
|
||||
if !bounds.contains(x, y) && sp == default {
|
||||
continue;
|
||||
}
|
||||
let mut input = [0.0; 16];
|
||||
let mut output = [0.0; 16];
|
||||
for (d, s) in input.iter_mut().zip(sp).take(ni) {
|
||||
*d = f32::from(*s) / 255.0;
|
||||
}
|
||||
t.eval_fast(&input[..ni], &mut output[..no]);
|
||||
// The hash can return 1.0; recenter its 65536 bins strictly inside (0, 1).
|
||||
let threshold = (f64::from(photocraft_color::dither_noise(x, y)) * 65535.0 + 0.5) / 65536.0;
|
||||
for (d, value) in dp.iter_mut().zip(output).take(no) {
|
||||
*d = quantize(value, threshold);
|
||||
}
|
||||
for (d, s) in dp.iter_mut().skip(no).zip(sp.iter().skip(ni)) {
|
||||
*d = *s;
|
||||
}
|
||||
let (x0, y0) = tile_origin(c)?;
|
||||
let source = s.tile(c).map(|tile| tile.bytes());
|
||||
let mut dest = out.try_default_tile().map_err(|e| EngineError::Other(format!("could not allocate dither output: {e}")))?;
|
||||
for (index, dp) in dest.bytes_mut().chunks_exact_mut(ds).enumerate() {
|
||||
let x = x0 + (index % TILE_SIZE as usize) as i32;
|
||||
let y = y0 + (index / TILE_SIZE as usize) as i32;
|
||||
let sp = match source {
|
||||
Some(bytes) => bytes.get(index * ss..index * ss + ss).ok_or_else(|| bad("a stored tile has invalid sample storage"))?,
|
||||
None => default,
|
||||
};
|
||||
// A redundant default tile outside the canvas must match the implicit plane.
|
||||
if !bounds.contains(x, y) && sp == default {
|
||||
continue;
|
||||
}
|
||||
let mut input = [0.0; 16];
|
||||
let mut output = [0.0; 16];
|
||||
for (d, s) in input.iter_mut().zip(sp).take(ni) {
|
||||
*d = f32::from(*s) / 255.0;
|
||||
}
|
||||
t.eval_fast(&input[..ni], &mut output[..no]);
|
||||
// The hash can return 1.0; recenter its 65536 bins strictly inside (0, 1).
|
||||
let threshold = (f64::from(photocraft_color::dither_noise(x, y)) * 65535.0 + 0.5) / 65536.0;
|
||||
for (d, value) in dp.iter_mut().zip(output).take(no) {
|
||||
*d = quantize(value, threshold);
|
||||
}
|
||||
for (d, s) in dp.iter_mut().skip(no).zip(sp.iter().skip(ni)) {
|
||||
*d = *s;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Preserve ordinary conversion outside opt-in U8, admitting the whole dither edit first.
|
||||
|
||||
@@ -119,7 +119,7 @@ fn rendered(s: &Session) -> Vec<[f32; 4]> {
|
||||
#[test]
|
||||
fn recorded_rgb_flags_survive_save_reimport_and_public_playback() {
|
||||
for values in [[true, true, true], [true, false, true], [true, false, false], [false, false, false]] {
|
||||
let mut d = descriptor().with("sdwM", D::Integer(2));
|
||||
let mut d = descriptor().with("sdwM", D::Integer(-1));
|
||||
let mut params = json!({"profile": "srgb", "intent": "relative", "bpc": false});
|
||||
for ((key, param), value) in FLAGS.into_iter().zip(values) {
|
||||
d = d.with(key, D::Boolean(value));
|
||||
@@ -149,7 +149,7 @@ fn recorded_rgb_flags_survive_save_reimport_and_public_playback() {
|
||||
let saved = save(&mut replay);
|
||||
let parsed = atn::parse(&saved).unwrap();
|
||||
let saved_d = parsed.actions[0].items[0].descriptor.as_ref().unwrap();
|
||||
assert_eq!(saved_d.get("sdwM"), Some(&D::Integer(2)));
|
||||
assert_eq!(saved_d.get("sdwM"), Some(&D::Integer(-1)));
|
||||
for ((key, _), value) in FLAGS.into_iter().zip(values) {
|
||||
assert_eq!(saved_d.get(key), Some(&D::Boolean(value)));
|
||||
}
|
||||
@@ -205,7 +205,7 @@ fn defaults_are_explicit_false_and_assign_profile_is_unchanged() {
|
||||
for (key, _) in FLAGS {
|
||||
assert_eq!(d.get(key), Some(&D::Boolean(false)));
|
||||
}
|
||||
assert_eq!(d.get("sdwM"), Some(&D::Integer(2)));
|
||||
assert_eq!(d.get("sdwM"), Some(&D::Integer(-1)));
|
||||
}
|
||||
let assign = Action::new("Assign", vec![("edit.assignProfile".into(), json!({"profile": "srgb"}))]);
|
||||
let (bytes, skipped) = atn_cmds::export("Profile", &[&assign], &[], false).unwrap();
|
||||
@@ -222,12 +222,12 @@ fn defaults_are_explicit_false_and_assign_profile_is_unchanged() {
|
||||
#[test]
|
||||
fn unsupported_values_are_preserved_and_invalid_native_values_refuse_export() {
|
||||
let mut descriptors = Vec::new();
|
||||
for value in [D::Integer(3), D::Integer(4), D::Integer(-1), D::Integer(999), D::Boolean(true), D::Double(2.0)] {
|
||||
for value in [D::Integer(3), D::Integer(4), D::Integer(2), D::Integer(999), D::Boolean(true), D::Double(2.0)] {
|
||||
descriptors.push(descriptor().with("sdwM", value));
|
||||
}
|
||||
for (key, _) in FLAGS {
|
||||
for value in [D::Integer(1), D::Text(UnicodeString::new_nul("true"))] {
|
||||
descriptors.push(descriptor().with("sdwM", D::Integer(2)).with(key, value));
|
||||
descriptors.push(descriptor().with("sdwM", D::Integer(-1)).with(key, value));
|
||||
}
|
||||
}
|
||||
for d in descriptors {
|
||||
@@ -251,3 +251,36 @@ fn unsupported_values_are_preserved_and_invalid_native_values_refuse_export() {
|
||||
let action = Action::new("Invalid radio null", vec![("edit.convertToProfile".into(), json!({"profile": "srgb", "sdwM": null}))]);
|
||||
assert!(atn_cmds::export("Profile", &[&action], &[], false).is_err());
|
||||
}
|
||||
|
||||
/// A step recorded by the reference application (`sdwM` -1, dither on) maps and saves back as recorded.
|
||||
#[test]
|
||||
fn reference_recorded_convert_to_profile_maps() {
|
||||
let d = Descriptor::new("null")
|
||||
.with(
|
||||
"null",
|
||||
D::Reference(vec![photocraft_psd::descriptor::ReferenceItem::Enumerated {
|
||||
class: photocraft_psd::descriptor::Class { name: UnicodeString::new_nul(""), class_id: Id::new("Dcmn") },
|
||||
type_id: Id::new("Ordn"),
|
||||
value: Id::new("Trgt"),
|
||||
}]),
|
||||
)
|
||||
.with("T ", D::Text(UnicodeString::new_nul("ProPhoto RGB")))
|
||||
.with("Inte", D::Enumerated { type_id: Id::new("Inte"), value: Id::new("AClr") })
|
||||
.with("Dthr", D::Boolean(true))
|
||||
.with("Fltt", D::Boolean(false))
|
||||
.with("rasterizePlaced", D::Boolean(false))
|
||||
.with("sdwM", D::Integer(-1));
|
||||
let mut s = fixture();
|
||||
assert_eq!(load(&mut s, &recorded(d))["unsupported"], json!([]));
|
||||
assert_eq!(
|
||||
s.actions.list[0].steps,
|
||||
vec![(
|
||||
"edit.convertToProfile".into(),
|
||||
json!({"profile": "prophoto-compat", "intent": "absolute", "dither": true, "flatten": false, "rasterizePlaced": false})
|
||||
)]
|
||||
);
|
||||
let saved = atn::parse(&save(&mut s)).unwrap();
|
||||
let saved = saved.actions[0].items[0].descriptor.as_ref().unwrap();
|
||||
assert_eq!(saved.get("sdwM"), Some(&D::Integer(-1)));
|
||||
assert_eq!(saved.get("Dthr"), Some(&D::Boolean(true)));
|
||||
}
|
||||
@@ -209,3 +209,21 @@ fn refusals_are_early_and_atomic() {
|
||||
assert!(state.history.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
/// Absent tiles of a transparent layer stay absent: only the stored tile is dithered.
|
||||
#[test]
|
||||
fn transparent_layers_stay_sparse() {
|
||||
let p = json!({"profile": "display-p3", "dither": true});
|
||||
let stored = |size: Size| {
|
||||
let mut surface = Surface::with_default(PixelFormat::RGBA8, &[0.0; 4]);
|
||||
let tile = surface.solid_tile(&GRAY);
|
||||
surface.put_tiles([(TileCoord::new(1, 1), tile)]);
|
||||
document(size, surface, SampleType::U8)
|
||||
};
|
||||
let large = converted(stored(Size::new(1024, 1024)), p.clone());
|
||||
assert_eq!(pixels(&large).tile_count(), 1, "a transparent layer must not become dense");
|
||||
let small = converted(stored(Size::new(512, 512)), p);
|
||||
let tile = |doc: &Document| pixels(doc).tile(TileCoord::new(1, 1)).unwrap().bytes().to_vec();
|
||||
assert_eq!(tile(&large), tile(&small), "the stored tile is dithered as before");
|
||||
assert_eq!(pixels(&large).pixel(700, 700)[3], 0.0);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use photocraft_doc::{Effect, Effects, FxCommon, FxPaint, LayerId, StrokeFx, Stro
|
||||
use photocraft_engine::{Session, actions_cmds::Action, atn_cmds};
|
||||
use photocraft_psd::{
|
||||
atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem},
|
||||
descriptor::{Descriptor, UnicodeString, Value as D},
|
||||
descriptor::{Descriptor, Id, UnicodeString, Value as D},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -83,7 +83,8 @@ fn imported_style_scaling_survives_saved_bytes_and_playback() {
|
||||
assert_eq!(s.actions.list[0].steps, vec![("image.imageSize".into(), json!({"percent": 50, "scaleStyles": scale}))]);
|
||||
let saved = save(&mut s);
|
||||
let parsed = atn::parse(&saved).unwrap();
|
||||
assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get("scaleStyles"), Some(&D::Boolean(scale)));
|
||||
// Only scaling needs the key: without it the step keeps style sizes.
|
||||
assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get("scaleStyles"), scale.then_some(&D::Boolean(true)));
|
||||
let imported = atn_cmds::import(&saved).unwrap();
|
||||
assert!(imported.unsupported.is_empty());
|
||||
assert_eq!(imported.actions[0].steps, s.actions.list[0].steps);
|
||||
@@ -109,7 +110,7 @@ fn native_default_is_written_explicitly_and_keeps_its_behavior() {
|
||||
let (bytes, skipped) = atn_cmds::export("Styles", &[&action], &[], false).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
let parsed = atn::parse(&bytes).unwrap();
|
||||
assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get("scaleStyles"), Some(&D::Boolean(scale)), "{params}");
|
||||
assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get("scaleStyles"), scale.then_some(&D::Boolean(true)), "{params}");
|
||||
let (mut direct, direct_id) = fixture();
|
||||
direct.execute("image.imageSize", params).unwrap();
|
||||
assert_size_and_style(&direct, direct_id, scale);
|
||||
@@ -150,3 +151,21 @@ fn malformed_or_empty_steps_are_kept_without_becoming_resizes() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A recorded Image Size step without `scaleStyles` (Fit Image records none) saves back identically.
|
||||
#[test]
|
||||
fn step_without_scale_styles_saves_back_identically() {
|
||||
for event in ["ImgS", "imageSize"] {
|
||||
let fit = Descriptor::new("null")
|
||||
.with("Wdth", D::UnitFloat { unit: *b"#Pxl", value: 100.0 })
|
||||
.with("Hght", D::UnitFloat { unit: *b"#Pxl", value: 78.0 })
|
||||
.with("Rslt", D::UnitFloat { unit: *b"#Rsl", value: 72.0 })
|
||||
.with("Intr", D::Enumerated { type_id: Id::new("Intp"), value: Id::new("Bcbc") });
|
||||
let source = recorded(event, Some(fit));
|
||||
let original = atn::parse(&source).unwrap();
|
||||
let mut s = Session::new();
|
||||
assert_eq!(load(&mut s, &source)["unsupported"], json!([]));
|
||||
let saved = atn::parse(&save(&mut s)).unwrap();
|
||||
assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor);
|
||||
}
|
||||
}
|
||||
@@ -279,10 +279,14 @@ fn atn_classes_roundtrip_and_play_real_modes() {
|
||||
for (class_id, mode) in [("Pxel", "pixels"), ("Path", "path"), ("shapeClass", "shape")] {
|
||||
let mut s = scene(8);
|
||||
assert_eq!(load(&mut s, &recorded(descriptor(class_id)))["unsupported"], json!([]));
|
||||
assert_eq!(s.actions.list[0].steps, vec![("edit.paste".into(), json!({"as":mode}))]);
|
||||
// `As Pxel` is what an ordinary Paste records: it keeps the ordinary route.
|
||||
let params = if mode == "pixels" { json!({}) } else { json!({"as":mode}) };
|
||||
assert_eq!(s.actions.list[0].steps, vec![("edit.paste".into(), params)]);
|
||||
let saved = save(&mut s);
|
||||
let file = atn::parse(&saved).unwrap();
|
||||
assert_eq!(file.actions[0].items[0].descriptor.as_ref().unwrap().get("As "), descriptor(class_id).get("As "));
|
||||
if mode != "pixels" {
|
||||
assert_eq!(file.actions[0].items[0].descriptor.as_ref().unwrap().get("As "), descriptor(class_id).get("As "));
|
||||
}
|
||||
let imported = atn_cmds::import(&saved).unwrap();
|
||||
assert_eq!(imported.actions[0].steps, s.actions.list[0].steps);
|
||||
s.actions.list = imported.actions;
|
||||
@@ -333,3 +337,35 @@ fn atn_unknown_forms_are_kept_and_legacy_omission_stays_legacy() {
|
||||
assert!(atn_cmds::export("Vector", &[&action], &[], false).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn recorded_pixels_paste(in_place: bool) -> Descriptor {
|
||||
let d = Descriptor::new("null").with("AntA", D::Enumerated { type_id: Id::new("Annt"), value: Id::new("Anno") });
|
||||
let d = if in_place { d.with("inPlace", D::Boolean(true)) } else { d };
|
||||
d.with("As ", descriptor("Pxel").get("As ").unwrap().clone())
|
||||
}
|
||||
|
||||
/// A recorded ordinary Paste (`AntA Anno`, `As Pxel`) keeps whole-layer recreation of a copied layer.
|
||||
#[test]
|
||||
fn recorded_pixels_paste_keeps_the_ordinary_route() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 20, "height": 16, "background": "transparent"})).unwrap();
|
||||
s.clipboard = Some(Clip::from_svg(SVG).unwrap());
|
||||
s.execute("edit.paste", json!({"as":"shape"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
assert_eq!(load(&mut s, &recorded(recorded_pixels_paste(false)))["unsupported"], json!([]));
|
||||
assert_eq!(s.actions.list[0].steps, vec![("edit.paste".into(), json!({}))]);
|
||||
let layers = s.active().unwrap().doc.layers.len();
|
||||
let result = s.execute("actions.play", json!({"action":"Paste"})).unwrap();
|
||||
assert_eq!(result["ran"], 1);
|
||||
assert!(result.get("failed").is_none(), "{result}");
|
||||
assert!(s.active().unwrap().doc.layers.len() > layers);
|
||||
assert!(matches!(output(&s).content, LayerContent::Shape(_)), "a copied shape layer pastes as a shape layer");
|
||||
}
|
||||
|
||||
/// A recorded Paste in Place (`inPlace true`, `AntA Anno`, `As Pxel`) plays as Paste in Place.
|
||||
#[test]
|
||||
fn recorded_pixels_paste_in_place_maps() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(load(&mut s, &recorded(recorded_pixels_paste(true)))["unsupported"], json!([]));
|
||||
assert_eq!(s.actions.list[0].steps, vec![("edit.pasteSpecial.pasteInPlace".into(), json!({}))]);
|
||||
}
|
||||
+5
-4
@@ -145,14 +145,15 @@ the reference application.
|
||||
- Image Size preserves `scaleStyles`. Native calls default to true; an absent ATN flag means
|
||||
false. Crop and resolution-only resizing retain their existing behavior.
|
||||
- Convert to Profile preserves `Dthr`, `Fltt` and `rasterizePlaced`, defaulting to false.
|
||||
Supported RGB destinations use `sdwM: 2`; other model codes and malformed flags remain kept
|
||||
steps. Absolute intent writes `AClr`, with `AbsC` still accepted on import.
|
||||
`sdwM` is written as the reference application records it (-1); other values and malformed
|
||||
flags remain kept steps. Absolute intent writes `AClr`, with `AbsC` still accepted on import.
|
||||
- Content-Aware Fill preserves colour, quarter-turn rotation, scale and mirror adaptation.
|
||||
Existing native defaults remain unchanged.
|
||||
- Paste Layer Style preserves `allowPasteFXOnLayerSet`. False skips selected groups without
|
||||
applying their styles to children; a selection with no eligible targets fails without editing.
|
||||
- Paste preserves `As ` as pixels, path or shape using the original bounded SVG source.
|
||||
Unsupported geometry fails instead of silently rasterizing. Omission retains ordinary Paste.
|
||||
- Paste preserves `As ` path or shape using the original bounded SVG source. Unsupported
|
||||
geometry fails instead of silently rasterizing. `As Pxel` plays as an ordinary Paste (or Paste
|
||||
in Place), as does omission.
|
||||
|
||||
Generated fixtures verify these native options and their load/save/playback behavior. Matching
|
||||
Photoshop conversion-order, SVG rasterization and placement comparisons were not available.
|
||||
|
||||
Reference in new issue
Block a user