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:
committed 2026-11-02 03:18:46 +01:00
1 parent 072a87e7b0
commit 8a0f959d29
7 files changed
+210 -60

No files matched your search

+25 -10
View File
@@ -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.
+38 -5
View File
@@ -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);
}
}
+38 -2
View File
@@ -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
View File
@@ -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.