Remove 21 requested PR contributions from the 108-PR integration

Retain 87 PRs. Reconcile overlapping integration fixes and update testing scope.
Removed: #7153 #7742 #5493 #9282 #3455 #9172 #3139 #3967 #4783 #5453 #8347 #1991 #3345 #4042 #4305 #4883 #4890 #5090 #6621 #5870 #5939.

Combined CI pending for this source; historical portable artifact is not applicable.
This commit is contained in:
FredApps
2026-10-29 18:49:17 +01:00
parent 8f9f1a9481
commit 255ecc81d0
157 changed files with 1128 additions and 10500 deletions
+2 -117
View File
@@ -17,10 +17,6 @@ struct State {
files: BTreeMap<String, Arc<[u8]>>, files: BTreeMap<String, Arc<[u8]>>,
bytes: u64, bytes: u64,
dirty: BTreeSet<String>, dirty: BTreeSet<String>,
/// User edits still queued or in flight. Startup migrations are reproducible from the
/// loaded records and built-ins, so their persistence alone is not work at risk.
changed: BTreeSet<String>,
initializing: bool,
writing: bool, writing: bool,
error: Option<String>, error: Option<String>,
rejected: BTreeMap<String, String>, rejected: BTreeMap<String, String>,
@@ -36,11 +32,7 @@ pub fn session(backend: Bridge) -> (Session, Vec<String>) {
// An unreadable group may reference any tip, including one shared with a good group. // An unreadable group may reference any tip, including one shared with a good group.
// Keep all tips until the damaged library can be recovered; unrelated edits still save. // Keep all tips until the damaged library can be recovered; unrelated edits still save.
backend.state().preserve_tips = !opened.warnings.is_empty(); backend.state().preserve_tips = !opened.warnings.is_empty();
// This is a fresh session: attaching can stage generated defaults, but no user edits.
// Keep those writes queued (including the seed marker) without a loss-of-work warning.
backend.state().initializing = true;
let warnings = session.attach_preset_store(opened); let warnings = session.attach_preset_store(opened);
backend.state().initializing = false;
(session, warnings) (session, warnings)
} }
@@ -107,13 +99,6 @@ impl Bridge {
// Requeue names, never old bytes: edits/deletes made during the failed write win. // Requeue names, never old bytes: edits/deletes made during the failed write win.
s.dirty.extend(batch.into_keys()); s.dirty.extend(batch.into_keys());
s.error = Some(e); s.error = Some(e);
} else {
for name in batch.into_keys() {
// A newer edit made during the transaction still needs its own write.
if !s.dirty.contains(&name) {
s.changed.remove(&name);
}
}
} }
} }
@@ -132,7 +117,7 @@ impl Bridge {
pub fn unsaved(&self) -> bool { pub fn unsaved(&self) -> bool {
let s = self.state(); let s = self.state();
!s.changed.is_empty() || !s.rejected.is_empty() s.writing || !s.dirty.is_empty() || !s.rejected.is_empty()
} }
} }
@@ -166,10 +151,7 @@ impl PresetBackend for Bridge {
return Err(e); return Err(e);
} }
s.dirty.insert(name.to_string()); s.dirty.insert(name.to_string());
if !s.initializing { // This current replacement resolves only its own omitted edit. Pending transactions
s.changed.insert(name.to_string());
}
// This current replacement resolves only its own omitted edit. Pending user edits
// still keep unsaved() true; Retry or an older successful batch cannot clear it. // still keep unsaved() true; Retry or an older successful batch cannot clear it.
s.rejected.remove(name); s.rejected.remove(name);
Ok(()) Ok(())
@@ -190,9 +172,6 @@ impl PresetBackend for Bridge {
if let Some(bytes) = s.files.remove(name) { if let Some(bytes) = s.files.remove(name) {
s.bytes = s.bytes.saturating_sub(bytes.len() as u64); s.bytes = s.bytes.saturating_sub(bytes.len() as u64);
s.dirty.insert(name.to_string()); s.dirty.insert(name.to_string());
if !s.initializing {
s.changed.insert(name.to_string());
}
} }
s.rejected.remove(name); s.rejected.remove(name);
Ok(()) Ok(())
@@ -366,100 +345,6 @@ mod tests {
assert!(b.error().unwrap().contains("SecurityError")); assert!(b.error().unwrap().contains("SecurityError"));
assert!(b.begin().is_none()); assert!(b.begin().is_none());
} }
#[test]
fn generated_defaults_are_persisted_without_unsaved_user_work() {
let b = Bridge::default();
let (s, warnings) = session(b.clone());
assert!(warnings.is_empty());
assert_eq!(s.actions.list.len(), 5);
assert!(!b.unsaved());
let batch = b.begin().unwrap();
let actions: serde_json::Value = serde_json::from_slice(batch.get(ACTIONS_FILE).unwrap().as_deref().unwrap()).unwrap();
assert_eq!(actions["defaults_version"], 1);
assert_eq!(actions["actions"].as_array().unwrap().len(), 5);
assert!(!b.unsaved(), "generated defaults in flight can be recreated on reload");
let restored = Bridge::default();
for (name, bytes) in &batch {
if let Some(bytes) = bytes {
restored.load(name, bytes.to_vec()).unwrap();
}
}
b.finish(batch, Ok(()));
let (reloaded, warnings) = session(restored.clone());
assert!(warnings.is_empty());
assert_eq!(reloaded.actions.list, s.actions.list);
assert!(restored.begin().is_none(), "the persisted marker avoids reseeding on reload");
assert!(!b.unsaved());
}
#[test]
fn denied_storage_marks_default_edits_unsaved_and_retry_saves_them() {
for command in ["actions.delete", "actions.rename"] {
let b = Bridge::default();
b.unavailable("SecurityError".into());
let (mut s, _) = session(b.clone());
assert!(!b.unsaved());
s.execute(command, json!({"action": "Auto Tone", "name": "My Tone"})).unwrap();
assert!(b.unsaved(), "editing generated defaults is user work: {command}");
assert!(b.begin().is_none());
assert!(b.error().unwrap().contains("SecurityError"));
b.retry();
let batch = b.begin().unwrap();
assert!(b.unsaved(), "the edit remains unsaved while its transaction runs");
let restored = Bridge::default();
for (name, bytes) in &batch {
if let Some(bytes) = bytes {
restored.load(name, bytes.to_vec()).unwrap();
}
}
b.finish(batch, Ok(()));
assert!(!b.unsaved());
assert!(b.error().is_none());
let (reloaded, warnings) = session(restored);
assert!(warnings.is_empty());
assert_eq!(reloaded.actions.list, s.actions.list);
assert!(!reloaded.actions.list.iter().any(|a| a.name == "Auto Tone"));
}
}
#[test]
fn finishing_default_transaction_keeps_newer_action_edits_unsaved() {
for result in [Ok(()), Err("QuotaExceededError".into())] {
let b = Bridge::default();
let (mut s, _) = session(b.clone());
let defaults = b.begin().unwrap();
s.execute("actions.delete", json!({"action": "Auto Tone"})).unwrap();
b.finish(defaults, result);
assert!(b.unsaved(), "the bootstrap transaction cannot save a later edit");
b.retry();
let edited = b.begin().unwrap();
let actions: serde_json::Value = serde_json::from_slice(edited.get(ACTIONS_FILE).unwrap().as_deref().unwrap()).unwrap();
assert!(!actions["actions"].as_array().unwrap().iter().any(|a| a["name"] == "Auto Tone"));
b.finish(edited, Ok(()));
assert!(!b.unsaved());
}
}
#[test]
fn failed_default_transaction_does_not_warn_until_a_user_edit() {
let b = Bridge::default();
let (mut s, _) = session(b.clone());
b.finish(b.begin().unwrap(), Err("QuotaExceededError".into()));
assert!(!b.unsaved(), "the defaults are still reproducible after a failed write");
assert!(b.begin().is_none());
import(&mut s);
assert!(b.unsaved());
assert!(b.error().unwrap().contains("QuotaExceededError"));
let (reloaded, warnings) = session(b.clone());
assert!(warnings.is_empty());
assert_eq!(reloaded.tools.presets, s.tools.presets);
assert!(b.unsaved(), "reattaching must not hide already pending user edits");
b.retry();
b.finish(b.begin().unwrap(), Ok(()));
assert!(!b.unsaved());
}
#[test] #[test]
fn corrupt_group_and_shared_tip_survive_unrelated_save_delete_and_reload() { fn corrupt_group_and_shared_tip_survive_unrelated_save_delete_and_reload() {
let b = Bridge::default(); let b = Bridge::default();
+1 -2
View File
@@ -462,7 +462,6 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
| "brush.presets.importAbr" | "brush.presets.importAbr"
| "brush.presets.importImage" | "brush.presets.importImage"
| "gradient.presets.importGrd" | "gradient.presets.importGrd"
| "shape.presets.importCsh"
| "plugin.install" | "plugin.install"
| "swatches.import" | "swatches.import"
| "style.presets.importAsl" | "style.presets.importAsl"
@@ -471,7 +470,7 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
params.get("data").is_none() && params.get("path").is_some() params.get("data").is_none() && params.get("path").is_some()
} }
// With a `path` the file is written there; without one the bytes come back as `data`. // With a `path` the file is written there; without one the bytes come back as `data`.
"actions.save" | "actions.export" | "swatches.export" | "style.presets.exportAsl" | "layer.layerStyle.contours.save" => params.get("path").is_some(), "actions.save" | "swatches.export" | "style.presets.exportAsl" | "layer.layerStyle.contours.save" => params.get("path").is_some(),
// Shortcut sets: a .kys read or written by path, or a Summarize page written by path. // Shortcut sets: a .kys read or written by path, or a Summarize page written by path.
"edit.keyboardShortcuts" => { "edit.keyboardShortcuts" => {
params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some()) params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())
-21
View File
@@ -449,7 +449,6 @@ fn automation_rejects_ambient_path_commands_and_preferences() {
("swatches.export", serde_json::json!({"path": "/outside/set.ase"})), ("swatches.export", serde_json::json!({"path": "/outside/set.ase"})),
("actions.load", serde_json::json!({"path": "/outside/set.atn"})), ("actions.load", serde_json::json!({"path": "/outside/set.atn"})),
("actions.save", serde_json::json!({"path": "/outside/set.atn"})), ("actions.save", serde_json::json!({"path": "/outside/set.atn"})),
("actions.export", serde_json::json!({"path": "/outside/actions.json"})),
("style.presets.importAsl", serde_json::json!({"path": "/outside/set.asl"})), ("style.presets.importAsl", serde_json::json!({"path": "/outside/set.asl"})),
("style.presets.exportAsl", serde_json::json!({"path": "/outside/set.asl"})), ("style.presets.exportAsl", serde_json::json!({"path": "/outside/set.asl"})),
("style.presets.exportAsl", serde_json::json!({"path": 5})), ("style.presets.exportAsl", serde_json::json!({"path": 5})),
@@ -467,8 +466,6 @@ fn automation_rejects_ambient_path_commands_and_preferences() {
("swatches.export", serde_json::json!({"format": "ase"})), ("swatches.export", serde_json::json!({"format": "ase"})),
("actions.load", serde_json::json!({"data": "QUJD"})), ("actions.load", serde_json::json!({"data": "QUJD"})),
("actions.save", serde_json::json!({"set": "S"})), ("actions.save", serde_json::json!({"set": "S"})),
("actions.import", serde_json::json!({"data": "{\"version\":1,\"actions\":[]}"})),
("actions.export", serde_json::json!({})),
("style.presets.importAsl", serde_json::json!({"data": "QUJD"})), ("style.presets.importAsl", serde_json::json!({"data": "QUJD"})),
("style.presets.exportAsl", serde_json::json!({"group": "Basics"})), ("style.presets.exportAsl", serde_json::json!({"group": "Basics"})),
("layer.layerStyle.contours.load", serde_json::json!({"data": "QUJD"})), ("layer.layerStyle.contours.load", serde_json::json!({"data": "QUJD"})),
@@ -881,21 +878,3 @@ fn absolute_paths_are_checked_against_the_authority_s_own_root() {
let none = AuthorizedWorkspace::default(); let none = AuthorizedWorkspace::default();
assert!(none.read(&target.to_string_lossy()).is_err()); assert!(none.read(&target.to_string_lossy()).is_err());
} }
#[test]
fn csh_import_requires_inline_data_in_automation() {
let id = "shape.presets.importCsh";
for path in ["/outside/set.csh", "set.csh", "../set.csh", ""] {
let params = serde_json::json!({"path": path});
assert!(authorize_engine_command(id, &params).is_err(), "{path}");
assert!(authorize_desktop_engine_command(id, &params).is_err(), "{path}");
}
// The importer gives inline data precedence, even when a path is also supplied.
for params in [serde_json::json!({"data": "QUJD"}), serde_json::json!({"data": "QUJD", "path": "/outside/set.csh"})] {
assert!(authorize_engine_command(id, &params).is_ok());
assert!(authorize_desktop_engine_command(id, &params).is_ok());
}
let mut headless = crate::headless::Headless::new();
let error = headless.command_run(id, serde_json::json!({"path": "/outside/set.csh"})).unwrap_err();
assert!(error.to_string().contains("uses ambient filesystem paths and is disabled"), "{error}");
}
+1 -1
View File
@@ -557,7 +557,7 @@ fn shape_parts(layer: &Layer, clipped: &[Layer], rect: Rect, cx: &Ctx) -> Option
fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, Option<Vec<f32>>)> { fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, Option<Vec<f32>>)> {
let LayerContent::Shape(sh) = &layer.content else { return None }; let LayerContent::Shape(sh) = &layer.content else { return None };
sh.stroke.as_ref()?; sh.stroke.as_ref()?;
let (fs, ss) = shape_split::split_with_patterns(sh, cx.canvas, cx.depth, cx.patterns.source(), cx.mode)?; let (fs, ss) = shape_split::split(sh, cx.canvas, cx.depth)?;
Some((surface_to_buffer(&fs, rect), surface_to_buffer(&ss, rect), mask_vals(layer, rect, cx))) Some((surface_to_buffer(&fs, rect), surface_to_buffer(&ss, rect), mask_vals(layer, rect, cx)))
} }
+1 -1
View File
@@ -41,9 +41,9 @@ mod paint;
pub use bevel::{BEVEL_H_EPS, BevelGeom, BevelPaint, TextureCtx, bevel_chisel_h, bevel_geom, satin_distance}; pub use bevel::{BEVEL_H_EPS, BevelGeom, BevelPaint, TextureCtx, bevel_chisel_h, bevel_geom, satin_distance};
#[cfg(test)] #[cfg(test)]
use bevel::{bevel_maps, satin_map}; use bevel::{bevel_maps, satin_map};
pub use blur::{tent_kernel, softer_glow_tail_width, softer_glow_tail_amplitude};
#[cfg(test)] #[cfg(test)]
use blur::{blur, box_line, dilate, tent, tent_direct}; use blur::{blur, box_line, dilate, tent, tent_direct};
pub use blur::{softer_glow_tail_amplitude, softer_glow_tail_width, tent_kernel};
pub(crate) use composite::{VectorStroke, build_maps_prepared, composite_with_effects_prepared, map_count}; pub(crate) use composite::{VectorStroke, build_maps_prepared, composite_with_effects_prepared, map_count};
pub use composite::{build_maps, composite_with_effects, outline_share, premerges_layer, shadow_see_through, stroke_widths}; pub use composite::{build_maps, composite_with_effects, outline_share, premerges_layer, shadow_see_through, stroke_widths};
pub use distance::{FieldKind, STROKE_CHAMFER_MAX, bevel_distance_fields, distance_field, stroke_outside_exact}; pub use distance::{FieldKind, STROKE_CHAMFER_MAX, bevel_distance_fields, distance_field, stroke_outside_exact};
+1 -1
View File
@@ -1,4 +1,4 @@
use super::blur::{blur, dilate, softer_glow_tail_amplitude, softer_glow_tail_width}; use super::blur::{blur, dilate, softer_glow_tail_width, softer_glow_tail_amplitude};
use super::distance::{dist_inside, dist_outside}; use super::distance::{dist_inside, dist_outside};
use super::{ use super::{
BlendMode, Buffer, Contour, Effect, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Map, Placement, PreparedPatterns, Rect, BlendMode, Buffer, Contour, Effect, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Map, Placement, PreparedPatterns, Rect,
+182 -3
View File
@@ -4,13 +4,13 @@
//! origin, plus the phase), scaled and rotated about that origin. Samples are bilinear with //! origin, plus the phase), scaled and rotated about that origin. Samples are bilinear with
//! wrap-around on premultiplied colour, so integer placements at 100 % reproduce the tile //! wrap-around on premultiplied colour, so integer placements at 100 % reproduce the tile
//! exactly and scaled ones stay seamless. Below 100 % each document pixel averages an //! exactly and scaled ones stay seamless. Below 100 % each document pixel averages an
//! `n × n` grid of bilinear taps over its footprint in the tile (see [`Tile::sample_area`]), so a //! `n × n` grid of bilinear taps over its footprint in the tile (see [`pixel_taps`]), so a
//! minified tile is box-filtered instead of aliased; at 100 % and above it is a single tap. //! minified tile is box-filtered instead of aliased; at 100 % and above it is a single tap.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use photocraft_doc::{ColorMode, Pattern, Rect}; use photocraft_doc::{ColorMode, Pattern, PixelFormat, Rect, SampleType};
pub(crate) const PREPARED_PATTERN_BYTES: usize = 64 << 20; pub(crate) const PREPARED_PATTERN_BYTES: usize = 64 << 20;
const MAX_PREPARED_PATTERNS: usize = 64; const MAX_PREPARED_PATTERNS: usize = 64;
@@ -96,7 +96,186 @@ impl<'a> PreparedPatterns<'a> {
} }
} }
pub use photocraft_vector::pattern::{Placement, Tile, pattern_rgba}; /// A pattern's pixels (straight-alpha RGBA, row-major) as they look painted into a document of
/// colour mode `mode`. A pattern keeps its own colours (an RGB pattern stays RGB, in the
/// library and in the document), but in a Grayscale or CMYK document it paints that mode's
/// colours: what Edit › Fill writes into the document's pixels, and what Photoshop shows (the
/// pattern in colour in its picker, filled in grey). Empty for a pattern that isn't
/// [paintable](Pattern::is_paintable).
pub fn pattern_rgba(p: &Pattern, mode: ColorMode) -> Vec<[f32; 4]> {
let Some(n) = p.is_paintable().then(|| usize::try_from(p.pixel_count()).ok()).flatten() else {
return Vec::new();
};
let mut px = vec![[0.0f32; 4]; n];
p.surface.read_rgba_into(p.rect(), &mut px);
let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk);
if paints_in && p.surface.format().mode != mode {
let fmt = PixelFormat::new(mode, SampleType::F32, true);
let mut v = [0.0f32; 5];
for q in &mut px {
let n = photocraft_raster::from_rgba_into(&fmt, *q, &mut v);
*q = photocraft_raster::to_rgba(&fmt, v.get(..n).unwrap_or_default());
}
}
px
}
/// A pattern converted once to straight-alpha RGBA for sampling.
pub struct Tile {
w: usize,
h: usize,
/// Premultiplied RGBA.
px: Vec<[f32; 4]>,
}
impl Tile {
pub fn new(p: &Pattern) -> Option<Tile> {
Tile::new_in(p, ColorMode::Rgb)
}
/// The tile of `p` painted into a document of colour mode `mode` (see [`pattern_rgba`]).
/// `None` when the pattern isn't [paintable](Pattern::is_paintable).
pub fn new_in(p: &Pattern, mode: ColorMode) -> Option<Tile> {
if !p.is_paintable() {
return None;
}
let (w, h) = (p.width as usize, p.height as usize);
let mut px = pattern_rgba(p, mode);
for q in &mut px {
for c in 0..3 {
q[c] *= q[3];
}
}
Some(Tile { w, h, px })
}
#[inline]
fn at(&self, x: i64, y: i64) -> [f32; 4] {
let xi = x.rem_euclid(self.w as i64) as usize;
let yi = y.rem_euclid(self.h as i64) as usize;
self.px[yi * self.w + xi]
}
/// Bilinear, wrapping sample at continuous tile coordinates (pixel centres at `i + 0.5`).
/// Returns straight alpha.
pub fn sample(&self, u: f64, v: f64) -> [f32; 4] {
unpremultiply(self.sample_premul(u, v))
}
/// The pattern at the centre `(x, y)` of a document pixel, filtered over the pixel's
/// footprint in the tile when the placement shrinks it. Returns straight alpha.
pub fn sample_area(&self, place: &Placement, x: f64, y: f64) -> [f32; 4] {
let n = pixel_taps(place.inv_scale);
if n == 1 {
let (u, v) = place.map(x, y);
return self.sample(u, v);
}
let mut acc = [0.0f32; 4];
for j in 0..n {
for i in 0..n {
let (u, v) = place.map(x + tap_offset(i, n), y + tap_offset(j, n));
let q = self.sample_premul(u, v);
for c in 0..4 {
acc[c] += q[c];
}
}
}
let k = 1.0 / (n * n) as f32;
unpremultiply([acc[0] * k, acc[1] * k, acc[2] * k, acc[3] * k])
}
/// Bilinear, wrapping sample, premultiplied.
fn sample_premul(&self, u: f64, v: f64) -> [f32; 4] {
let (u, v) = (u - 0.5, v - 0.5);
let (x0, y0) = (u.floor(), v.floor());
let (fx, fy) = ((u - x0) as f32, (v - y0) as f32);
let (x0, y0) = (x0 as i64, y0 as i64);
let mut acc = [0.0f32; 4];
for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
if wy == 0.0 {
continue;
}
for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
if wx == 0.0 {
continue;
}
let p = self.at(x0.saturating_add(dx), y0.saturating_add(dy));
let k = wx * wy;
for c in 0..4 {
acc[c] += p[c] * k;
}
}
}
acc
}
}
/// Most taps per axis of the minification filter (the WGSL mirror uses the same cap).
const MAX_TAPS: usize = 8;
/// Taps per axis for a placement whose inverse scale is `inv_scale`: one pixel covers
/// `inv_scale` tile pixels, so that many taps (capped) box-filter it. 1 at 100 % and above.
fn pixel_taps(inv_scale: f64) -> usize {
let s = inv_scale as f32;
if s.is_finite() && s > 1.0 { (s.ceil() as usize).min(MAX_TAPS) } else { 1 }
}
/// Offset in document pixels of tap `i` of `n`, centred on the pixel.
fn tap_offset(i: usize, n: usize) -> f64 {
(i as f64 + 0.5) / n as f64 - 0.5
}
fn unpremultiply(acc: [f32; 4]) -> [f32; 4] {
if acc[3] <= 0.0 {
return [0.0; 4];
}
[acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)]
}
/// Where a pattern sits: document point → tile coordinates.
#[derive(Clone, Copy, Debug)]
pub struct Placement {
origin: (f64, f64),
/// Inverse rotation (cos, sin) and inverse scale.
cs: (f64, f64),
inv_scale: f64,
}
impl Placement {
/// `frame`: the layer's frame (used when `link`); `phase` in pixels; `scale` as a fraction;
/// `angle` in degrees counter-clockwise.
pub fn new(frame: Rect, link: bool, phase: (f32, f32), scale: f32, angle: f32) -> Placement {
let anchor = if frame.is_empty() { (0.0, 0.0) } else { (f64::from(frame.x0), f64::from(frame.y0)) };
Self::anchored(anchor, link, phase, scale, angle)
}
/// Like [`Placement::new`], with the linked origin given as a point. Layer effects anchor
/// linked patterns at the layer's effects reference point (PSD `fxrp`): Photoshop's
/// pattern overlays and pattern strokes match pixel-for-pixel tiled from there.
pub fn anchored(anchor: (f64, f64), link: bool, phase: (f32, f32), scale: f32, angle: f32) -> Placement {
let base = if link { anchor } else { (0.0, 0.0) };
let origin = (base.0 + f64::from(phase.0), base.1 + f64::from(phase.1));
let s = f64::from(scale);
let inv_scale = if s.is_finite() && s > 1e-3 { 1.0 / s } else { 1.0 };
let a = f64::from(angle).to_radians();
Placement { origin, cs: (a.cos(), a.sin()), inv_scale }
}
/// (origin, inverse rotation (cos, sin), inverse scale), for GPU shaders.
pub fn parts(&self) -> ((f64, f64), (f64, f64), f64) {
(self.origin, self.cs, self.inv_scale)
}
/// Tile coordinates of the document point `(x, y)`.
#[inline]
pub fn map(&self, x: f64, y: f64) -> (f64, f64) {
let (dx, dy) = (x - self.origin.0, y - self.origin.1);
// Undo a counter-clockwise rotation (y points down on screen).
let (c, s) = self.cs;
let (rx, ry) = (dx * c - dy * s, dx * s + dy * c);
(rx * self.inv_scale, ry * self.inv_scale)
}
}
/// Renders `tile` over `rect` (row-major straight RGBA). /// Renders `tile` over `rect` (row-major straight RGBA).
pub fn render(tile: &Tile, place: &Placement, rect: Rect) -> Vec<[f32; 4]> { pub fn render(tile: &Tile, place: &Placement, rect: Rect) -> Vec<[f32; 4]> {
+21 -64
View File
@@ -6,15 +6,15 @@ use std::collections::HashMap;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_color::{PixelFormat, SampleType};
use photocraft_doc::vector::ShapeLayer; use photocraft_doc::vector::ShapeLayer;
use photocraft_doc::{Fill, Layer, LayerContent, Pattern}; use photocraft_doc::{Layer, LayerContent};
use photocraft_geom::{Rect, TileCoord}; use photocraft_geom::{Rect, TileCoord};
use photocraft_raster::Surface; use photocraft_raster::Surface;
/// (fill, stroke), last use, and the shape's pixels the stroke was fitted to (kept alive so /// (fill, stroke), last use, and the shape's pixels the stroke was fitted to (kept alive so
/// their tile addresses, part of the key, can't be reused). /// their tile addresses, part of the key, can't be reused).
type Entry = ((Surface, Surface), u64, Option<Surface>, Vec<Pattern>); type Entry = ((Surface, Surface), u64, Option<Surface>);
fn cache() -> &'static Mutex<(HashMap<u64, Entry>, u64)> { fn cache() -> &'static Mutex<(HashMap<u64, Entry>, u64)> {
static C: OnceLock<Mutex<(HashMap<u64, Entry>, u64)>> = OnceLock::new(); static C: OnceLock<Mutex<(HashMap<u64, Entry>, u64)>> = OnceLock::new();
@@ -22,26 +22,24 @@ fn cache() -> &'static Mutex<(HashMap<u64, Entry>, u64)> {
} }
const CAPACITY: usize = 32; const CAPACITY: usize = 32;
/// Most pixel bytes held by split parts and their pinned patterns (#4530). /// Most pixel bytes the split parts may hold (#4530): each entry is two canvas-sized surfaces,
/// so 32 of them at 24 MP could hold several GB outside every memory budget.
#[cfg(not(test))] #[cfg(not(test))]
const BUDGET: usize = 256 << 20; const BUDGET: usize = 256 << 20;
/// Small under test, so a few 512² shapes exercise the eviction. /// Small under test, so a few 512² shapes exercise the eviction.
#[cfg(test)] #[cfg(test)]
const BUDGET: usize = 8 << 20; const BUDGET: usize = 8 << 20;
/// Pixel bytes held by one entry, including pinned source tiles. Shared pattern tiles count once /// Pixel bytes of one entry's (fill, stroke) parts (the pinned shape pixels are the document's).
/// per entry, conservatively matching their retained lifetime without cross-entry bookkeeping. fn entry_bytes(parts: &(Surface, Surface)) -> usize {
fn entry_bytes(parts: &(Surface, Surface), patterns: &[Pattern]) -> usize { [&parts.0, &parts.1].iter().map(|s| s.tiles().map(|(_, t)| t.bytes().len()).sum::<usize>()).sum()
let part_bytes = [&parts.0, &parts.1].iter().flat_map(|s| s.tiles().map(|(_, t)| t.bytes().len())).fold(0usize, usize::saturating_add);
let pattern_bytes = patterns.iter().flat_map(|p| p.surface.tiles().map(|(_, t)| t.bytes().len())).fold(0usize, usize::saturating_add);
part_bytes.saturating_add(pattern_bytes)
} }
/// Drop every cached split (Edit › Purge › All), returning accounted pixel bytes. Callers may /// Drop every cached split (Edit › Purge › All), returning the parts' pixel bytes. Callers may
/// still hold returned surfaces and patterns, so this is not a measure of freed process memory. /// still hold returned surfaces, so this is not a measure of freed process memory.
pub fn purge_cache() -> usize { pub fn purge_cache() -> usize {
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
let bytes = c.0.values().map(|e| entry_bytes(&e.0, &e.3)).sum(); let bytes = c.0.values().map(|e| entry_bytes(&e.0)).sum();
c.0.clear(); c.0.clear();
bytes bytes
} }
@@ -51,29 +49,10 @@ pub fn purge_cache() -> usize {
/// (Photoshop's rendering, `sh.cache`) reach past our fill, the part the fill doesn't explain is /// (Photoshop's rendering, `sh.cache`) reach past our fill, the part the fill doesn't explain is
/// stroke: the stroke follows Photoshop's coverage (psd-tools double-stroke-effects). /// stroke: the stroke follows Photoshop's coverage (psd-tools double-stroke-effects).
pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surface, Surface)> { pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surface, Surface)> {
split_with_patterns(sh, canvas, depth, &[], ColorMode::Rgb)
}
/// Splits patterned paints with the same embedded tiles and colour mode as the shape cache.
pub fn split_with_patterns(sh: &ShapeLayer, canvas: Rect, depth: SampleType, patterns: &[Pattern], mode: ColorMode) -> Option<(Surface, Surface)> {
sh.stroke.as_ref()?; sh.stroke.as_ref()?;
let mut h = std::collections::hash_map::DefaultHasher::new(); let mut h = std::collections::hash_map::DefaultHasher::new();
(canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h); (canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h);
depth.hash(&mut h); depth.hash(&mut h);
mode.hash(&mut h);
let mut used_patterns = Vec::new();
for fill in sh.fill.iter().chain(sh.stroke.iter().map(|s| &s.paint)) {
if let Fill::Pattern { id, name, .. } = fill
&& let Some(p) = photocraft_doc::pattern::find(patterns, id, name)
{
(&p.id, &p.name, p.width, p.height, p.surface.format()).hash(&mut h);
format!("{:?}", p.surface.default_pixel()).hash(&mut h);
for (k, tile) in p.surface.tiles() {
(k.tx, k.ty, std::sync::Arc::as_ptr(tile) as usize).hash(&mut h);
}
used_patterns.push(p.clone());
}
}
format!("{:?}{:?}{:?}{:?}", sh.path, sh.fill, sh.stroke, sh.live).hash(&mut h); format!("{:?}{:?}{:?}{:?}", sh.path, sh.fill, sh.stroke, sh.live).hash(&mut h);
(sh.density.to_bits(), sh.feather.to_bits()).hash(&mut h); (sh.density.to_bits(), sh.feather.to_bits()).hash(&mut h);
if let Some(c) = &sh.cache { if let Some(c) = &sh.cache {
@@ -95,29 +74,27 @@ pub fn split_with_patterns(sh: &ShapeLayer, canvas: Rect, depth: SampleType, pat
let fmt = PixelFormat::RGBA8.with_sample(depth); let fmt = PixelFormat::RGBA8.with_sample(depth);
let fill_only = ShapeLayer { stroke: None, cache: None, ..sh.clone() }; let fill_only = ShapeLayer { stroke: None, cache: None, ..sh.clone() };
let stroke_only = ShapeLayer { fill: None, cache: None, ..sh.clone() }; let stroke_only = ShapeLayer { fill: None, cache: None, ..sh.clone() };
let render = let mut parts = (photocraft_vector::render_shape(&fill_only, fmt, canvas), photocraft_vector::render_shape(&stroke_only, fmt, canvas));
|shape| photocraft_vector::CompiledShape::with_patterns(shape, photocraft_vector::DEFAULT_TOLERANCE, canvas, patterns, mode).render(fmt, canvas);
let mut parts = (render(&fill_only), render(&stroke_only));
if let Some(cache) = &sh.cache { if let Some(cache) = &sh.cache {
fit_stroke(&parts.0, &mut parts.1, cache, canvas); fit_stroke(&parts.0, &mut parts.1, cache, canvas);
} }
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
// Least recently used first, until the new entry fits both the count and the byte budget. // Least recently used first, until the new entry fits both the count and the byte budget.
let new_bytes = entry_bytes(&parts, &used_patterns); let new_bytes = entry_bytes(&parts);
if new_bytes > BUDGET {
// Larger than the whole budget on its own: use it, don't keep it.
return Some(parts);
}
loop { loop {
let held: usize = c.0.values().map(|e| entry_bytes(&e.0, &e.3)).sum(); let held: usize = c.0.values().map(|e| entry_bytes(&e.0)).sum();
if c.0.len() < CAPACITY && held.saturating_add(new_bytes) <= BUDGET { if c.0.len() < CAPACITY && held.saturating_add(new_bytes) <= BUDGET {
break; break;
} }
let Some(old) = c.0.iter().min_by_key(|e| (e.1).1).map(|e| *e.0) else { break }; let Some(old) = c.0.iter().min_by_key(|e| (e.1).1).map(|e| *e.0) else { break };
c.0.remove(&old); c.0.remove(&old);
} }
if new_bytes > BUDGET {
// Larger than the whole budget on its own: use it, don't keep it.
return Some(parts);
}
let tick = c.1; let tick = c.1;
c.0.insert(key, (parts.clone(), tick, sh.cache.clone(), used_patterns)); c.0.insert(key, (parts.clone(), tick, sh.cache.clone()));
Some(parts) Some(parts)
} }
@@ -238,29 +215,9 @@ mod tests {
} }
fn held() -> usize { fn held() -> usize {
cache().lock().unwrap_or_else(|e| e.into_inner()).0.values().map(|e| entry_bytes(&e.0, &e.3)).sum() cache().lock().unwrap_or_else(|e| e.into_inner()).0.values().map(|e| entry_bytes(&e.0)).sum()
} }
#[test]
fn split_cache_does_not_pin_pattern_tiles_over_its_budget() {
let fmt = PixelFormat::RGBA32F;
let mut pixels = Surface::new(fmt);
pixels.fill_rect(Rect::new(0, 0, 1024, 1024), &[0.2, 0.4, 0.6, 1.0]);
let pattern = Pattern::new("dense", pixels, 1024, 1024);
assert!(pattern.surface.tiles().map(|(_, tile)| tile.bytes().len()).sum::<usize>() > BUDGET);
let mut sh = shape(0.0);
sh.fill = Some(Fill::Pattern { id: pattern.id.clone(), name: pattern.name.clone(), scale: 1.0, angle: 0.0, link: false, phase: (0.0, 0.0) });
split_with_patterns(&sh, Rect::new(0, 0, 32, 32), SampleType::U8, std::slice::from_ref(&pattern), ColorMode::Rgb).expect("stroked");
let pattern_tiles: Vec<_> = pattern.surface.tiles().map(|(_, tile)| std::sync::Arc::as_ptr(tile) as usize).collect();
let c = cache().lock().unwrap_or_else(|e| e.into_inner());
assert!(
c.0.values().all(|entry| entry
.3
.iter()
.all(|held| held.surface.tiles().all(|(_, tile)| !pattern_tiles.contains(&(std::sync::Arc::as_ptr(tile) as usize))))),
"oversized pattern was pinned by split cache"
);
}
/// The split cache stays within its byte budget, evicting the least recently used, and /// The split cache stays within its byte budget, evicting the least recently used, and
/// Edit › Purge › All empties it (#4530). /// Edit › Purge › All empties it (#4530).
#[test] #[test]
@@ -268,7 +225,7 @@ mod tests {
let canvas = Rect::new(0, 0, 1024, 512); let canvas = Rect::new(0, 0, 1024, 512);
for i in 0..6 { for i in 0..6 {
let parts = split(&shape(f64::from(i) * 10.0), canvas, SampleType::U8).expect("stroked"); let parts = split(&shape(f64::from(i) * 10.0), canvas, SampleType::U8).expect("stroked");
assert!(entry_bytes(&parts, &[]) > 0); assert!(entry_bytes(&parts) > 0);
assert!(held() <= BUDGET, "shape {i}: {} bytes held", held()); assert!(held() <= BUDGET, "shape {i}: {} bytes held", held());
} }
// Other tests share the cache, so only what this test can rely on: a purge frees bytes. // Other tests share the cache, so only what this test can rely on: a purge frees bytes.
-6
View File
@@ -198,11 +198,6 @@ pub struct ParagraphStyle {
pub auto_leading: f32, pub auto_leading: f32,
pub direction: TextDirection, pub direction: TextDirection,
pub hyphenate: bool, pub hyphenate: bool,
/// Roman Hanging Punctuation (Photoshop's Paragraph panel): trailing punctuation
/// (`, . : ; ! ? …` and their fullwidth twins) may step past the box's right edge without
/// counting toward the line's width, so a centered line ending in an ellipsis stays
/// centered and a period the box can't hold hangs instead of wrapping.
pub hanging: bool,
/// Applied paragraph style (id in [`crate::TextStyles`]); `None` = Basic Paragraph. /// Applied paragraph style (id in [`crate::TextStyles`]); `None` = Basic Paragraph.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub style_sheet: Option<u32>, pub style_sheet: Option<u32>,
@@ -220,7 +215,6 @@ impl Default for ParagraphStyle {
auto_leading: 1.2, auto_leading: 1.2,
direction: TextDirection::Auto, direction: TextDirection::Auto,
hyphenate: false, hyphenate: false,
hanging: false,
style_sheet: None, style_sheet: None,
} }
} }
-226
View File
@@ -1,226 +0,0 @@
//! Independently authored starter actions, composed from PhotoCraft commands. The preset
//! store seeds these once; an explicit command restores missing names without replacing edits.
use serde_json::{Value, json};
use crate::actions_cmds::{Action, ActionState};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
pub(crate) const VERSION: u32 = 1;
const SET: &str = "PhotoCraft Essentials";
fn defaults() -> Vec<Action> {
[
("Auto Tone", "image.autoTone", json!({})),
("Auto Contrast", "image.autoContrast", json!({})),
("Auto Color", "image.autoColor", json!({})),
("Select Subject", "select.subject", json!({"sampleAllLayers": true, "mode": "replace"})),
("Blur Background", "layer.blurBackground", json!({"radius": 8.0})),
]
.into_iter()
.map(|(name, id, params)| Action { set: Some(SET.into()), ..Action::new(name, vec![(id.into(), params)]) })
.collect()
}
/// Preserve custom actions, including a user's edited default with the same name.
pub(crate) fn append_missing(state: &mut ActionState) -> Vec<String> {
let mut added = Vec::new();
for action in defaults() {
if !state.list.iter().any(|have| have.name == action.name) {
added.push(action.name.clone());
state.list.push(action);
}
}
if !added.is_empty() {
state.touch();
}
added
}
fn enabled(s: &Session) -> std::result::Result<(), String> {
if s.actions.recording.is_some() || s.actions.playing > 0 {
return Err("Stop recording or playing an action before loading defaults".into());
}
Ok(())
}
fn load(s: &mut Session, p: &Value) -> Result<Value> {
let bad = |msg: String| EngineError::BadParams { cmd: "actions.loadDefaults".into(), msg };
enabled(s).map_err(bad)?;
if !p.is_object() {
return Err(bad("parameters must be an object".into()));
}
Ok(json!({"added": append_missing(&mut s.actions), "set": SET}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: "actions.loadDefaults",
label: "Load Default Actions",
menu: &[],
shortcut: None,
params: "{} → {added:[name…], set}. Append missing PhotoCraft Essentials actions; existing names and edits are preserved. No document needed. Unavailable during recording or playback.",
enabled,
run: load,
journal: false,
}]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actions_cmds::ActionsFile;
use crate::preset_store::{ACTIONS_FILE, MemBackend, PresetBackend, open};
use photocraft_geom::Rect;
fn attached(mem: &MemBackend) -> Session {
let mut s = Session::new();
assert!(s.attach_preset_store(open(Box::new(mem.clone()))).is_empty());
s
}
fn saved(mem: &MemBackend) -> ActionsFile {
serde_json::from_slice(&mem.read(ACTIONS_FILE, 1 << 20).unwrap()).unwrap()
}
#[test]
fn load_defaults_preserves_names_and_edits_and_is_idempotent() {
let mut s = Session::new();
assert!(s.actions.list.is_empty(), "headless sessions remain hermetic");
let custom = Action::new("Auto Tone", vec![("image.flipCanvasHorizontal".into(), json!({}))]);
s.actions.list.push(custom.clone());
let loaded = s.execute("actions.loadDefaults", json!({})).unwrap();
assert_eq!(loaded["added"], json!(["Auto Contrast", "Auto Color", "Select Subject", "Blur Background"]));
assert_eq!(s.actions.list[0], custom);
assert!(s.actions.list.iter().skip(1).all(|a| a.set.as_deref() == Some(SET)));
let rev = s.actions.rev;
assert_eq!(s.execute("actions.loadDefaults", json!({})).unwrap()["added"], json!([]));
assert_eq!(s.actions.rev, rev, "unchanged defaults do not cause a store write");
assert!(s.journal.is_empty());
assert!(s.active().is_none());
for bad in [Value::Null, json!([]), json!("defaults"), json!(true), json!(1)] {
assert!(s.execute("actions.loadDefaults", bad).is_err());
}
s.execute("actions.record", json!({"name": "Recording"})).unwrap();
assert!(!s.is_enabled("actions.loadDefaults"));
assert!(s.execute("actions.loadDefaults", json!({})).is_err());
s.execute("actions.stop", json!({})).unwrap();
s.actions.playing = 1;
assert!(load(&mut s, &json!({})).is_err());
}
#[test]
fn defaults_seed_once_and_restore_only_when_requested() {
let mem = MemBackend::default();
let mut s = attached(&mem);
assert_eq!(s.actions.list, defaults());
assert_eq!(saved(&mem).defaults_version, VERSION);
s.execute("actions.delete", json!({"action": "Auto Tone"})).unwrap();
s.execute("actions.rename", json!({"action": "Auto Color", "name": "My Color"})).unwrap();
let customized = s.actions.list.clone();
let mut restarted = attached(&mem);
assert_eq!(restarted.actions.list, customized, "deleted and renamed defaults stay customized on restart");
assert_eq!(restarted.execute("actions.loadDefaults", json!({})).unwrap()["added"], json!(["Auto Tone", "Auto Color"]));
assert_eq!(attached(&mem).actions.list, restarted.actions.list);
}
#[test]
fn old_store_and_early_actions_win_over_defaults() {
let mem = MemBackend::default();
let custom = Action::new("Auto Tone", vec![("select.all".into(), json!({}))]);
let disk = Action::new("My Workflow", vec![]);
// Old files have no defaults_version field.
mem.write(ACTIONS_FILE, &serde_json::to_vec(&json!({"version": 1, "actions": [custom, disk]})).unwrap()).unwrap();
let mut s = Session::new();
let early = Action::new("Auto Color", vec![("select.deselect".into(), json!({}))]);
s.actions.list.push(early.clone());
s.attach_preset_store(open(Box::new(mem.clone())));
assert_eq!(&s.actions.list[..3], &[early, custom, disk]);
assert_eq!(
s.actions.list.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
["Auto Color", "Auto Tone", "My Workflow", "Auto Contrast", "Select Subject", "Blur Background"]
);
assert_eq!(saved(&mem).defaults_version, VERSION);
assert_eq!(attached(&mem).actions.list, s.actions.list);
}
#[test]
fn unreadable_actions_are_not_seeded_or_overwritten() {
for bytes in [b"{".as_slice(), br#"{"version":999,"actions":[]}"#.as_slice()] {
let mem = MemBackend::default();
mem.write(ACTIONS_FILE, bytes).unwrap();
let mut s = Session::new();
assert!(!s.attach_preset_store(open(Box::new(mem.clone()))).is_empty());
assert!(s.actions.list.is_empty());
s.execute("actions.loadDefaults", json!({})).unwrap();
assert_eq!(s.actions.list, defaults(), "explicit recovery is available in memory");
assert_eq!(mem.read(ACTIONS_FILE, 1 << 20).unwrap(), bytes, "the unreadable original remains recoverable");
}
}
#[test]
fn auto_defaults_play_on_pixels_and_undo_at_every_depth() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("actions.loadDefaults", json!({})).unwrap();
s.execute("file.new", json!({"width": 64, "height": 64, "depth": depth})).unwrap();
s.edit("ramp", |doc, _| {
let surf = doc.layers[0].surface_mut().unwrap();
for x in 0..64 {
let v = 0.3 + 0.4 * x as f32 / 63.0;
surf.fill_rect(Rect::new(x, 0, x + 1, 64), &[v, v * 0.9, v * 0.8, 1.0]);
}
Ok(())
})
.unwrap();
let pixel = |s: &Session, x| s.active().unwrap().doc.layers[0].surface().unwrap().pixel(x, 0);
let before = (pixel(&s, 0), pixel(&s, 63));
let history = s.active().unwrap().history.past_len();
for name in ["Auto Tone", "Auto Contrast", "Auto Color"] {
let played = s.execute("actions.play", json!({"action": name})).unwrap();
assert_eq!(played["ran"], 1, "{name} at {depth}: {played}");
assert!(played.get("failed").is_none());
let (a, b) = (pixel(&s, 0), pixel(&s, 63));
let (lo, hi) = if name == "Auto Contrast" { (a[2], b[0]) } else { (a[0], b[0]) };
assert!(lo < 0.05 && hi > 0.95, "{name} at {depth}: {a:?} {b:?}");
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
assert!(s.undo());
assert_eq!((pixel(&s, 0), pixel(&s, 63)), before);
}
}
}
#[test]
fn subject_default_samples_visible_layers_and_undo_restores_selection() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("actions.loadDefaults", json!({})).unwrap();
s.execute("file.new", json!({"width": 200, "height": 160, "depth": depth})).unwrap();
s.edit("disc", |doc, _| {
let surf = doc.layers[0].surface_mut().unwrap();
surf.fill_rect(Rect::new(0, 0, 200, 160), &[0.25, 0.45, 0.7, 1.0]);
for y in 0..160 {
for x in 0..200 {
if (x - 100) * (x - 100) + (y - 80) * (y - 80) <= 45 * 45 {
surf.fill_rect(Rect::new(x, y, x + 1, y + 1), &[0.9, 0.3, 0.15, 1.0]);
}
}
}
Ok(())
})
.unwrap();
s.execute("layer.new.layer", json!({"name": "Empty active layer"})).unwrap();
s.execute("select.all", json!({})).unwrap();
let played = s.execute("actions.play", json!({"action": "Select Subject"})).unwrap();
assert_eq!(played["ran"], 1, "{depth}: {played}");
assert!(played.get("failed").is_none());
let coverage = |s: &Session, x, y| s.active().unwrap().doc.selection.as_ref().unwrap().sample_channel(x, y, 0);
assert!(coverage(&s, 100, 80) > 0.9, "the subject on the underlying layer is selected");
assert!(coverage(&s, 0, 0) < 0.1, "replace mode removes the old background selection");
assert!(s.undo());
assert!(coverage(&s, 0, 0) > 0.9);
}
}
}
+3 -14
View File
@@ -58,7 +58,6 @@ pub mod analysis_cmds;
pub mod artboard_cmds; pub mod artboard_cmds;
pub mod atn_cmds; pub mod atn_cmds;
pub mod automate_cmds; pub mod automate_cmds;
pub mod background_blur_cmds;
pub mod background_cmds; pub mod background_cmds;
pub mod brush_cmds; pub mod brush_cmds;
pub mod brush_key_cmds; pub mod brush_key_cmds;
@@ -85,7 +84,6 @@ pub mod fill_key_cmds;
pub mod float_cmds; pub mod float_cmds;
pub mod font_library_cmds; pub mod font_library_cmds;
pub(super) mod frame_cmds; pub(super) mod frame_cmds;
pub mod frequency_separation_cmds;
pub mod fx_view_cmds; pub mod fx_view_cmds;
pub mod fx_visibility_cmds; pub mod fx_visibility_cmds;
pub mod gallery_cmds; pub mod gallery_cmds;
@@ -801,20 +799,17 @@ fn build() -> Vec<CommandSpec> {
"Gradient…", "Gradient…",
["Layer", "New Fill Layer"], ["Layer", "New Fill Layer"],
None, None,
r##"{"from":"#rrggbb"=foreground,"to":"#rrggbb"? (none: Foreground to Transparent),"angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}"##, r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}"##,
has_doc, has_doc,
|s, p| { |s, p| {
let a = color_param("layer.newFillLayer.gradient", p, "from", s.tools.foreground)?; let a = color_param("layer.newFillLayer.gradient", p, "from", s.tools.foreground)?;
// Without a `to`, Photoshop's default Foreground to Transparent (#8708): the let b = color_param("layer.newFillLayer.gradient", p, "to", s.tools.background)?;
// `from` colour at both ends, fading out through the transparency stops.
let to_transparent = p.get("to").is_none_or(Value::is_null);
let b = if to_transparent { a } else { color_param("layer.newFillLayer.gradient", p, "to", s.tools.background)? };
let angle = crate::gradient_fill_cmds::angle_param("layer.newFillLayer.gradient", p, 90.0)?; let angle = crate::gradient_fill_cmds::angle_param("layer.newFillLayer.gradient", p, 90.0)?;
let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear"));
let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false);
let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true);
let id = s.edit("New Gradient Fill Layer", |doc, active| { let id = s.edit("New Gradient Fill Layer", |doc, active| {
let mut fill = Fill::gradient( let fill = Fill::gradient(
vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))], vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))],
angle, angle,
1.0, 1.0,
@@ -822,9 +817,6 @@ fn build() -> Vec<CommandSpec> {
reverse, reverse,
) )
.in_mode(doc.mode); .in_mode(doc.mode);
if to_transparent && let Fill::Gradient { opacity_stops, .. } = &mut fill {
*opacity_stops = vec![(0.0, 1.0), (1.0, 0.0)];
}
let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill)); let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill));
mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient", default_mask)?; mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient", default_mask)?;
let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto); let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto);
@@ -1224,7 +1216,6 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::filters::specs()); v.extend(crate::filters::specs());
v.extend(crate::filters_ext::specs()); v.extend(crate::filters_ext::specs());
v.extend(crate::color_to_alpha_cmds::specs()); v.extend(crate::color_to_alpha_cmds::specs());
v.extend(crate::frequency_separation_cmds::specs());
v.extend(crate::gallery_cmds::specs()); v.extend(crate::gallery_cmds::specs());
v.extend(crate::gradient_fill_cmds::specs()); v.extend(crate::gradient_fill_cmds::specs());
v.extend(crate::solid_fill_cmds::specs()); v.extend(crate::solid_fill_cmds::specs());
@@ -1237,7 +1228,6 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::path_edit_cmds::specs()); v.extend(crate::path_edit_cmds::specs());
v.extend(crate::smartselect_cmds::specs()); v.extend(crate::smartselect_cmds::specs());
v.extend(crate::cutout_cmds::specs()); v.extend(crate::cutout_cmds::specs());
v.extend(crate::background_blur_cmds::specs());
v.extend(crate::symmetry_cmds::specs()); v.extend(crate::symmetry_cmds::specs());
v.extend(crate::edit_cmds::specs()); v.extend(crate::edit_cmds::specs());
v.extend(crate::color_cmds::specs()); v.extend(crate::color_cmds::specs());
@@ -1330,7 +1320,6 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::mask_view_cmds::specs()); v.extend(crate::mask_view_cmds::specs());
v.extend(crate::layer_mask_props_cmds::specs()); v.extend(crate::layer_mask_props_cmds::specs());
v.extend(crate::actions_cmds::specs()); v.extend(crate::actions_cmds::specs());
v.extend(crate::action_defaults::specs());
// The dispatch guard's unit test needs a command that panics on purpose (REL-3). // The dispatch guard's unit test needs a command that panics on purpose (REL-3).
#[cfg(test)] #[cfg(test)]
v.push(test_panic_command()); v.push(test_panic_command());
+6 -129
View File
@@ -22,8 +22,6 @@ use serde_json::{Value, json};
use crate::commands::CommandSpec; use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session}; use crate::{EngineError, Result, Session};
mod json_file;
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError { fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
} }
@@ -80,9 +78,6 @@ impl Action {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ActionsFile { pub struct ActionsFile {
pub version: u32, pub version: u32,
/// Starter set last seeded. Older stores default to zero; deletions after seeding persist.
#[serde(default)]
pub defaults_version: u32,
pub actions: Vec<Action>, pub actions: Vec<Action>,
} }
@@ -118,84 +113,6 @@ pub struct ActionState {
/// started in is still the one running when its result lands. /// started in is still the one running when its result lands.
#[serde(skip)] #[serde(skip)]
pub take: u64, pub take: u64,
#[serde(skip)]
pub(crate) undo: Vec<ActionEdit>,
#[serde(skip)]
pub(crate) redo: Vec<ActionEdit>,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ActionEdit {
list: Vec<Action>,
recording: Option<(usize, usize)>,
shortcuts: std::collections::BTreeMap<String, String>,
}
fn snapshot(s: &Session) -> ActionEdit {
let mut list = s.actions.list.clone();
if let Some((_, idx)) = s.actions.recording {
let pending: Vec<_> = pending_steps(s, idx).cloned().collect();
if let Some(action) = list.get_mut(idx) {
action.steps.extend(pending);
}
}
ActionEdit {
list,
recording: s.actions.recording.map(|(_, idx)| (s.journal.len(), idx)),
shortcuts: s.prefs().shortcuts.iter().filter(|(key, _)| key.starts_with(PLAY_SHORTCUT_PREFIX)).map(|(k, v)| (k.clone(), v.clone())).collect(),
}
}
fn edit_action(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>, include_pending: bool) -> Result<Value> {
let mut before = snapshot(s);
if !include_pending {
before.list = s.actions.list.clone();
}
let result = run(s, p)?;
if before.list != snapshot(s).list {
s.actions.undo.push(before);
if s.actions.undo.len() > 100 {
s.actions.undo.remove(0);
}
s.actions.redo.clear();
}
Ok(result)
}
pub fn can_undo(s: &Session) -> bool {
s.actions.playing == 0 && !s.actions.undo.is_empty()
}
pub fn can_redo(s: &Session) -> bool {
s.actions.playing == 0 && !s.actions.redo.is_empty()
}
fn undo_edit(s: &mut Session, redo: bool) -> Result<Value> {
not_playing(s, if redo { "actions.redo" } else { "actions.undo" })?;
let current = snapshot(s);
let prior = if redo { s.actions.redo.pop() } else { s.actions.undo.pop() };
let Some(prior) = prior else { return Ok(json!(false)) };
if redo {
s.actions.undo.push(current);
} else {
s.actions.redo.push(current);
}
let old_names: std::collections::BTreeSet<_> = s.actions.list.iter().map(|a| a.name.as_str()).collect();
let new_names: std::collections::BTreeSet<_> = prior.list.iter().map(|a| a.name.as_str()).collect();
let changed_bindings: Vec<_> = old_names.symmetric_difference(&new_names).map(|name| format!("{PLAY_SHORTCUT_PREFIX}{name}")).collect();
s.actions.list = prior.list;
s.actions.recording = s.actions.recording.and(prior.recording).map(|(_, idx)| (s.journal.len(), idx));
if !changed_bindings.is_empty() {
s.edit_prefs(|prefs| {
for key in changed_bindings {
prefs.shortcuts.remove(&key);
if let Some(value) = prior.shortcuts.get(&key) {
prefs.shortcuts.insert(key, value.clone());
}
}
});
}
s.actions.touch();
Ok(json!(true))
} }
/// Visibility gestures name a layer for replay, since document-local ids change on another image. /// Visibility gestures name a layer for replay, since document-local ids change on another image.
@@ -805,46 +722,6 @@ fn set_color(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
CommandSpec {
id: "actions.import",
label: "Load Actions…",
menu: &[],
shortcut: None,
params: r##"{"data":str} → {imported, skipped, actions:[names]}. Append version 1 Actions JSON, retarget nested calls, and rename conflicts; unchanged reimports do nothing."##,
enabled: always,
run: json_file::import,
journal: false,
},
CommandSpec {
id: "actions.export",
label: "Save Actions…",
menu: &[],
shortcut: None,
params: r##"{"path":str?} → {count, bytes, data?:JSON string, path?}. Export version 1 Actions JSON, preserving disabled steps, dialog toggles and sets."##,
enabled: always,
run: json_file::export,
journal: false,
},
CommandSpec {
id: "actions.undo",
label: "Undo Action Edit",
menu: &[],
shortcut: None,
params: "{} → bool. Undo an action definition edit without changing the document.",
enabled: |s| if can_undo(s) { Ok(()) } else { Err("nothing to undo".into()) },
run: |s, _| undo_edit(s, false),
journal: false,
},
CommandSpec {
id: "actions.redo",
label: "Redo Action Edit",
menu: &[],
shortcut: None,
params: "{} → bool. Redo an action definition edit without changing the document.",
enabled: |s| if can_redo(s) { Ok(()) } else { Err("nothing to redo".into()) },
run: |s, _| undo_edit(s, true),
journal: false,
},
CommandSpec { CommandSpec {
id: "actions.move", id: "actions.move",
label: "Move Action or Step", label: "Move Action or Step",
@@ -852,7 +729,7 @@ pub fn specs() -> Vec<CommandSpec> {
shortcut: None, shortcut: None,
params: r##"{"action":name|index, "step":index?, "to":index}. Move a step within its action, or the action itself. to is the final zero-based index. Pending recorded steps can be moved."##, params: r##"{"action":name|index, "step":index?, "to":index}. Move a step within its action, or the action itself. to is the final zero-based index. Pending recorded steps can be moved."##,
enabled: always, enabled: always,
run: |s, p| edit_action(s, p, move_item, true), run: move_item,
journal: false, journal: false,
}, },
CommandSpec { CommandSpec {
@@ -882,7 +759,7 @@ pub fn specs() -> Vec<CommandSpec> {
shortcut: None, shortcut: None,
params: r##"{"name":str? (new action, default "Action N")} or {"action":name|index} (append) → {action, index}"##, params: r##"{"name":str? (new action, default "Action N")} or {"action":name|index} (append) → {action, index}"##,
enabled: always, enabled: always,
run: |s, p| edit_action(s, p, record, true), run: record,
journal: false, journal: false,
}, },
CommandSpec { CommandSpec {
@@ -892,7 +769,7 @@ pub fn specs() -> Vec<CommandSpec> {
shortcut: None, shortcut: None,
params: "{} → {action, steps:count} (steps recorded since actions.record, queries and action-editing commands omitted)", params: "{} → {action, steps:count} (steps recorded since actions.record, queries and action-editing commands omitted)",
enabled: always, enabled: always,
run: |s, p| edit_action(s, p, stop, false), run: stop,
journal: false, journal: false,
}, },
CommandSpec { CommandSpec {
@@ -912,7 +789,7 @@ pub fn specs() -> Vec<CommandSpec> {
shortcut: None, shortcut: None,
params: r##"{"action":name|index, "step":index?} → {deleted:name} or {action, deletedStep, steps:count}. step is 0-based, including pending recorded steps. Whole-action deletion is refused while recording."##, params: r##"{"action":name|index, "step":index?} → {deleted:name} or {action, deletedStep, steps:count}. step is 0-based, including pending recorded steps. Whole-action deletion is refused while recording."##,
enabled: always, enabled: always,
run: |s, p| edit_action(s, p, delete, true), run: delete,
journal: false, journal: false,
}, },
CommandSpec { CommandSpec {
@@ -922,7 +799,7 @@ pub fn specs() -> Vec<CommandSpec> {
shortcut: None, shortcut: None,
params: r##"{"action":name|index, "step":index?, "enabled":bool?} → {action, disabled:[step…]}. Switch one 0-based step (including pending recorded steps) on or off for playback, Batch and droplets; omitted enabled flips it. Without step, switch every step: on unless all already are, or as enabled says."##, params: r##"{"action":name|index, "step":index?, "enabled":bool?} → {action, disabled:[step…]}. Switch one 0-based step (including pending recorded steps) on or off for playback, Batch and droplets; omitted enabled flips it. Without step, switch every step: on unless all already are, or as enabled says."##,
enabled: always, enabled: always,
run: |s, p| edit_action(s, p, toggle, true), run: toggle,
journal: false, journal: false,
}, },
CommandSpec { CommandSpec {
@@ -932,7 +809,7 @@ pub fn specs() -> Vec<CommandSpec> {
shortcut: None, shortcut: None,
params: r##"{"action":name|index, "name":str} → {action, index}. The name must not be empty or whitespace-only and must be unique. Nested Play Action steps and the action's function key follow the rename."##, params: r##"{"action":name|index, "name":str} → {action, index}. The name must not be empty or whitespace-only and must be unique. Nested Play Action steps and the action's function key follow the rename."##,
enabled: always, enabled: always,
run: |s, p| edit_action(s, p, rename, true), run: rename,
journal: false, journal: false,
}, },
CommandSpec { CommandSpec {
@@ -1,291 +0,0 @@
//! Portable Actions JSON files share the preset store's versioned representation.
use std::collections::{HashMap, HashSet};
use super::{Action, ActionsFile, bad};
use crate::{Result, Session};
use serde_json::{Value, json};
const MAX_ACTIONS: usize = 1024;
const MAX_STEPS: usize = 10_000;
const MAX_NAME_BYTES: usize = 256;
fn idle(s: &Session, cmd: &str) -> Result<()> {
if s.actions.recording.is_some() || s.actions.playing > 0 {
return Err(bad(cmd, "stop recording or playing before transferring Actions"));
}
Ok(())
}
fn validate(file: &ActionsFile, cmd: &str) -> Result<()> {
if file.version != 1 {
return Err(bad(cmd, format!("unsupported Actions file version {}", file.version)));
}
if file.actions.len() > MAX_ACTIONS {
return Err(bad(cmd, "an Actions JSON file may contain at most 1024 actions"));
}
let mut names = HashSet::new();
for action in &file.actions {
if action.name.trim().is_empty() || action.name.len() > MAX_NAME_BYTES || !names.insert(&action.name) {
return Err(bad(cmd, "action names must be unique, non-empty and at most 256 bytes"));
}
if action.steps.len() > MAX_STEPS {
return Err(bad(cmd, "an action may contain at most 10,000 steps"));
}
if action.disabled.iter().chain(&action.dialogs).any(|&i| i >= action.steps.len()) {
return Err(bad(cmd, "a disabled or dialog step index is out of range"));
}
if action.set.as_ref().is_some_and(|name| name.len() > MAX_NAME_BYTES) {
return Err(bad(cmd, "action set names may contain at most 256 bytes"));
}
}
Ok(())
}
fn same_contents(a: &Action, b: &Action) -> bool {
a.steps == b.steps && a.disabled == b.disabled && a.dialogs == b.dialogs && a.set == b.set && a.color == b.color
}
fn same_name_family(source: &str, candidate: &str) -> bool {
if source == candidate {
return true;
}
let Some((stem, tail)) = candidate.rsplit_once(" (Imported ") else { return false };
if tail.strip_suffix(')').and_then(|n| n.parse::<usize>().ok()).is_none_or(|n| n < 2) {
return false;
}
let suffix_len = candidate.len().saturating_sub(stem.len());
let mut end = source.len().min(MAX_NAME_BYTES.saturating_sub(suffix_len));
while !source.is_char_boundary(end) {
end = end.saturating_sub(1);
}
source.get(..end) == Some(stem)
}
fn retarget(action: &mut Action, original: &[String], mapping: &HashMap<String, String>, cmd: &str) -> Result<()> {
for (id, params) in &mut action.steps {
if id != "actions.play" {
continue;
}
let name = match params.get("action") {
Some(Value::String(name)) => name.clone(),
Some(Value::Number(n)) => {
let index = n.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "nested action index must be a non-negative integer"))?;
original.get(index).cloned().ok_or_else(|| bad(cmd, format!("invalid nested action index {index}")))?
}
_ => return Err(bad(cmd, "nested action calls need a name or index")),
};
if let Some(target) = mapping.get(&name) {
let object = params.as_object_mut().ok_or_else(|| bad(cmd, "nested action parameters must be an object"))?;
object.insert("action".into(), json!(target));
}
}
Ok(())
}
fn imported_name(name: &str, reserved: &mut HashSet<String>, cmd: &str) -> Result<String> {
if reserved.insert(name.to_string()) {
return Ok(name.to_string());
}
// There can be no more occupied candidates than reserved names. The bound also avoids
// an unbounded counter on a hostile library.
for n in 2..=reserved.len().saturating_add(2) {
let suffix = format!(" (Imported {n})");
let limit = MAX_NAME_BYTES.saturating_sub(suffix.len());
let mut end = name.len().min(limit);
while !name.is_char_boundary(end) {
end = end.saturating_sub(1);
}
let base = name.get(..end).ok_or_else(|| bad(cmd, "invalid action name boundary"))?;
let candidate = format!("{base}{suffix}");
if reserved.insert(candidate.clone()) {
return Ok(candidate);
}
}
Err(bad(cmd, "cannot allocate a unique imported action name"))
}
pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "actions.import";
idle(s, CMD)?;
let data = p.get("data").and_then(Value::as_str).ok_or_else(|| bad(CMD, "data must be an Actions JSON string"))?;
if data.len() as u64 > crate::preset_store::MAX_ACTIONS_BYTES {
return Err(bad(CMD, "Actions JSON exceeds the 16 MiB import limit"));
}
let file: ActionsFile = serde_json::from_str(data).map_err(|e| bad(CMD, format!("invalid Actions JSON: {e}")))?;
validate(&file, CMD)?;
let original: Vec<String> = file.actions.iter().map(|a| a.name.clone()).collect();
let existing = &s.actions.list;
let mut reserved: HashSet<String> = existing.iter().map(|a| a.name.clone()).chain(original.iter().cloned()).collect();
let mut mapping = HashMap::new();
for action in &file.actions {
let target = if let Some(found) = existing.iter().find(|a| a.name == action.name && same_contents(a, action)) {
found.name.clone()
} else if !existing.iter().any(|a| a.name == action.name) {
action.name.clone()
} else {
imported_name(&action.name, &mut reserved, CMD)?
};
mapping.insert(action.name.clone(), target);
}
// A parent can precede its child. Reusing an already imported child can make the
// parent reusable too, so resolve those dependencies before mutating the library.
let mut planned = Vec::new();
for _ in 0..=file.actions.len() {
planned.clear();
let mut corrections = Vec::new();
for source in &file.actions {
let mut action = source.clone();
retarget(&mut action, &original, &mapping, CMD)?;
let target = mapping.get(&source.name).ok_or_else(|| bad(CMD, "missing imported action name"))?;
action.name = target.clone();
if let Some(found) = existing.iter().find(|a| same_contents(a, &action) && (a.name == *target || same_name_family(&source.name, &a.name))) {
if action.name != found.name {
corrections.push((source.name.clone(), found.name.clone()));
}
action.name = found.name.clone();
}
planned.push(action);
}
if corrections.is_empty() {
break;
}
mapping.extend(corrections);
}
// Apply the final mapping once more so every retained nested call uses the destination.
for (source, action) in file.actions.iter().zip(&mut planned) {
*action = source.clone();
retarget(action, &original, &mapping, CMD)?;
action.name = mapping.get(&source.name).cloned().ok_or_else(|| bad(CMD, "missing imported action name"))?;
}
let insert: Vec<Action> = planned.into_iter().filter(|a| !existing.iter().any(|b| a == b)).collect();
let names: Vec<String> = insert.iter().map(|a| a.name.clone()).collect();
let imported = insert.len();
if imported > 0 {
s.actions.list.extend(insert);
s.actions.touch();
}
Ok(json!({"imported": imported, "skipped": original.len().saturating_sub(imported), "actions": names}))
}
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "actions.export";
idle(s, CMD)?;
let path = match p.get("path") {
None => None,
Some(Value::String(path)) if !path.trim().is_empty() => Some(path.as_str()),
Some(_) => return Err(bad(CMD, "path must be a non-empty string")),
};
let file = ActionsFile { version: 1, defaults_version: 0, actions: s.actions.list.clone() };
validate(&file, CMD)?;
let data = serde_json::to_string_pretty(&file).map_err(|e| bad(CMD, e.to_string()))?;
if data.len() as u64 > crate::preset_store::MAX_ACTIONS_BYTES {
return Err(bad(CMD, "Actions JSON exceeds the 16 MiB export limit"));
}
let mut out = json!({"count": file.actions.len(), "bytes": data.len()});
if let Some(path) = path {
crate::file_cmds::write_file(path, data.as_bytes())?;
out["path"] = json!(path);
} else {
out["data"] = json!(data);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_actions_round_trip_preserves_metadata() {
let mut s = Session::new();
let mut action = Action::new("Draw", vec![("layer.new.layer".into(), json!({}))]);
action.disabled.insert(0);
action.dialogs.insert(0);
action.set = Some("Painting".into());
s.actions.list.push(action.clone());
let exported = s.execute("actions.export", json!({})).unwrap();
s.actions.list.clear();
let result = s.execute("actions.import", json!({"data": exported["data"]})).unwrap();
assert_eq!(result["imported"], 1);
assert_eq!(s.actions.list, [action]);
let rev = s.actions.rev;
assert_eq!(s.execute("actions.import", json!({"data": exported["data"]})).unwrap()["imported"], 0);
assert_eq!(s.actions.rev, rev);
}
#[test]
fn reimport_after_changing_button_color_keeps_names_unique() {
let mut s = Session::new();
s.actions.list.push(Action::new("Draw", vec![]));
let data = s.execute("actions.export", json!({})).unwrap()["data"].clone();
s.execute("actions.setColor", json!({"action": "Draw", "color": "red"})).unwrap();
let params = json!({"data": data});
assert_eq!(s.execute("actions.import", params.clone()).unwrap()["imported"], 1);
assert_eq!(s.actions.list[0].name, "Draw");
assert_eq!(s.actions.list[0].color, "red");
assert_eq!(s.actions.list[1].name, "Draw (Imported 2)");
assert!(s.actions.list[1].color.is_empty());
let before = s.actions.clone();
assert_eq!(s.execute("actions.import", params).unwrap()["imported"], 0);
assert_eq!(s.actions, before);
}
#[test]
fn parents_before_children_are_retargeted_and_reimport_is_idempotent() {
let mut s = Session::new();
s.actions.list.push(Action::new("Child", vec![]));
let file = ActionsFile {
version: 1,
defaults_version: 0,
actions: vec![
Action::new("Parent", vec![("actions.play".into(), json!({"action": 1}))]),
Action::new("Child", vec![("layer.new.layer".into(), json!({}))]),
Action::new("Named", vec![("actions.play".into(), json!({"action": "Child"}))]),
],
};
let params = json!({"data": serde_json::to_string(&file).unwrap()});
assert_eq!(s.execute("actions.import", params.clone()).unwrap()["imported"], 3);
assert_eq!(s.actions.list[1].steps[0].1["action"], "Child (Imported 2)");
assert_eq!(s.actions.list[2].name, "Child (Imported 2)");
assert_eq!(s.actions.list[3].steps[0].1["action"], "Child (Imported 2)");
let before = s.actions.clone();
assert_eq!(s.execute("actions.import", params).unwrap()["imported"], 0);
assert_eq!(s.actions, before);
}
#[test]
fn invalid_json_params_and_nested_indices_fail_atomically() {
let mut s = Session::new();
s.actions.list.push(Action::new("Keep", vec![]));
let before = s.actions.clone();
for p in [Value::Null, json!([]), json!({"data": true}), json!({"data": "invalid"}), json!({"data": "{\"version\":2,\"actions\":[]}"})] {
assert!(s.execute("actions.import", p).is_err());
assert_eq!(s.actions, before);
}
for target in [json!(-1), json!(0.5), json!(1), Value::Null] {
let file =
ActionsFile { version: 1, defaults_version: 0, actions: vec![Action::new("A", vec![("actions.play".into(), json!({"action": target}))])] };
assert!(s.execute("actions.import", json!({"data": serde_json::to_string(&file).unwrap()})).is_err());
assert_eq!(s.actions, before);
}
assert!(s.execute("actions.export", json!({"path": 1})).is_err());
s.actions.recording = Some((0, 0));
assert!(s.execute("actions.import", json!({"data": "{}"})).is_err());
assert!(s.execute("actions.export", json!({})).is_err());
assert_eq!(s.actions.list, before.list);
}
#[test]
fn long_unicode_collision_names_remain_portable_and_idempotent() {
let mut s = Session::new();
let name = "界".repeat(85);
s.actions.list.push(Action::new(&name, vec![]));
let file = ActionsFile { version: 1, defaults_version: 0, actions: vec![Action::new(&name, vec![("select.all".into(), json!({}))])] };
let params = json!({"data": serde_json::to_string(&file).unwrap()});
assert_eq!(s.execute("actions.import", params.clone()).unwrap()["imported"], 1);
assert!(s.actions.list[1].name.len() <= MAX_NAME_BYTES);
assert_eq!(s.execute("actions.import", params).unwrap()["imported"], 0);
assert!(s.execute("actions.export", json!({})).is_ok());
}
}
+2 -89
View File
@@ -93,93 +93,6 @@ fn unchecked_steps_are_skipped_and_follow_move_and_delete() {
assert_eq!(s.actions.list[0].disabled.iter().copied().collect::<Vec<_>>(), [1], "rejected toggles change nothing"); assert_eq!(s.actions.list[0].disabled.iter().copied().collect::<Vec<_>>(), [1], "rejected toggles change nothing");
} }
#[test]
fn recording_and_appending_steps_can_be_undone_without_restarting_recording() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("actions.record", json!({"name": "A"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
s.execute("actions.record", json!({"action": "A"})).unwrap();
s.execute("select.all", json!({})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let doc = s.active().unwrap().doc.clone();
assert_eq!(s.actions.list[0].steps.len(), 2);
s.execute("actions.undo", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps.len(), 1);
assert!(s.actions.recording.is_none());
s.execute("actions.redo", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps.len(), 2);
assert!(s.actions.recording.is_none());
assert_eq!(s.active().unwrap().doc, doc);
}
#[test]
fn definition_edits_undo_redo_without_touching_document_or_journal() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.actions.list.push(Action {
name: "A".into(),
steps: vec![("layer.new.layer".into(), json!({})), ("select.all".into(), json!({}))],
disabled: Default::default(),
..Default::default()
});
s.actions.list.push(Action { name: "Caller".into(), steps: vec![("actions.play".into(), json!({"action": "A"}))], ..Default::default() });
s.edit_prefs(|p| {
p.shortcuts.insert("actions.play:A".into(), "F6".into());
});
let before = s.actions.list.clone();
let doc = s.active().unwrap().doc.clone();
let journal = s.journal.clone();
s.execute("actions.move", json!({"action": "A", "step": 0, "to": 1})).unwrap();
s.execute("actions.delete", json!({"action": "A", "step": 0})).unwrap();
s.execute("actions.rename", json!({"action": "A", "name": "B"})).unwrap();
let after = s.actions.list.clone();
for _ in 0..3 {
s.execute("actions.undo", json!({})).unwrap();
}
assert_eq!(s.actions.list, before);
assert_eq!(s.prefs().shortcuts.get("actions.play:A").map(String::as_str), Some("F6"));
assert!(!s.prefs().shortcuts.contains_key("actions.play:B"));
for _ in 0..3 {
s.execute("actions.redo", json!({})).unwrap();
}
assert_eq!(s.actions.list, after);
assert_eq!(s.active().unwrap().doc, doc);
assert_eq!(s.journal, journal);
s.execute("actions.undo", json!({})).unwrap();
s.execute("actions.delete", json!({"action": "Caller"})).unwrap();
assert!(!can_redo(&s));
}
#[test]
fn enabled_step_edits_undo_and_invalidate_redo() {
let mut s = Session::new();
s.actions.list.push(Action::new("A", vec![("select.all".into(), json!({}))]));
s.execute("actions.toggle", json!({"action": "A", "step": 0})).unwrap();
assert!(s.actions.list[0].disabled.contains(&0));
s.execute("actions.undo", json!({})).unwrap();
assert!(s.actions.list[0].disabled.is_empty());
s.execute("actions.redo", json!({})).unwrap();
assert!(s.actions.list[0].disabled.contains(&0));
s.execute("actions.undo", json!({})).unwrap();
s.execute("actions.toggle", json!({"action": "A"})).unwrap();
assert!(!can_redo(&s));
}
#[test]
fn undo_pending_step_deletion_does_not_duplicate_recording() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("actions.record", json!({"name": "A"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("actions.delete", json!({"action": "A", "step": 0})).unwrap();
s.execute("actions.undo", json!({})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps.len(), 1);
assert_eq!(s.active().unwrap().doc.layers.len(), 2);
}
#[test] #[test]
fn recorded_eye_visibility_replays_on_another_document_and_duplicate_names() { fn recorded_eye_visibility_replays_on_another_document_and_duplicate_names() {
let mut s = Session::new(); let mut s = Session::new();
@@ -1092,8 +1005,8 @@ fn actions_persist_with_the_preset_store() {
let mut t = Session::new(); let mut t = Session::new();
t.attach_preset_store(crate::preset_store::open_dir(&dir)); t.attach_preset_store(crate::preset_store::open_dir(&dir));
let listed = t.execute("actions.list", json!({})).unwrap(); let listed = t.execute("actions.list", json!({})).unwrap();
let kept = listed["actions"].as_array().unwrap().iter().find(|a| a["name"] == "Kept").unwrap(); assert_eq!(listed["actions"][0]["name"], "Kept");
assert_eq!(kept["steps"], 1); assert_eq!(listed["actions"][0]["steps"], 1);
assert_eq!(t.execute("actions.get", json!({"action": "Kept"})).unwrap()["steps"][0][0], "layer.new.layer"); assert_eq!(t.execute("actions.get", json!({"action": "Kept"})).unwrap()["steps"][0][0], "layer.new.layer");
// An action created before the store finishes loading is kept, and a different one on // An action created before the store finishes loading is kept, and a different one on
+1 -1
View File
@@ -222,7 +222,7 @@ pub fn export(
_ => format!("`{id}`"), _ => format!("`{id}`"),
}; };
return Err(format!( return Err(format!(
"step {} of `{}` ({step_name}) has no Photoshop equivalent event or has a setting its event cannot hold; pass \"skipUnsupported\": true to leave it out", "step {} of `{}` ({step_name}) has no Photoshop equivalent; pass \"skipUnsupported\": true to leave it out",
i + 1, i + 1,
a.name a.name
)); ));
-50
View File
@@ -473,56 +473,6 @@ fn settings_at_the_value_the_event_plays_still_export() {
} }
} }
/// Photoshop's integer fields cannot hold a fractional setting. Rounding one into the file wrote
/// an edit the user never recorded -- `cellSize: 5.4` as `ClSz 5`, `amount: 81.4` as `Amnt 81` --
/// and replay then changed the image with `skipped` empty and nothing in the file to say so
/// (#7669, #7670). Such a step is refused, as a step with no equivalent event already is.
///
/// Only for the fields whose command uses the setting as written: `edit.stroke` ceils its width,
/// so 2.4 renders as 3 and the integer field holds it faithfully -- that one belongs in #7696,
/// not here (#7742 review).
#[test]
fn a_fractional_setting_an_integer_field_cannot_hold_is_refused_not_rounded() {
for (id, params) in [("filter.pixelate.crystallize", json!({"cellSize": 5.4})), ("filter.distort.ripple", json!({"amount": 81.4, "size": "small"}))] {
let mut s = Session::new();
s.actions.list.push(Action::new("A", vec![(id.into(), params)]));
let e = s.execute("actions.save", json!({"action": "A"})).unwrap_err().to_string();
assert!(e.contains(id), "{id}: {e}");
let r = s.execute("actions.save", json!({"action": "A", "skipUnsupported": true})).unwrap();
assert_eq!(r["skipped"], json!([{"action": "A", "step": 0, "id": id}]), "{id}");
}
}
/// The whole numbers those fields were made for are written and come back as themselves: the
/// refusal above is for the settings Photoshop's event cannot hold, not for the ones it can.
#[test]
fn whole_number_settings_for_integer_fields_still_round_trip() {
for (id, params) in [
("filter.pixelate.crystallize", json!({"cellSize": 5})),
("filter.distort.ripple", json!({"amount": 81, "size": "small"})),
("edit.stroke", json!({"width": 2, "color": "#d03060", "location": "inside"})),
] {
let mut s = Session::new();
s.actions.list.push(Action::new("A", vec![(id.into(), params.clone())]));
let r = s.execute("actions.save", json!({"action": "A"})).unwrap();
assert_eq!(r["skipped"], json!([]), "{id}");
let imported = import(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
let (back_id, back) = &imported.actions[0].steps[0];
assert_eq!(back_id, id);
for (key, want) in params.as_object().unwrap() {
let got = back.get(key);
match want.as_f64() {
// A number can come back as an integer where it went out as a float, so these are
// compared by value. Anything else is compared as it is: `as_f64` is `None` for a
// string on both sides, so `size`, `color` and `location` went unchecked
// (#7742 review).
Some(n) => assert_eq!(got.and_then(Value::as_f64), Some(n), "{id}: {key} is {got:?}"),
None => assert_eq!(got, Some(want), "{id}: {key} is {got:?}"),
}
}
}
}
#[test] #[test]
fn every_mapped_command_exports_and_imports_back() { fn every_mapped_command_exports_and_imports_back() {
let cases: Vec<(&str, Value)> = vec![ let cases: Vec<(&str, Value)> = vec![
@@ -1,384 +0,0 @@
//! Blur the background of one pixel layer while preserving the detected subject. Subject
//! coverage is temporary: the user's selection, layer masks and alpha are left intact.
use photocraft_algo::{
FilterParams,
segment::{Region, subject},
};
use photocraft_color::{PixelFormat, SampleType};
use photocraft_doc::{Document, Layer, LayerContent};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::{Value, json};
use crate::commands::{CommandSpec, layer_param};
use crate::jobs::JobCtx;
use crate::{EngineError, Result, Session};
const CMD: &str = "layer.blurBackground";
fn check(doc: &Document, layer: &Layer) -> std::result::Result<(), String> {
if !matches!(layer.content, LayerContent::Raster(_)) {
return Err("Blur Background needs a pixel layer".into());
}
let locks = doc.effective_locks(layer.id);
if locks.pixels || locks.all {
return Err(format!("Could not complete your request because the layer \"{}\" is locked", layer.name));
}
Ok(())
}
fn enabled(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
check(&d.doc, crate::active_layer_of(s)?)
}
fn run(s: &mut Session, p: &Value) -> Result<Value> {
let bad = |msg: &str| EngineError::BadParams { cmd: CMD.into(), msg: msg.into() };
if !p.is_object() {
return Err(bad("parameters must be an object"));
}
let radius = match p.get("radius") {
None => 8.0,
Some(v) => v.as_f64().filter(|r| r.is_finite() && (0.1..=1000.0).contains(r)).ok_or_else(|| bad("radius must be a number from 0.1 to 1000"))? as f32,
};
let id = layer_param(s, p)?;
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
check(doc, doc.layer(id).ok_or(EngineError::NoLayer(id))?).map_err(EngineError::Other)?;
crate::jobs::edit_job(
s,
"Blur Background",
move |doc, _, ctx| {
let found = ctx
.stage(0.0, 0.45, "Finding subject", |ctl| {
ctl.check()?;
crate::smartselect_cmds::with_doc_sampler(doc, Some(id), false, |sampler, d| subject::select_subject_with(sampler, d.bounds(), ctl))
})
.map_err(|_| EngineError::Cancelled)?;
let subject = found.ok_or_else(|| EngineError::Other("no subject found; the layer was not changed".into()))?;
let source = doc.layer(id).and_then(Layer::surface).ok_or(EngineError::NoLayer(id))?;
let blurred = blur_background(source, &subject, doc.selection.as_ref(), doc.bounds(), radius, ctx)?;
ctx.check()?;
*crate::pixels_mut(doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?)? = blurred;
Ok(())
},
move |()| json!({"layer": id.0, "radius": radius}),
)
}
fn blur_background(source: &Surface, subject: &Region, selection: Option<&Surface>, bounds: Rect, radius: f32, ctx: &JobCtx) -> Result<Surface> {
let format = source.format();
let colors = format.mode.color_channels();
// Gaussian Blur premultiplies, blurs, then divides by alpha. Excluding subject coverage
// from this scratch alpha prevents fully selected foreground colours bleeding outward.
// Keep fractional coverage in float even for 8-bit documents.
let scratch_format = PixelFormat::new(format.mode, SampleType::F32, true);
let mut scratch = Surface::new(scratch_format);
ctx.progress(0.45, "Preparing background");
for tile in bounds.tiles() {
ctx.check()?;
let area = tile.rect().intersect(&bounds);
let pixels = crate::allocation::read_region(source, area, "Blur Background")?;
let count = pixels.len() / source.channels();
let mut data = crate::allocation::capacity(count * scratch_format.channels(), "Blur Background")?;
for (i, pixel) in pixels.chunks_exact(source.channels()).enumerate() {
let (x, y) = (area.x0 + (i % area.width() as usize) as i32, area.y0 + (i / area.width() as usize) as i32);
data.extend(pixel.iter().take(colors));
let alpha = if format.alpha { pixel.last().copied().unwrap_or(0.0).clamp(0.0, 1.0) } else { 1.0 };
data.push(alpha * (1.0 - subject.at(x, y)));
}
scratch.try_write_region(area, &data)?;
}
let filtered = ctx
.stage(0.5, 0.9, "Blurring background", |ctl| {
photocraft_algo::apply_in_with(&scratch, &FilterParams::GaussianBlur { radius }, bounds, bounds, None, bounds, ctl)
})
.ok_or(EngineError::Cancelled)?;
// Sample freely across the background, then restrict only the result to the user's
// selection. The original layer remains the source for alpha and untouched pixels.
let mut output = source.clone();
ctx.progress(0.9, "Applying background blur");
for tile in bounds.tiles() {
ctx.check()?;
let area = tile.rect().intersect(&bounds);
let mut pixels = crate::allocation::read_region(source, area, "Blur Background")?;
let blurred = crate::allocation::read_region(&filtered, area, "Blur Background")?;
let mut changed = false;
for (i, (pixel, blurred)) in pixels.chunks_exact_mut(source.channels()).zip(blurred.chunks_exact(scratch_format.channels())).enumerate() {
let (x, y) = (area.x0 + (i % area.width() as usize) as i32, area.y0 + (i / area.width() as usize) as i32);
let weight = (1.0 - subject.at(x, y)) * selection.map_or(1.0, |mask| mask.sample_channel(x, y, 0).clamp(0.0, 1.0));
if weight <= 0.0 || blurred.last().copied().unwrap_or(0.0) <= 1e-7 || (format.alpha && pixel.last().copied().unwrap_or(0.0) <= 0.0) {
continue;
}
for (original, blurred) in pixel.iter_mut().zip(blurred).take(colors) {
let value = *original * (1.0 - weight) + blurred * weight;
changed |= value != *original;
*original = value;
}
}
if changed {
output.try_write_region(area, &pixels)?;
}
}
ctx.check()?;
Ok(output)
}
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: CMD,
label: "Blur Background",
menu: &[],
shortcut: None,
params: r##"{"layer":id|name?,"radius":0.1..1000=8} → {layer,radius}. Detect a subject on the pixel layer and blur its background within the current selection; preserve selection, masks and alpha. One undo step. No detected subject leaves the document unchanged."##,
enabled,
run,
journal: true,
}]
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_algo::segment::Rng;
use photocraft_doc::{LayerId, LayerMask};
use std::sync::Arc;
fn disc(width: u32, height: u32, depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": width, "height": height, "depth": depth})).unwrap();
s.edit("test photo", |doc, _| {
let area = doc.bounds();
let surface = doc.layers[0].surface_mut().unwrap();
let mut rng = Rng::new(2);
for tile in area.tiles() {
let r = tile.rect().intersect(&area);
let mut pixels = Vec::new();
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let (dx, dy) = (x as f32 + 0.5 - width as f32 * 0.5, y as f32 + 0.5 - height as f32 * 0.5);
let noise = 0.05 * rng.normal();
let inside = dx * dx + dy * dy <= (height as f32 * 0.28).powi(2);
let color = if inside {
let texture = ((x + y) as f32 * 0.35).sin() * 0.5 + 0.5;
[0.85 + 0.05 * texture + noise, 0.2 + 0.3 * texture + noise, 0.15 + noise]
} else {
[0.25 + noise, 0.45 + noise, 0.7 + noise]
};
pixels.extend(color.map(|v| v.clamp(0.0, 1.0)));
if surface.format().alpha {
pixels.push(1.0);
}
}
}
surface.write_region(r, &pixels);
}
Ok(())
})
.unwrap();
s
}
fn id(s: &Session) -> LayerId {
s.active().unwrap().active_layer.unwrap()
}
fn surface(s: &Session, layer: LayerId) -> &Surface {
s.active().unwrap().doc.layer(layer).unwrap().surface().unwrap()
}
fn background_energy(surface: &Surface) -> f32 {
(10..40).flat_map(|y| (10..40).map(move |x| (x, y))).map(|(x, y)| (surface.sample_channel(x, y, 0) - 0.25).powi(2)).sum::<f32>() / 900.0
}
#[test]
fn blur_background_preserves_subject_and_undoes_at_every_depth() {
for depth in [8, 16, 32] {
let mut s = disc(200, 160, depth);
let layer = id(&s);
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
assert!(s.is_enabled(CMD));
// Exercise the shipping action, including its command params and single-step undo.
s.execute("actions.loadDefaults", json!({})).unwrap();
let result = s.execute("actions.play", json!({"action": "Blur Background"})).unwrap();
assert_eq!(result["ran"], 1, "{depth}: {result}");
assert!(result.get("failed").is_none(), "{depth}: {result}");
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
let old = before.layer(layer).unwrap().surface().unwrap();
let new = surface(&s, layer);
assert_eq!(
new.read_region(Rect::new(85, 65, 115, 95)),
old.read_region(Rect::new(85, 65, 115, 95)),
"the subject interior remains sharp at {depth}"
);
assert!(background_energy(new) < background_energy(old) * 0.1, "background noise is reduced at {depth}");
let after = s.active().unwrap().doc.clone();
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
assert!(s.redo());
assert_eq!(s.active().unwrap().doc, after);
}
}
#[test]
fn blur_background_preserves_selection_alpha_masks_and_other_layers() {
let mut s = disc(200, 160, 16);
let layer = id(&s);
s.execute("layer.new.layer", json!({"name": "Unchanged overlay"})).unwrap();
s.edit("existing state", |doc, _| {
let layer = doc.layer_mut(layer).unwrap();
let surf = layer.surface_mut().unwrap();
for y in 0..160 {
for x in 0..200 {
let mut pixel = surf.pixel(x, y);
*pixel.last_mut().unwrap() = if x < 50 { 0.5 } else { 1.0 };
surf.write_pixel(x, y, &pixel);
}
}
// Off-canvas pixels must survive the canvas-bounded edit too.
surf.fill_rect(Rect::new(-8, -8, 0, 0), &[0.1, 0.2, 0.3, 0.4]);
layer.mask = Some(LayerMask { density: 0.7, feather: 2.0, ..LayerMask::reveal_all() });
layer.opacity = 0.6;
let mut selection = Surface::new(PixelFormat::GRAY8);
selection.fill_rect(Rect::new(0, 0, 60, 160), &[0.5]);
doc.selection = Some(selection);
Ok(())
})
.unwrap();
// A mask target must not turn this layer operation into a mask filter.
s.active_mut().unwrap().active_layer = Some(layer);
s.active_mut().unwrap().channel_view.target = crate::channel_cmds::ChannelTarget::LayerMask;
let before = s.active().unwrap().doc.clone();
s.execute(CMD, json!({"radius": 4})).unwrap();
let mut expected = (*before).clone();
expected.layer_mut(layer).unwrap().content = s.active().unwrap().doc.layer(layer).unwrap().content.clone();
assert_eq!(*s.active().unwrap().doc, expected, "only target layer pixels may change");
let (old, new) = (before.layer(layer).unwrap().surface().unwrap(), surface(&s, layer));
assert_ne!(new.read_region(Rect::new(10, 10, 40, 40)), old.read_region(Rect::new(10, 10, 40, 40)));
assert_eq!(new.read_region(Rect::new(60, 0, 200, 160)), old.read_region(Rect::new(60, 0, 200, 160)), "outside the user's selection");
assert_eq!(new.read_region(Rect::new(-8, -8, 0, 0)), old.read_region(Rect::new(-8, -8, 0, 0)));
for y in 0..160 {
for x in 0..200 {
assert_eq!(new.sample_channel(x, y, 3), old.sample_channel(x, y, 3));
}
}
assert_eq!(s.active().unwrap().channel_view.target, crate::channel_cmds::ChannelTarget::LayerMask);
}
#[test]
fn background_sampling_excludes_subject_colors_for_both_blur_paths() {
let bounds = Rect::new(0, 0, 80, 60);
let subject = Region { bbox: Rect::new(30, 10, 50, 50), mask: vec![255; 20 * 40] };
use photocraft_color::ColorMode;
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut source = Surface::new(PixelFormat::new(mode, depth, true));
let colors = mode.color_channels();
let mut background = vec![0.2; colors];
let mut foreground = vec![0.9; colors];
if mode == ColorMode::Rgb {
background = vec![0.0, 0.3, 0.85];
foreground = vec![1.0, 0.0, 0.0];
}
background.push(0.5);
foreground.push(1.0);
source.fill_rect(bounds, &background);
source.fill_rect(subject.bbox, &foreground);
for radius in [3.0, 8.0] {
let output = blur_background(&source, &subject, None, bounds, radius, &JobCtx::new()).unwrap();
assert_eq!(output.format(), source.format());
let edge = output.pixel(29, 30);
for (c, value) in edge.iter().take(colors).enumerate() {
assert!(
(value - source.sample_channel(29, 30, c)).abs() < 0.005,
"foreground leaked into {mode:?} channel {c}, {depth:?}, radius {radius}: {edge:?}"
);
}
if mode == ColorMode::Rgb {
assert!(edge[0] < 1e-6, "subject red leaked through at {depth:?}, radius {radius}: {edge:?}");
}
assert_eq!(output.read_region(subject.bbox), source.read_region(subject.bbox));
assert_eq!(edge[colors], source.sample_channel(29, 30, colors));
}
}
}
let mut tiny_alpha = Surface::new(PixelFormat::RGBA32F);
tiny_alpha.fill_rect(bounds, &[0.2, 0.3, 0.4, 1e-9]);
let output = blur_background(&tiny_alpha, &subject, None, bounds, 8.0, &JobCtx::new()).unwrap();
assert_eq!(output, tiny_alpha, "no usable background contribution must not turn into black");
}
#[test]
fn blur_background_without_subject_or_when_cancelled_changes_nothing() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap();
s.execute("select.rect", json!({"x": 4, "y": 4, "width": 16, "height": 16})).unwrap();
let before = s.active().unwrap().doc.clone();
let steps = s.active().unwrap().history.past_len();
assert!(s.execute(CMD, json!({})).is_err());
assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc));
assert_eq!(steps, s.active().unwrap().history.past_len());
let mut s = disc(200, 160, 8);
let before = s.active().unwrap().doc.clone();
let steps = s.active().unwrap().history.past_len();
let ctx = JobCtx::new();
ctx.cancel();
s.set_inline_job_ctx(Some(ctx));
assert!(matches!(s.execute(CMD, json!({})), Err(EngineError::Cancelled)));
assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc));
assert_eq!(steps, s.active().unwrap().history.past_len());
}
#[test]
fn blur_background_rejects_invalid_targets_params_and_effective_locks() {
let mut empty = Session::new();
assert!(!empty.is_enabled(CMD));
assert!(empty.execute(CMD, json!({})).is_err());
let mut s = disc(200, 160, 8);
let before = s.active().unwrap().doc.clone();
for params in [
Value::Null,
json!([]),
json!(false),
json!({"radius": "8"}),
json!({"radius": null}),
json!({"radius": 0}),
json!({"radius": -1}),
json!({"radius": 1001}),
json!({"radius": 1e100}),
json!({"layer": false}),
json!({"layer": -1}),
json!({"layer": 1.5}),
json!({"layer": "missing"}),
json!({"layer": 999_999}),
] {
assert!(s.execute(CMD, params.clone()).is_err(), "{params}");
assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc));
}
s.execute("layer.setProps", json!({"locks": {"pixels": true}})).unwrap();
assert!(!s.is_enabled(CMD));
assert!(s.execute(CMD, json!({})).is_err());
s.execute("layer.setProps", json!({"locks": {"pixels": false}})).unwrap();
s.execute("layer.groupLayers", json!({})).unwrap();
// The group is not a raster target; a locked parent also protects the member's pixels.
assert!(!s.is_enabled(CMD));
assert!(s.execute(CMD, json!({})).is_err());
let group = id(&s);
s.execute("layer.setProps", json!({"locks": {"all": true}})).unwrap();
let child = s.active().unwrap().doc.layer(group).unwrap().children().unwrap().first().unwrap().id;
let before = s.active().unwrap().doc.clone();
assert!(s.execute(CMD, json!({"layer": child.0})).is_err());
assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc));
}
#[test]
#[ignore = "release-only 24 MP background blur timing; run explicitly"]
fn blur_background_24mp_timing() {
let mut s = disc(6000, 4000, 8);
let started = std::time::Instant::now();
let result = s.execute(CMD, json!({})).unwrap();
println!("Blur Background 24 MP: {:?}; {result}", started.elapsed());
}
}
@@ -1,475 +0,0 @@
//! Filter › Other › Frequency Separation (`layer.frequencySeparation`, #1714): splits the active
//! pixel layer into a "Low Frequency" layer (a Gaussian-blurred copy: colour and tone) and a
//! "High Frequency" layer (the detail the blur took out) in a "Frequency Separation" group right
//! above it, so tone and texture can be retouched apart. One history step; the source layer is
//! left as it is, under the group.
//!
//! High = (Source − Low) / 2 + ½ in every colour channel, on Linear Light (Low + 2·High − 1),
//! gives the source back. That is Photoshop's recipe (Apply Image › Subtract, Scale 2, Offset 128
//! in 8-bit; Add with Invert, Scale 2, Offset 0 in 16-bit: the same values) and it holds in 32-bit
//! too, since our Linear Light is the same formula at every depth. It clips at 0 and 1, so 32-bit
//! values above 1 come back as 1. 8-bit stores High to the nearest level, so the result is within
//! one level of the source, as in Photoshop.
//!
//! Photoshop has no such menu item (retouchers record it as an action), so the id is our own. It
//! is not a `filter.*` command: it adds layers instead of filtering the target, so it must not
//! follow a targeted mask or channel, nor be offered to Fade or Last Filter. The new layers carry
//! the source's pixels only (no mask, style or blending options), and the selection is ignored.
use photocraft_color::{BlendMode, ColorMode};
use photocraft_doc::{Layer, LayerContent};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use rayon::prelude::*;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
/// The command id.
pub const ID: &str = "layer.frequencySeparation";
const LABEL: &str = "Frequency Separation";
const GAUSSIAN: &str = "filter.blur.gaussianBlur";
/// Gaussian Blur's own range and a portrait-sized default.
const RADIUS: (f64, f64, f64) = (0.1, 1000.0, 4.0);
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: ID.into(), msg: msg.into() }
}
fn radius(p: &Value) -> Result<f64> {
let (min, max, default) = RADIUS;
match p.get("radius") {
None => Ok(default),
Some(v) => {
v.as_f64().filter(|r| r.is_finite() && (min..=max).contains(r)).ok_or_else(|| bad(format!("`radius` must be a number in {min}..{max}, not {v}")))
}
}
}
/// The pixels of `l`, when it is a pixel layer with any. Only the tile count is checked here, as
/// the menu asks often; [`run`] looks at the pixels themselves.
fn pixels_of(l: &Layer) -> std::result::Result<&Surface, String> {
match &l.content {
LayerContent::Raster(surf) if surf.tile_count() > 0 => Ok(surf),
LayerContent::Raster(_) => Err(format!("the layer \"{}\" has no pixels", l.name)),
other => Err(format!("Frequency Separation needs a pixel layer (\"{}\" is {} {} layer)", l.name, other.article(), other.kind_name())),
}
}
/// Menu state: an RGB or Grayscale document whose active layer is a pixel layer with pixels, its
/// pixels targeted (not a channel, Quick Mask or the layer's mask).
fn enabled(s: &Session) -> std::result::Result<(), String> {
if crate::channel_cmds::edits_channel(s) {
return Err("Frequency Separation works on layer pixels, not on a channel or Quick Mask".into());
}
let d = s.active().ok_or("no document open")?;
if d.channel_view.target == crate::channel_cmds::ChannelTarget::LayerMask || crate::mask_view_cmds::current(d).is_some() {
return Err("Frequency Separation works on layer pixels, not on a layer mask".into());
}
match d.doc.mode {
ColorMode::Rgb | ColorMode::Grayscale => {}
m => return Err(format!("Frequency Separation needs an RGB or Grayscale document (this one is {m:?})")),
}
pixels_of(crate::active_layer_of(s)?).map(|_| ())
}
/// High = (Source − Low) / 2 + ½ in the colour channels, the source's transparency kept. Where a
/// layer with transparency is transparent High stays so; a layer without (the Background) is
/// computed over the whole canvas.
fn high_pass(src: &Surface, low: &Surface, canvas: Rect) -> Surface {
let fmt = src.format();
let (n, colors) = (fmt.channels(), fmt.mode.color_channels());
let coords: Vec<_> = if fmt.alpha { src.tiles().map(|(c, _)| *c).collect() } else { canvas.union(&src.tile_bounds()).tiles().collect() };
let mut high = src.clone();
// A bounded batch at a time: the f32 copies of a whole 36 MP layer would take gigabytes.
for batch in coords.chunks(rayon::current_num_threads().max(1) * 4) {
let done: Vec<_> = batch
.par_iter()
.map(|c| {
let r = c.rect();
let mut px = src.read_region(r);
for (h, l) in px.chunks_exact_mut(n).zip(low.read_region(r).chunks_exact(n)) {
// Nothing to rebuild where the source is transparent: leaving it as it is keeps
// High's bounds the source's (mid-grey there would fill out whole tiles).
if fmt.alpha && h.get(colors).is_some_and(|a| *a <= 0.0) {
continue;
}
for (h, l) in h.iter_mut().zip(l).take(colors) {
*h = (*h - l) * 0.5 + 0.5;
}
}
(r, px)
})
.collect();
for (r, px) in done {
high.write_region(r, &px);
}
}
high.prune();
high
}
fn run(s: &mut Session, p: &Value) -> Result<Value> {
let radius = radius(p)?;
enabled(s).map_err(EngineError::Other)?;
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
let (group, low, high) = s.edit(LABEL, |doc, active| {
let id = active.ok_or_else(|| EngineError::Other("no active layer".into()))?;
let canvas = doc.bounds();
let source = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let src = pixels_of(source).map_err(EngineError::Other)?;
if src.content_bounds().is_empty() {
return Err(EngineError::Other(format!("the layer \"{}\" has no pixels", source.name)));
}
let low = crate::filters::apply_filter_to_surface(GAUSSIAN, &json!({ "radius": radius }), src, canvas, None, canvas)?
.ok_or_else(|| EngineError::Other("Gaussian Blur is unavailable".into()))?;
// From the Low that is stored (quantised at 8 and 16 bits), so the two add up exactly.
let high = high_pass(src, &low, canvas);
let low = Layer::new("Low Frequency", LayerContent::Raster(low));
let mut high = Layer::new("High Frequency", LayerContent::Raster(high));
high.blend = BlendMode::LinearLight;
let ids = (low.id, high.id);
let group = doc.insert_above(Some(id), Layer::group(LABEL, vec![low, high]));
crate::layer_multi_cmds::check_group_depth(doc, LABEL)?;
*active = Some(ids.1);
Ok((group, ids.0, ids.1))
})?;
crate::layer_multi_cmds::note_insert(s, &before);
Ok(json!({ "group": group.0, "low": low.0, "high": high.0, "radius": radius }))
}
/// The command spec.
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: ID,
label: "Frequency Separation…",
menu: &["Filter", "Other"],
shortcut: None,
params: r##"{"radius":0.1..1000=4} → {group,low,high,radius} (Low Frequency = Gaussian Blur of the active pixel layer, High Frequency = (layer − Low) / 2 + ½ on Linear Light, grouped above it)"##,
enabled,
run,
journal: true,
}]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EngineError, Session};
use photocraft_color::BlendMode;
use photocraft_doc::{Document, LayerContent, LayerId};
use photocraft_geom::Rect;
use serde_json::json;
const W: i32 = 40;
const H: i32 = 28;
/// Fine detail on a slow gradient, different in every channel, so a blur changes it a lot.
fn texture(x: i32, y: i32) -> [f32; 4] {
let hash = |k: i32| {
let v = ((x * 73 + y * 151 + k * 211) as f32 * 12.9898).sin() * 43758.547;
v - v.floor()
};
let ramp = (x + y) as f32 / (W + H) as f32;
[0.1 + 0.5 * ramp + 0.35 * hash(1), 0.8 - 0.6 * ramp + 0.15 * hash(2), 0.5 + 0.45 * (hash(3) - 0.5), 1.0]
}
/// A document with a white Background and an opaque textured pixel layer on top (active).
fn session(depth: u32, mode: &str) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": W, "height": H, "depth": depth, "mode": mode})).unwrap();
s.execute("layer.new.layer", json!({"name": "Skin"})).unwrap();
paint(&mut s);
s
}
fn paint(s: &mut Session) {
s.edit("texture", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let fmt = surf.format();
let px: Vec<f32> = (0..H).flat_map(|y| (0..W).flat_map(move |x| photocraft_raster::from_rgba(&fmt, texture(x, y)))).collect();
surf.write_region(Rect::new(0, 0, W, H), &px);
Ok(())
})
.unwrap();
}
fn doc(s: &Session) -> &Document {
&s.active().unwrap().doc
}
fn active(s: &Session) -> LayerId {
s.active().unwrap().active_layer.unwrap()
}
fn composite(d: &Document) -> Vec<[f32; 4]> {
photocraft_compose::flatten(d).px
}
fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 {
assert_eq!(a.len(), b.len());
a.iter().zip(b).flat_map(|(p, q)| (0..3).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
}
/// The document with layer `id` hidden.
fn without(d: &Document, id: LayerId) -> Document {
let mut d = d.clone();
d.layer_mut(id).unwrap().visible = false;
d
}
/// The new group: (group id, [Low, High] ids, bottom to top).
fn group_of(d: &Document, source: LayerId) -> (LayerId, [LayerId; 2]) {
let path = d.path_of(source).unwrap();
let (&k, parent) = path.split_last().unwrap();
let mut above = parent.to_vec();
above.push(k + 1);
let g = d.layer_at(&above).expect("a layer right above the source");
let ch = g.children().expect("a group right above the source");
assert_eq!(ch.len(), 2, "two layers in the group");
(g.id, [ch[0].id, ch[1].id])
}
fn pixels(d: &Document, id: LayerId) -> Vec<f32> {
d.layer(id).unwrap().surface().unwrap().read_region(Rect::new(0, 0, W, H))
}
/// The defining identity: High on Linear Light over Low gives the source back, through the
/// compositor, to within one step of the depth (8-bit stores High to the nearest level: Photoshop's
/// recipe is ±1 level too). Low and High each differ from it, so the identity isn't a no-op.
#[test]
fn low_and_high_composite_back_to_the_source_at_every_depth() {
for (depth, mode, tol) in [(8, "rgb", 1.0 / 255.0), (16, "rgb", 1.0 / 65535.0), (32, "rgb", 1e-5), (8, "gray", 1.0 / 255.0), (32, "gray", 1e-5)] {
let tol = tol + 1e-6;
let mut s = session(depth, mode);
let source = active(&s);
let before = composite(doc(&s));
s.execute(ID, json!({"radius": 3})).unwrap_or_else(|e| panic!("{depth} {mode}: {e}"));
let d = doc(&s);
let (_, [low, high]) = group_of(d, source);
// The group alone rebuilds the source: hide the source so it can't show through.
let rebuilt = composite(&without(d, source));
let err = max_diff(&before, &rebuilt);
assert!(err <= tol, "{depth} {mode}: Low + High is off by {err} (tolerance {tol})");
assert!(max_diff(&composite(d), &rebuilt) <= 1e-6, "{depth} {mode}: the group covers the source");
// Low alone is visibly smoother than the source; High carries what it lost.
let low_only = composite(&without(&without(d, source), high));
assert!(max_diff(&before, &low_only) > 0.1, "{depth} {mode}: Low is blurred");
let h = d.layer(high).unwrap().surface().unwrap();
assert!((0..W).any(|x| (h.rgba(x, 5)[0] - 0.5).abs() > 0.05), "{depth} {mode}: High holds the detail");
assert_ne!(pixels(d, low), pixels(d, source), "{depth} {mode}");
}
}
/// Breaking the recipe breaks the identity: a High layer left in Normal mode covers the image
/// with grey (guards the test above against passing for the wrong reason).
#[test]
fn high_must_be_on_linear_light() {
let mut s = session(16, "rgb");
let source = active(&s);
let before = composite(doc(&s));
s.execute(ID, json!({})).unwrap();
let (_, [_, high]) = group_of(doc(&s), source);
let mut d = doc(&s).clone();
d.layer_mut(high).unwrap().blend = BlendMode::Normal;
assert!(max_diff(&before, &composite(&d)) > 0.1);
}
#[test]
fn builds_a_named_group_above_the_source_and_targets_high() {
let mut s = session(8, "rgb");
let source = active(&s);
let source_px = pixels(doc(&s), source);
let r = s.execute(ID, json!({})).unwrap();
let d = doc(&s);
let (group, [low, high]) = group_of(d, source);
let g = d.layer(group).unwrap();
assert_eq!(g.name, "Frequency Separation");
assert_eq!(g.blend, BlendMode::PassThrough);
let (l, h) = (d.layer(low).unwrap(), d.layer(high).unwrap());
assert_eq!((l.name.as_str(), l.blend), ("Low Frequency", BlendMode::Normal));
assert_eq!((h.name.as_str(), h.blend), ("High Frequency", BlendMode::LinearLight));
assert!(matches!(l.content, LayerContent::Raster(_)) && matches!(h.content, LayerContent::Raster(_)));
assert!(l.visible && h.visible && g.visible);
// High draws where the source does, not grey over the transparent rest of its tiles.
let bounds = |id: LayerId| d.layer(id).unwrap().surface().unwrap().content_bounds();
assert_eq!(bounds(source), Rect::new(0, 0, W, H));
assert_eq!(bounds(high), bounds(source));
// The source is left exactly as it was, visible, below the group.
let src = d.layer(source).unwrap();
assert_eq!((src.name.as_str(), src.visible), ("Skin", true));
assert_eq!(pixels(d, source), source_px);
assert_eq!(active(&s), high, "retouching starts on the High layer");
assert_eq!(r, json!({"group": group.0, "low": low.0, "high": high.0, "radius": 4.0}));
}
/// Low is exactly what Filter › Blur › Gaussian Blur makes of the source at that radius.
#[test]
fn low_is_the_gaussian_blur_of_the_source_at_the_radius() {
for radius in [2.0, 6.5] {
let mut s = session(16, "rgb");
let source = active(&s);
s.execute(ID, json!({"radius": radius})).unwrap();
let (_, [low, _]) = group_of(doc(&s), source);
let mut t = session(16, "rgb");
t.execute("filter.blur.gaussianBlur", json!({"radius": radius})).unwrap();
assert_eq!(pixels(doc(&s), low), pixels(doc(&t), active(&t)), "radius {radius}");
}
}
#[test]
fn one_undo_removes_everything_and_redo_brings_it_back() {
let mut s = session(8, "rgb");
let source = active(&s);
let before = doc(&s).clone();
let steps = s.active().unwrap().history.past_len();
s.execute(ID, json!({})).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
assert_eq!(s.active().unwrap().history.undo_label(), Some("Frequency Separation"));
let after = doc(&s).clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*doc(&s), before);
assert_eq!(active(&s), source);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(*doc(&s), after);
}
/// A Background source stays the Background; the copies are ordinary, unlocked layers.
#[test]
fn works_on_the_background() {
let mut s = Session::new();
s.execute("file.new", json!({"width": W, "height": H})).unwrap();
paint(&mut s);
let source = active(&s);
assert!(doc(&s).is_background(source));
let before = composite(doc(&s));
s.execute(ID, json!({"radius": 2})).unwrap();
let d = doc(&s);
assert!(d.is_background(source));
let (_, [low, high]) = group_of(d, source);
for id in [low, high] {
assert_eq!(d.layer(id).unwrap().locks, Default::default());
}
assert!(max_diff(&before, &composite(d)) <= 1.0 / 255.0 + 1e-6);
}
fn refused(s: &mut Session, why: &str) {
assert!(!s.is_enabled(ID), "{why}: enabled");
let before = s.active().map(|d| d.doc.clone());
let e = s.execute(ID, json!({})).expect_err(why);
assert!(!e.to_string().is_empty());
assert_eq!(s.active().map(|d| d.doc.clone()), before, "{why}: the document changed");
}
#[test]
fn needs_a_pixel_layer_with_pixels() {
refused(&mut Session::new(), "no document");
let mut s = session(8, "rgb");
s.execute("layer.new.layer", json!({})).unwrap();
refused(&mut s, "an empty layer");
let e = s.execute(ID, json!({})).unwrap_err().to_string();
assert!(e.contains("no pixels"), "{e}");
let mut s = session(8, "rgb");
s.execute("type.create", json!({"x": 4, "y": 20, "text": "Hi", "size": 12})).unwrap();
refused(&mut s, "a type layer");
let e = s.execute(ID, json!({})).unwrap_err().to_string();
assert!(e.contains("pixel layer"), "{e}");
let mut s = session(8, "rgb");
s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap();
refused(&mut s, "an adjustment layer");
let mut s = session(8, "rgb");
s.execute("layer.new.group", json!({})).unwrap();
refused(&mut s, "a group");
let mut s = session(8, "rgb");
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
refused(&mut s, "a smart object");
}
#[test]
fn refuses_a_channel_or_mask_target() {
let mut s = session(8, "rgb");
s.execute("select.all", json!({})).unwrap();
s.execute("select.saveSelection", json!({})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute("channel.target", json!({"channel": "Alpha 1"})).unwrap();
assert!(crate::channel_cmds::edits_channel(&s));
refused(&mut s, "an alpha channel");
let mut s = session(8, "rgb");
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.execute("channel.target", json!({"channel": "mask"})).unwrap();
assert_eq!(s.active().unwrap().channel_view.target, crate::channel_cmds::ChannelTarget::LayerMask);
refused(&mut s, "a targeted layer mask");
// The same layer, its pixels targeted again: allowed (the refusal was the target's).
s.execute("channel.target", json!({"channel": "composite"})).unwrap();
assert!(s.is_enabled(ID));
s.execute(ID, json!({})).unwrap();
// Showing the mask (⌥-click its thumbnail) targets it too.
let mut s = session(8, "rgb");
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.execute("channel.target", json!({"channel": "composite"})).unwrap();
assert!(s.is_enabled(ID));
s.execute("view.layerMask", json!({"mode": "gray"})).unwrap();
refused(&mut s, "a mask shown in mask view");
}
/// Linear Light over Low gives the source back in RGB and Grayscale only: CMYK and Lab blend
/// in other values (measured: off by 0.7 and 0.14), so they are refused rather than split.
#[test]
fn refuses_cmyk_and_lab() {
for mode in ["cmyk", "lab"] {
let mut s = session(8, mode);
refused(&mut s, mode);
let e = s.execute(ID, json!({})).unwrap_err().to_string();
assert!(e.contains("RGB or Grayscale"), "{mode}: {e}");
}
}
/// A layer whose tiles hold only transparency (not yet pruned) passes the menu's quick check
/// but has nothing to split: the command says so and changes nothing.
#[test]
fn a_layer_of_transparent_tiles_is_refused_when_run() {
let mut s = session(8, "rgb");
s.execute("layer.new.layer", json!({})).unwrap();
s.edit("clear", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
surf.write_region(Rect::new(0, 0, 8, 8), &vec![0.0; 8 * 8 * surf.channels()]);
Ok(())
})
.unwrap();
assert!(s.is_enabled(ID));
let before = doc(&s).clone();
let e = s.execute(ID, json!({})).unwrap_err().to_string();
assert!(e.contains("no pixels"), "{e}");
assert_eq!(*doc(&s), before);
}
#[test]
fn bad_params_are_rejected_without_changes() {
let mut s = session(8, "rgb");
let before = doc(&s).clone();
for p in [
json!(7),
json!("4"),
json!({"radius": 0}),
json!({"radius": 0.05}),
json!({"radius": -3}),
json!({"radius": 1000.5}),
json!({"radius": 1e308}),
json!({"radius": "4"}),
json!({"radius": null}),
json!({"radius": [4]}),
] {
assert!(matches!(s.execute(ID, p.clone()), Err(EngineError::BadParams { .. })), "{p}");
}
assert_eq!(*doc(&s), before);
}
}
@@ -386,22 +386,3 @@ fn stop_rejects_huge_indices_without_panicking() {
assert_eq!(midpoints, vec![0.25]); assert_eq!(midpoints, vec![0.25]);
assert_eq!(s.active().unwrap().history.past_len(), h + 1); assert_eq!(s.active().unwrap().history.past_len(), h + 1);
} }
#[test]
fn new_gradient_fill_layer_defaults_to_foreground_to_transparent() {
// #8708: Photoshop's Layer › New Fill Layer › Gradient… starts from Foreground to
// Transparent: the foreground at both ends, its opacity 100% to 0%. A `to` colour keeps a
// two-colour gradient.
let mut s = session(8, "transparent");
s.tools.foreground = [0.2, 0.4, 0.6, 1.0];
s.tools.background = [1.0, 1.0, 1.0, 1.0];
s.execute("layer.newFillLayer.gradient", json!({})).unwrap();
let Fill::Gradient { stops, opacity_stops, .. } = active_fill(&s) else { panic!("not a gradient") };
let fg = Color::rgba(0.2, 0.4, 0.6, 1.0);
assert_eq!(stops, vec![(0.0, fg), (1.0, fg)]);
assert_eq!(opacity_stops, vec![(0.0, 1.0), (1.0, 0.0)]);
s.execute("layer.newFillLayer.gradient", json!({"to": "#ff0000"})).unwrap();
let Fill::Gradient { stops, opacity_stops, .. } = active_fill(&s) else { panic!("not a gradient") };
assert_eq!(stops[1].1, Color::rgba(1.0, 0.0, 0.0, 1.0));
assert!(opacity_stops.is_empty());
}
+11 -32
View File
@@ -34,39 +34,26 @@ pub const MAX_ABR_FILE_BYTES: u64 = photocraft_psd::abr::MAX_TOTAL_BYTES as u64;
/// File bytes from `data` (base64) or `path`, plus a default group name. /// File bytes from `data` (base64) or `path`, plus a default group name.
pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec<u8>, String)> { pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec<u8>, String)> {
file_bytes_limited(p, cmd, what, MAX_FILE_BYTES) file_bytes_within(p, cmd, what, MAX_FILE_BYTES)
} }
/// Load preset bytes with a format-specific limit; `data` takes precedence over `path`. /// [`file_bytes`] with a size cap of `max` bytes.
/// Inline base64 returns an empty group name; a file returns its filename stem. fn file_bytes_within(p: &Value, cmd: &str, what: &str, max: u64) -> Result<(Vec<u8>, String)> {
/// The encoded-length check bounds base64 allocation, then decoded length is checked exactly.
/// File reads are capped at `max_bytes + 1` so growth after the metadata check is still rejected.
/// This helper does not authorize paths; automation callers must enforce their workspace policy.
pub(crate) fn file_bytes_limited(p: &Value, cmd: &str, what: &str, max_bytes: u64) -> Result<(Vec<u8>, String)> {
if let Some(d) = p.get("data") { if let Some(d) = p.get("data") {
let s = d.as_str().ok_or_else(|| bad(cmd, "`data` must be a base64 string"))?; let s = d.as_str().ok_or_else(|| bad(cmd, "`data` must be a base64 string"))?;
if s.len() as u64 > max_bytes / 3 * 4 + 4 { if s.len() as u64 > max / 3 * 4 + 4 {
return Err(bad(cmd, "`data` is too large")); return Err(bad(cmd, "`data` is too large"));
} }
let bytes = photocraft_paint::tile::b64_decode(s).ok_or_else(|| bad(cmd, "`data` is not valid base64"))?; let bytes = photocraft_paint::tile::b64_decode(s).ok_or_else(|| bad(cmd, "`data` is not valid base64"))?;
if bytes.len() as u64 > max_bytes {
return Err(bad(cmd, "`data` is too large"));
}
return Ok((bytes, String::new())); return Ok((bytes, String::new()));
} }
let path = let path =
p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, format!("give `path` ({what}) or `data` (base64)")))?; p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, format!("give `path` ({what}) or `data` (base64)")))?;
let len = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.len(); let len = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.len();
if len > max_bytes { if len > max {
return Err(bad(cmd, format!("{path}: file too large ({len} bytes)"))); return Err(bad(cmd, format!("{path}: file too large ({len} bytes)")));
} }
use std::io::Read; let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let file = std::fs::File::open(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mut bytes = Vec::new();
file.take(max_bytes.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if bytes.len() as u64 > max_bytes {
return Err(bad(cmd, format!("{path}: file too large")));
}
let stem = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); let stem = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
Ok((bytes, stem)) Ok((bytes, stem))
} }
@@ -114,7 +101,7 @@ fn import_abr(s: &mut Session, p: &Value) -> Result<Value> {
move |ctx| { move |ctx| {
ctx.progress(0.0, "Importing brushes"); ctx.progress(0.0, "Importing brushes");
let p = &params; let p = &params;
let (bytes, stem) = file_bytes_limited(p, cmd, ".abr file", MAX_ABR_FILE_BYTES)?; let (bytes, stem) = file_bytes_within(p, cmd, ".abr file", MAX_ABR_FILE_BYTES)?;
let group = match p.get("group") { let group = match p.get("group") {
Some(v) => v.as_str().map(str::trim).filter(|g| !g.is_empty()).ok_or_else(|| bad(cmd, "`group` must be a non-empty string"))?.to_string(), Some(v) => v.as_str().map(str::trim).filter(|g| !g.is_empty()).ok_or_else(|| bad(cmd, "`group` must be a non-empty string"))?.to_string(),
None if !stem.is_empty() => stem, None if !stem.is_empty() => stem,
@@ -385,14 +372,6 @@ fn import_grd(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn decoded_preset_data_cannot_exceed_its_format_budget() {
// Both payloads have four encoded characters: checking base64 length alone is insufficient.
let at_limit = serde_json::json!({"data": "AQI="});
let over_limit = serde_json::json!({"data": "AQID"});
assert_eq!(super::file_bytes_limited(&at_limit, "test", "preset", 2).unwrap().0, [1, 2]);
assert!(super::file_bytes_limited(&over_limit, "test", "preset", 2).is_err());
}
use super::*; use super::*;
use photocraft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v6, write_v12}; use photocraft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v6, write_v12};
use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value as DV}; use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value as DV};
@@ -638,16 +617,16 @@ mod tests {
let path = dir.join("cap.abr"); let path = dir.join("cap.abr");
std::fs::write(&path, [0u8; 10]).unwrap(); std::fs::write(&path, [0u8; 10]).unwrap();
let p = json!({"path": path.to_string_lossy()}); let p = json!({"path": path.to_string_lossy()});
assert!(file_bytes_limited(&p, "t", "f", 10).is_ok()); assert!(file_bytes_within(&p, "t", "f", 10).is_ok());
assert!(file_bytes_limited(&p, "t", "f", 9).is_err(), "a file over the cap is refused before reading"); assert!(file_bytes_within(&p, "t", "f", 9).is_err(), "a file over the cap is refused before reading");
// A sparse file between the two caps: refused as a .grd, which is checked before reading. // A sparse file between the two caps: refused as a .grd, which is checked before reading.
let big = dir.join("big.grd"); let big = dir.join("big.grd");
std::fs::File::create(&big).unwrap().set_len(MAX_FILE_BYTES + 1).unwrap(); std::fs::File::create(&big).unwrap().set_len(MAX_FILE_BYTES + 1).unwrap();
let err = Session::new().execute("gradient.presets.importGrd", json!({"path": big.to_string_lossy()})).unwrap_err(); let err = Session::new().execute("gradient.presets.importGrd", json!({"path": big.to_string_lossy()})).unwrap_err();
assert!(err.to_string().contains("too large"), "{err}"); assert!(err.to_string().contains("too large"), "{err}");
let data = json!({"data": "A".repeat(16)}); let data = json!({"data": "A".repeat(16)});
assert!(file_bytes_limited(&data, "t", "f", 12).is_ok()); assert!(file_bytes_within(&data, "t", "f", 12).is_ok());
assert!(file_bytes_limited(&data, "t", "f", 8).is_err()); assert!(file_bytes_within(&data, "t", "f", 8).is_err());
std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&dir).ok();
} }
+6 -3
View File
@@ -190,9 +190,12 @@ impl DabTool {
} }
"paint.blur" | "paint.sharpen" => { "paint.blur" | "paint.sharpen" => {
let sharpen = cmd == "paint.sharpen"; let sharpen = cmd == "paint.sharpen";
let sigma = if sharpen { (stroke.brush.size * 0.03).clamp(1.0, 4.0) } else { (stroke.brush.size * 0.15).clamp(1.0, 64.0) }; let tool = Self::Focus {
let tool = sharpen,
Self::Focus { sharpen, strength: num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0, protect: flag(p, "protectDetail", true), sigma }; strength: num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0,
protect: flag(p, "protectDetail", true),
sigma: (stroke.brush.size * 0.03).clamp(1.0, 4.0),
};
(tool, if sharpen { "Sharpen Tool" } else { "Blur Tool" }) (tool, if sharpen { "Sharpen Tool" } else { "Blur Tool" })
} }
"paint.smudge" => { "paint.smudge" => {
@@ -483,28 +483,6 @@ fn blur_reduces_local_variance_and_sharpen_increases_it() {
} }
} }
#[test]
fn blur_scales_with_brush_size_and_diffuses_pixels_visibly() {
for depth in [8u64, 16] {
let run = |size: u32| {
let mut s = session(120, 40, depth, "rgb");
paint_layer(&mut s, |x, _| if x < 60 { [0.0, 0.0, 0.0, 1.0] } else { [1.0, 1.0, 1.0, 1.0] });
s.execute("paint.blur", json!({"points": [[60, 20]], "size": size, "hardness": 100, "strength": 100})).unwrap();
(0..120).map(|x| rgba(&s, x, 20)[0]).collect::<Vec<_>>()
};
let p20 = run(20);
let p60 = run(60);
// Size 20 (sigma = 3.0, radius 10, footprint [50, 70]) leaves x=45 and x=75 untouched.
assert_eq!(p20[45], 0.0, "size 20 footprint does not reach x=45");
assert_eq!(p20[75], 1.0, "size 20 footprint does not reach x=75");
// Size 60 (sigma = 9.0, radius 30, footprint [30, 90]) diffuses light and dark across a wider band.
assert!(p60[45] > 0.0, "size 60 diffuses light past x=45: {}", p60[45]);
assert!(p60[75] < 1.0, "size 60 diffuses dark past x=75: {}", p60[75]);
// Near the step edge (x=55), size 60 diffuses more than size 20 due to larger sigma.
assert!(p60[55] > p20[55], "larger sigma diffuses deeper near edge: p60={} p20={}", p60[55], p20[55]);
}
}
#[test] #[test]
fn smudge_moves_colour_along_the_stroke() { fn smudge_moves_colour_along_the_stroke() {
for depth in DEPTHS { for depth in DEPTHS {
+1 -5
View File
@@ -479,10 +479,6 @@ pub fn apply_para_props(s: &mut ParagraphStyle, p: &Value) -> bool {
s.hyphenate = v; s.hyphenate = v;
any = true; any = true;
} }
if let Some(v) = p.get("hanging").and_then(Value::as_bool) {
s.hanging = v;
any = true;
}
any any
} }
@@ -1073,7 +1069,7 @@ pub fn specs() -> Vec<CommandSpec> {
menu: &[], menu: &[],
shortcut: None, shortcut: None,
params: Box::leak( params: Box::leak(
format!(r##"{{"layer":id? or "layers":[id,…]? (default selected Type layers),"range":[startChar,endChar]? (single layer; default all),"metricsAsShown":bool=false (size/leading/leadingBy include each layer's transform scale), {CHAR_PARAMS}, relative steps from each character's own value: "leadingBy":pt (auto leading starts from its laid-out value; result 0.01..=5000),"baselineShiftBy":pt (result -5000..=5000),"trackingBy":1/1000 em (result -1000..=10000), paragraph keys: "align":"left|center|right|justify|justifyCenter|justifyRight|justifyAll","firstLineIndent":pt,"startIndent":pt,"endIndent":pt,"spaceBefore":pt,"spaceAfter":pt,"autoLeading":%,"direction":"auto|ltr|rtl","hyphenate":bool,"hanging":bool}}"##) format!(r##"{{"layer":id? or "layers":[id,…]? (default selected Type layers),"range":[startChar,endChar]? (single layer; default all),"metricsAsShown":bool=false (size/leading/leadingBy include each layer's transform scale), {CHAR_PARAMS}, relative steps from each character's own value: "leadingBy":pt (auto leading starts from its laid-out value; result 0.01..=5000),"baselineShiftBy":pt (result -5000..=5000),"trackingBy":1/1000 em (result -1000..=10000), paragraph keys: "align":"left|center|right|justify|justifyCenter|justifyRight|justifyAll","firstLineIndent":pt,"startIndent":pt,"endIndent":pt,"spaceBefore":pt,"spaceAfter":pt,"autoLeading":%,"direction":"auto|ltr|rtl","hyphenate":bool}}"##)
.into_boxed_str(), .into_boxed_str(),
), ),
enabled: has_doc, enabled: has_doc,
-16
View File
@@ -404,22 +404,6 @@ fn rasterize_and_fonts() {
assert!(!faces["faces"].as_array().unwrap().is_empty()); assert!(!faces["faces"].as_array().unwrap().is_empty());
} }
#[test]
fn set_style_paragraph_hanging_round_trips() {
let mut s = session();
let id = s.execute("type.create", json!({"x": 10, "y": 10, "text": "Hello, world."})).unwrap()["layer"].as_u64().unwrap();
let info = s.execute("type.setStyle", json!({"layer": id, "hanging": true})).unwrap();
assert_eq!(info["paragraphs"][0]["style"]["hanging"], true, "the report exposes the flag");
let t = text_layer(&s, id);
assert!(t.paragraphs[0].style.hanging);
// Off again.
let info = s.execute("type.setStyle", json!({"layer": id, "hanging": false})).unwrap();
assert_eq!(info["paragraphs"][0]["style"]["hanging"], false);
// A junk value is ignored (like a junk `align`), not coerced or a crash.
let _ = s.execute("type.setStyle", json!({"layer": id, "hanging": "yes"})).unwrap();
assert!(!text_layer(&s, id).paragraphs[0].style.hanging);
}
/// #124: a type edit damages only the layer's old and new pixels (the canvas recomposites /// #124: a type edit damages only the layer's old and new pixels (the canvas recomposites
/// that, not the document), and a drag's coalesced steps are one history step. /// that, not the document), and a drag's coalesced steps are one history step.
#[test] #[test]
@@ -128,7 +128,6 @@ fn perturbed_para() -> ParagraphStyle {
auto_leading: -1.0, auto_leading: -1.0,
direction: TextDirection::Rtl, direction: TextDirection::Rtl,
hyphenate: true, hyphenate: true,
hanging: true,
style_sheet: None, style_sheet: None,
} }
} }
+2 -2
View File
@@ -70,7 +70,7 @@ mod shape;
mod vector_mask; mod vector_mask;
pub(crate) use params::{layer_id, nums, op_from, pt}; pub(crate) use params::{layer_id, nums, op_from, pt};
pub use params::{parse_path, parse_stroke, path_json, shape_fill, shape_paint_params}; pub use params::{parse_path, parse_stroke, path_json};
pub use paths::STROKE_PATH_TOOLS; pub use paths::STROKE_PATH_TOOLS;
use paths::{ use paths::{
clipping_path_clear, clipping_path_set, path_delete, path_fill, path_rename, path_set, path_stroke, path_to_selection, path_transform, paths_list, clipping_path_clear, clipping_path_set, path_delete, path_fill, path_rename, path_set, path_stroke, path_to_selection, path_transform, paths_list,
@@ -91,7 +91,7 @@ pub(crate) fn leak(s: String) -> &'static str {
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
let stroke = r##""stroke":{"width":px,"color":"#rrggbb"|fill,"opacity":0..100,"align":"inside|center|outside","cap":"butt|round|square","join":"miter|round|bevel","miterLimit":n,"dashes":[multiples of width],"dashOffset":n}|null"##; let stroke = r##""stroke":{"width":px,"color":"#rrggbb"|fill,"opacity":0..100,"align":"inside|center|outside","cap":"butt|round|square","join":"miter|round|bevel","miterLimit":n,"dashes":[multiples of width],"dashOffset":n}|null"##;
let fill = r##""fill":"#rrggbb[aa]"|[r,g,b,a]|{"color":{mode,c:[4 channels],alpha}}|{"gradient":{"stops":[[0..1,colour]],"angle":deg,"scale":%>0,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"opacityStops":[[0..1,0..1]],"midpoints":[0..1],"offset":[fractionX,fractionY],"dither":bool,"align":bool}}|{"pattern":name|id,"id":str?,"scale":%>0,"angle":deg,"link":bool,"phase":[px,px]}|null (no fill)"##; let fill = r##""fill":"#rrggbb"|[r,g,b,a]|{"gradient":{"stops":[[t,"#hex"|[r,g,b,a]|stored Color]],"angle":deg,"scale":%,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"opacityStops":[[t,0..1]]?,"midpoints":[0..1]?,"offset":[x,y]?,"dither":bool?,"align":bool?}}|{"pattern":name}|null (no fill)"##;
vec![ vec![
spec!( spec!(
"shape.create", "shape.create",
+55 -191
View File
@@ -10,18 +10,6 @@ pub(crate) fn layer_id(s: &Session, p: &Value) -> Result<LayerId> {
} }
} }
pub(super) fn check_clipboard_pattern_collision(s: &Session, patterns: &[photocraft_doc::Pattern]) -> Result<()> {
let st = s.active().ok_or(EngineError::NoDocument)?;
for pattern in patterns {
if let Some(existing) = st.doc.patterns.iter().find(|p| p.id == pattern.id)
&& (existing.width != pattern.width || existing.height != pattern.height || existing.surface != pattern.surface)
{
return Err(EngineError::Other("A different pattern with the copied pattern's id already exists in this document".into()));
}
}
Ok(())
}
pub(super) fn f64p(p: &Value, k: &str) -> Option<f64> { pub(super) fn f64p(p: &Value, k: &str) -> Option<f64> {
p.get(k).and_then(Value::as_f64) p.get(k).and_then(Value::as_f64)
} }
@@ -157,44 +145,12 @@ pub fn path_json(p: &Path) -> Value {
pub(super) fn fill_json(f: &Fill) -> Value { pub(super) fn fill_json(f: &Fill) -> Value {
match f { match f {
Fill::Solid(c) => paint_color_json(c), Fill::Solid(c) => json!(hex(c)),
Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align } => json!({ "gradient": { Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align } => json!({ "gradient": {
"stops": stops.iter().map(|(t, c)| json!([t, paint_color_json(c)])).collect::<Vec<_>>(), "stops": stops.iter().map(|(t, c)| json!([t, hex(c)])).collect::<Vec<_>>(),
"angle": angle, "scale": f64::from(*scale) * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse, "angle": angle, "scale": scale * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse,
"opacityStops": opacity_stops, "midpoints": midpoints, "offset": offset, "dither": dither, "align": align } }), "opacityStops": opacity_stops, "midpoints": midpoints, "offset": [offset.0, offset.1], "dither": dither, "align": align } }),
Fill::Pattern { name, scale, id, angle, link, phase } => json!({ "pattern": name, "scale": f64::from(*scale) * 100.0, Fill::Pattern { name, scale, .. } => json!({ "pattern": name, "scale": scale * 100.0 }),
"id":id, "angle":angle, "link":link, "phase":phase }),
}
}
fn fill_pair(v: &Value, key: &str) -> std::result::Result<(f32, f32), String> {
let Some(value) = v.get(key) else { return Ok((0.0, 0.0)) };
let Some(values) = value.as_array().filter(|a| a.len() == 2) else { return Err(format!("{key} must contain two numbers")) };
let mut values = values.iter();
let a = values.next().ok_or_else(|| format!("{key} needs an x value"))?;
let b = values.next().ok_or_else(|| format!("{key} needs a y value"))?;
Ok((fill_number(a, key, -1_000_000.0, 1_000_000.0)?, fill_number(b, key, -1_000_000.0, 1_000_000.0)?))
}
fn fill_bool(v: &Value, key: &str, default: bool) -> std::result::Result<bool, String> {
v.get(key).map_or(Ok(default), |v| v.as_bool().ok_or_else(|| format!("{key} must be a boolean")))
}
fn fill_number(v: &Value, label: &str, lo: f64, hi: f64) -> std::result::Result<f32, String> {
v.as_f64()
.filter(|n| n.is_finite() && (lo..=hi).contains(n))
.map(|n| n as f32)
.ok_or_else(|| format!("{label} must be a finite number between {lo} and {hi}"))
}
fn paint_color_json(c: &Color) -> Value {
if c.mode != photocraft_color::ColorMode::Rgb || c.c[3] != 0.0 {
return json!({"color":c});
}
if c.alpha == 1.0 && c.c.iter().take(3).all(|v| (0.0..=1.0).contains(v) && (v * 255.0).round() / 255.0 == *v) {
json!(hex(c))
} else {
json!([c.c[0], c.c[1], c.c[2], c.alpha])
} }
} }
@@ -202,168 +158,85 @@ fn paint_color_json(c: &Color) -> Value {
/// edits (`gradient.fill.stop`, `gradient.presets.stop`) cap them. /// edits (`gradient.fill.stop`, `gradient.presets.stop`) cap them.
const MAX_GRADIENT_STOPS: usize = 1024; const MAX_GRADIENT_STOPS: usize = 1024;
/// Legacy colour/gradient/pattern forms plus complete geometry and opacity metadata. /// `"#rrggbb"` / `[r,g,b,a]` / `{"gradient":{"stops":[[t,"#hex"|[r,g,b,a]],…],"angle","scale","style","reverse",
/// Gradient locations, opacity stops and midpoints are fractions; scale is percent. /// "opacityStops":[[t,0..1],…],"midpoints":[0..1,…],"offset":[x,y],"dither","align"}}` / `{"pattern":name,"scale"}`.
/// `null` → `Ok(None)` (no fill).
pub(super) fn parse_fill(v: &Value) -> std::result::Result<Option<Fill>, String> { pub(super) fn parse_fill(v: &Value) -> std::result::Result<Option<Fill>, String> {
if let Some(g) = v.get("gradient") {
for key in ["stops", "opacityStops", "midpoints"] {
if g.get(key).and_then(Value::as_array).is_some_and(|a| a.len() > MAX_GRADIENT_STOPS) {
return Err(format!("too many gradient {key} (max {MAX_GRADIENT_STOPS})"));
}
}
}
if v.is_null() { if v.is_null() {
return Ok(None); return Ok(None);
} }
if v.is_array() || v.is_string() || v.get("color").is_some() || (v.get("mode").is_some() && v.get("c").is_some()) { if let Some(c) = color(v) {
return Ok(Some(Fill::Solid(paint_color(v)?))); return Ok(Some(Fill::Solid(c)));
} }
if let Some(g) = v.get("gradient") { if let Some(g) = v.get("gradient") {
if !g.is_object() { // Stop lists are capped as `gradient.fill.stop` caps them: more is an error, not a cut.
return Err("gradient must be an object".into()); let list = |key: &str, what: &str| match g.get(key) {
} None | Some(Value::Null) => Ok(None),
let stops = match g.get("stops") { Some(Value::Array(a)) if a.len() > MAX_GRADIENT_STOPS => Err(format!("too many {what} (max {MAX_GRADIENT_STOPS})")),
None => vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], Some(Value::Array(a)) => Ok(Some(a)),
Some(Value::Array(stops)) if stops.len() <= 4096 => stops Some(_) => Err(format!("gradient `{key}` must be an array")),
.iter()
.map(|stop| {
let values = stop.as_array().filter(|a| a.len() == 2).ok_or("gradient stop must contain a location and colour")?;
let t = values.first().ok_or("missing stop location")?;
let c = values.get(1).ok_or("missing stop colour")?;
let location = fill_number(t, "stop location", 0.0, 1.0)?;
Ok(paint_color(c).ok().map(|color| (location, color)))
})
.collect::<std::result::Result<Vec<_>, String>>()?
.into_iter()
.flatten()
.collect(),
_ => return Err("gradient stops must contain at most 4096 location/colour pairs".into()),
}; };
let style = match g.get("style").map_or(Some("linear"), Value::as_str).ok_or("gradient style must be a string")? { let stops: Vec<(f32, Color)> = list("stops", "colour stops")?
"linear" => GradientStyle::Linear, .map(|a| a.iter().filter_map(|s| Some((s.get(0)?.as_f64()? as f32, color(s.get(1)?)?))).collect())
.unwrap_or_else(|| vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]);
let style = match g.get("style").and_then(Value::as_str).unwrap_or("linear") {
"radial" => GradientStyle::Radial, "radial" => GradientStyle::Radial,
"angle" => GradientStyle::Angle, "angle" => GradientStyle::Angle,
"reflected" => GradientStyle::Reflected, "reflected" => GradientStyle::Reflected,
"diamond" => GradientStyle::Diamond, "diamond" => GradientStyle::Diamond,
_ => return Err("unknown gradient style".into()), _ => GradientStyle::Linear,
}; };
let mut opacity_stops = match g.get("opacityStops") { let mut fill = Fill::gradient(
None => Vec::new(),
Some(Value::Array(stops)) if stops.len() <= 4096 => stops
.iter()
.map(|stop| {
let values = stop.as_array().filter(|a| a.len() == 2).ok_or("opacity stop must contain a location and opacity")?;
Ok((
fill_number(values.first().ok_or("missing opacity location")?, "opacity location", 0.0, 1.0)?,
fill_number(values.get(1).ok_or("missing opacity value")?, "opacity value", 0.0, 1.0)?,
))
})
.collect::<std::result::Result<Vec<_>, String>>()?,
_ => return Err("opacityStops must contain at most 4096 location/opacity pairs".into()),
};
opacity_stops.sort_by(|a, b| a.0.total_cmp(&b.0));
let midpoints = match g.get("midpoints") {
None => Vec::new(),
Some(Value::Array(values)) if values.len() <= 4096 => {
values.iter().map(|v| fill_number(v, "midpoint", 0.0, 1.0)).collect::<std::result::Result<Vec<_>, _>>()?
}
_ => return Err("midpoints must contain at most 4096 fractions".into()),
};
return Ok(Some(Fill::Gradient {
stops, stops,
angle: g.get("angle").map_or(Ok(90.0), |v| fill_number(v, "angle", -1_000_000.0, 1_000_000.0))?, f64p(g, "angle").unwrap_or(90.0) as f32,
scale: fill_scale(g)?, f64p(g, "scale").unwrap_or(100.0) as f32 / 100.0,
style, style,
reverse: fill_bool(g, "reverse", false)?, g.get("reverse").and_then(Value::as_bool).unwrap_or(false),
opacity_stops, );
midpoints, // The rest of a gradient, so a read-modify-write (the shape swatches) loses nothing.
offset: fill_pair(g, "offset")?, if let Fill::Gradient { opacity_stops, midpoints, offset, dither, align, .. } = &mut fill {
dither: fill_bool(g, "dither", false)?, let unit = |v: &Value| v.as_f64().map(|n| n.clamp(0.0, 1.0) as f32);
align: fill_bool(g, "align", true)?, if let Some(a) = list("opacityStops", "opacity stops")? {
})); *opacity_stops = a.iter().filter_map(|s| Some((unit(s.get(0)?)?, unit(s.get(1)?)?))).collect();
} opacity_stops.sort_by(|a, b| a.0.total_cmp(&b.0));
if let Some(pattern) = v.get("pattern") {
let name = pattern.as_str().filter(|s| s.len() <= 65536).ok_or("pattern must be a string of at most 65536 bytes")?;
let id = v.get("id").map_or(Some(""), Value::as_str).filter(|s| s.len() <= 65536).ok_or("pattern id must be a string of at most 65536 bytes")?;
return Ok(Some(Fill::Pattern {
name: name.into(),
scale: fill_scale(v)?,
id: id.into(),
angle: v.get("angle").map_or(Ok(0.0), |v| fill_number(v, "angle", -1_000_000.0, 1_000_000.0))?,
link: fill_bool(v, "link", true)?,
phase: fill_pair(v, "phase")?,
}));
}
Err("unrecognised fill".into())
}
fn paint_color(v: &Value) -> std::result::Result<Color, String> {
let c = if let Some(raw) = v.get("color").or_else(|| (v.get("mode").is_some() && v.get("c").is_some()).then_some(v)) {
serde_json::from_value::<Color>(raw.clone()).map_err(|e| format!("invalid model colour: {e}"))?
} else {
if let Some(values) = v.as_array() {
if !matches!(values.len(), 3 | 4) {
return Err("colour must contain three or four numbers".into());
} }
for value in values.iter().take(3) { if let Some(a) = list("midpoints", "midpoints")? {
fill_number(value, "colour component", -1_000_000.0, 1_000_000.0)?; *midpoints = a.iter().filter_map(unit).collect();
} }
if let Some(alpha) = values.get(3) { if let Some(o) = g.get("offset") {
fill_number(alpha, "colour alpha", 0.0, 1.0)?; let at = |i: usize| o.get(i).and_then(Value::as_f64).map(|n| n.clamp(-1000.0, 1000.0) as f32);
*offset = (at(0).ok_or("gradient offset must be [x, y]")?, at(1).ok_or("gradient offset must be [x, y]")?);
}
if let Some(d) = g.get("dither").and_then(Value::as_bool) {
*dither = d;
}
if let Some(a) = g.get("align").and_then(Value::as_bool) {
*align = a;
} }
} }
color(v).ok_or("colour must be hex, RGB(A) numbers or a model colour object")? return Ok(Some(fill));
};
if !c.c.iter().all(|n| n.is_finite() && n.abs() <= 1_000_000.0) || !c.alpha.is_finite() || !(0.0..=1.0).contains(&c.alpha) {
return Err("colour components must be finite and within ±1000000; alpha must be 0..1".into());
} }
Ok(c) if let Some(name) = v.get("pattern").and_then(Value::as_str) {
} return Ok(Some(Fill::Pattern {
name: name.into(),
fn fill_scale(v: &Value) -> std::result::Result<f32, String> { scale: f64p(v, "scale").unwrap_or(100.0) as f32 / 100.0,
let Some(value) = v.get("scale") else { return Ok(1.0) }; id: String::new(),
let n = value.as_f64().filter(|n| n.is_finite() && *n > 0.0 && *n <= 1_000_000.0).ok_or("scale must be greater than 0 and at most 1000000 percent")?; angle: 0.0,
let scale = (n / 100.0) as f32; link: true,
if scale <= 0.0 { phase: (0.0, 0.0),
return Err("scale is too small".into()); }));
} }
Ok(scale) Err(format!("unrecognised fill {v}"))
} }
pub(super) fn stroke_json(s: &ShapeStroke) -> Value { pub(super) fn stroke_json(s: &ShapeStroke) -> Value {
json!({ json!({
"width": s.width, "fill": fill_json(&s.paint), "opacity": f64::from(s.opacity) * 100.0, "width": s.width, "fill": fill_json(&s.paint), "opacity": s.opacity * 100.0,
"align": format!("{:?}", s.align).to_ascii_lowercase(), "cap": format!("{:?}", s.cap).to_ascii_lowercase(), "align": format!("{:?}", s.align).to_ascii_lowercase(), "cap": format!("{:?}", s.cap).to_ascii_lowercase(),
"join": format!("{:?}", s.join).to_ascii_lowercase(), "miterLimit": s.miter_limit, "dashes": s.dashes, "dashOffset": s.dash_offset, "join": format!("{:?}", s.join).to_ascii_lowercase(), "miterLimit": s.miter_limit, "dashes": s.dashes, "dashOffset": s.dash_offset,
}) })
} }
pub(super) fn pattern_for_fill(s: &Session, fill: &mut Fill) -> Result<Option<photocraft_doc::Pattern>> {
let Fill::Pattern { name, id, .. } = fill else { return Ok(None) };
let by_id = (!id.is_empty()).then(|| crate::pattern_cmds::resolve(s, id)).flatten();
let by_name = (!name.is_empty()).then(|| crate::pattern_cmds::resolve(s, name)).flatten();
if let (Some(a), Some(b)) = (&by_id, &by_name)
&& a.id != b.id
{
return Err(EngineError::Other("pattern id and name refer to different tiles".into()));
}
let pattern = by_id.or(by_name).ok_or_else(|| EngineError::Other(format!("no pattern \"{name}\" (see pattern.list)")))?;
if pattern.is_empty() {
return Err(EngineError::Other("the pattern is empty".into()));
}
if id.is_empty() {
*name = pattern.name.clone();
}
*id = pattern.id.clone();
Ok(Some(pattern))
}
/// Shape-tool creation uses the paint displayed for the active shape, including gradients.
pub fn shape_paint_params(fill: Option<&Fill>, stroke: Option<&ShapeStroke>) -> Value {
json!({"fill":fill.map_or(Value::Null,fill_json),"stroke":stroke.map_or(Value::Null,stroke_json)})
}
/// Applies stroke keys onto `base` (`null` → no stroke). /// Applies stroke keys onto `base` (`null` → no stroke).
pub fn parse_stroke(v: &Value, base: Option<ShapeStroke>, fg: Color) -> std::result::Result<Option<ShapeStroke>, String> { pub fn parse_stroke(v: &Value, base: Option<ShapeStroke>, fg: Color) -> std::result::Result<Option<ShapeStroke>, String> {
if v.is_null() || v.as_bool() == Some(false) { if v.is_null() || v.as_bool() == Some(false) {
@@ -443,15 +316,6 @@ pub fn parse_stroke(v: &Value, base: Option<ShapeStroke>, fg: Color) -> std::res
Ok(Some(s)) Ok(Some(s))
} }
/// Validate a shape paint without changing the document, resolving catalog pattern references.
pub fn shape_fill(s: &Session, value: &Value) -> Result<Option<Fill>> {
let mut fill = parse_fill(value).map_err(EngineError::Other)?;
if let Some(fill) = &mut fill {
pattern_for_fill(s, fill)?;
}
Ok(fill)
}
pub(super) fn live_json(l: &LiveShape) -> Value { pub(super) fn live_json(l: &LiveShape) -> Value {
serde_json::to_value(l).unwrap_or(Value::Null) serde_json::to_value(l).unwrap_or(Value::Null)
} }
+15 -91
View File
@@ -1,6 +1,5 @@
use super::params::{ use super::params::{
check_clipboard_pattern_collision, f64p, fill_json, layer_id, live_from_params, live_json, nums, op_from, parse_fill, parse_path, parse_stroke, path_json, f64p, fill_json, layer_id, live_from_params, live_json, nums, op_from, parse_fill, parse_path, parse_stroke, path_json, radii, stroke_json,
pattern_for_fill, radii, shape_fill, stroke_json,
}; };
use super::vector_mask::with; use super::vector_mask::with;
use super::{ use super::{
@@ -37,31 +36,21 @@ pub(super) fn can_paste_shape_stroke(s: &Session) -> std::result::Result<(), Str
} }
pub(super) fn copy_shape_fill(s: &mut Session, _p: &Value) -> Result<Value> { pub(super) fn copy_shape_fill(s: &mut Session, _p: &Value) -> Result<Value> {
let mut fill = active_shape(s).map_err(EngineError::Other)?.fill.clone().ok_or_else(|| EngineError::Other("shape has no fill".into()))?; let fill = active_shape(s).map_err(EngineError::Other)?.fill.clone().ok_or_else(|| EngineError::Other("shape has no fill".into()))?;
let pattern = clipboard_pattern(s, &fill, None)?;
set_clipboard_pattern_id(&mut fill, pattern.as_ref());
s.path_fill_clipboard = Some(fill); s.path_fill_clipboard = Some(fill);
s.path_fill_pattern = pattern;
Ok(Value::Null) Ok(Value::Null)
} }
pub(super) fn copy_shape_stroke(s: &mut Session, _p: &Value) -> Result<Value> { pub(super) fn copy_shape_stroke(s: &mut Session, _p: &Value) -> Result<Value> {
let mut stroke = active_shape(s).map_err(EngineError::Other)?.stroke.clone().ok_or_else(|| EngineError::Other("shape has no stroke".into()))?; let stroke = active_shape(s).map_err(EngineError::Other)?.stroke.clone().ok_or_else(|| EngineError::Other("shape has no stroke".into()))?;
let pattern = clipboard_pattern(s, &stroke.paint, None)?;
set_clipboard_pattern_id(&mut stroke.paint, pattern.as_ref());
s.path_stroke_clipboard = Some(stroke); s.path_stroke_clipboard = Some(stroke);
s.path_stroke_pattern = pattern;
Ok(Value::Null) Ok(Value::Null)
} }
pub(super) fn paste_shape_fill(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn paste_shape_fill(s: &mut Session, p: &Value) -> Result<Value> {
let mut fill = s.path_fill_clipboard.clone().ok_or_else(|| EngineError::Other("no shape fill copied".into()))?; let fill = s.path_fill_clipboard.clone().ok_or_else(|| EngineError::Other("no shape fill copied".into()))?;
let pattern = clipboard_pattern(s, &fill, s.path_fill_pattern.as_ref())?;
set_clipboard_pattern_id(&mut fill, pattern.as_ref());
let patterns: Vec<_> = pattern.into_iter().collect();
check_clipboard_pattern_collision(s, &patterns)?;
let id = layer_id(s, p)?; let id = layer_id(s, p)?;
with_shape_patterns(s, id, "Paste Fill", &patterns, |shape, _| { with_shape(s, id, "Paste Fill", |shape, _| {
shape.fill = Some(fill); shape.fill = Some(fill);
Ok(()) Ok(())
})?; })?;
@@ -69,39 +58,15 @@ pub(super) fn paste_shape_fill(s: &mut Session, p: &Value) -> Result<Value> {
} }
pub(super) fn paste_shape_stroke(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn paste_shape_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let mut stroke = s.path_stroke_clipboard.clone().ok_or_else(|| EngineError::Other("no shape stroke copied".into()))?; let stroke = s.path_stroke_clipboard.clone().ok_or_else(|| EngineError::Other("no shape stroke copied".into()))?;
let pattern = clipboard_pattern(s, &stroke.paint, s.path_stroke_pattern.as_ref())?;
set_clipboard_pattern_id(&mut stroke.paint, pattern.as_ref());
let patterns: Vec<_> = pattern.into_iter().collect();
check_clipboard_pattern_collision(s, &patterns)?;
let id = layer_id(s, p)?; let id = layer_id(s, p)?;
with_shape_patterns(s, id, "Paste Complete Stroke", &patterns, |shape, _| { with_shape(s, id, "Paste Complete Stroke", |shape, _| {
shape.stroke = Some(stroke); shape.stroke = Some(stroke);
Ok(()) Ok(())
})?; })?;
shape_info(s, id) shape_info(s, id)
} }
fn clipboard_pattern(s: &Session, fill: &Fill, snapshot: Option<&photocraft_doc::Pattern>) -> Result<Option<photocraft_doc::Pattern>> {
let Fill::Pattern { id, name, .. } = fill else { return Ok(None) };
let find = |patterns| photocraft_doc::pattern::find(patterns, id, name).cloned();
let pattern = snapshot
.and_then(|p| find(std::slice::from_ref(p)))
.or_else(|| s.active().and_then(|st| find(&st.doc.patterns)))
.or_else(|| crate::pattern_cmds::resolve(s, id).or_else(|| crate::pattern_cmds::resolve(s, name)))
.ok_or_else(|| EngineError::Other(format!("no copied pattern \"{name}\" (see pattern.list)")))?;
if pattern.is_empty() {
return Err(EngineError::Other("the copied pattern is empty".into()));
}
Ok(Some(pattern))
}
fn set_clipboard_pattern_id(fill: &mut Fill, pattern: Option<&photocraft_doc::Pattern>) {
if let (Fill::Pattern { id, .. }, Some(pattern)) = (fill, pattern) {
*id = pattern.id.clone();
}
}
/// The path `shape.create` draws for live-shape params `p` (the Shape tools preview it while dragging). /// The path `shape.create` draws for live-shape params `p` (the Shape tools preview it while dragging).
pub fn shape_path(p: &Value) -> Result<Path> { pub fn shape_path(p: &Value) -> Result<Path> {
let kind = p.get("kind").and_then(Value::as_str).unwrap_or("rect"); let kind = p.get("kind").and_then(Value::as_str).unwrap_or("rect");
@@ -121,7 +86,7 @@ fn live_kind(l: &LiveShape) -> &'static str {
/// Re-renders a shape layer's cache for `doc` (canvas-clipped, document pixel format). /// Re-renders a shape layer's cache for `doc` (canvas-clipped, document pixel format).
pub fn refresh_shape(doc: &Document, sh: &mut ShapeLayer) { pub fn refresh_shape(doc: &Document, sh: &mut ShapeLayer) {
sh.cache = Some(vector::render_shape_with_patterns(sh, doc.pixel_format(), doc.bounds(), &doc.patterns)); sh.cache = Some(vector::render_shape(sh, doc.pixel_format(), doc.bounds()));
} }
/// A staged stroke edit rendered with the same validation and cache refresh as `shape.edit`. /// A staged stroke edit rendered with the same validation and cache refresh as `shape.edit`.
@@ -298,16 +263,6 @@ pub(super) fn shape_info(s: &Session, id: LayerId) -> Result<Value> {
} }
pub(crate) fn with_shape<R>(s: &mut Session, id: LayerId, label: &str, f: impl FnOnce(&mut ShapeLayer, &mut Layer) -> Result<R>) -> Result<R> { pub(crate) fn with_shape<R>(s: &mut Session, id: LayerId, label: &str, f: impl FnOnce(&mut ShapeLayer, &mut Layer) -> Result<R>) -> Result<R> {
with_shape_patterns(s, id, label, &[], f)
}
fn with_shape_patterns<R>(
s: &mut Session,
id: LayerId,
label: &str,
patterns: &[photocraft_doc::Pattern],
f: impl FnOnce(&mut ShapeLayer, &mut Layer) -> Result<R>,
) -> Result<R> {
if let Some(st) = s.active() { if let Some(st) = s.active() {
let locks = st.doc.effective_locks(id); let locks = st.doc.effective_locks(id);
if locks.all || locks.pixels { if locks.all || locks.pixels {
@@ -315,9 +270,6 @@ fn with_shape_patterns<R>(
} }
} }
s.edit(label, |doc, _| { s.edit(label, |doc, _| {
for pattern in patterns {
crate::pattern_cmds::ensure_in_doc(doc, pattern);
}
let snapshot = doc.clone(); let snapshot = doc.clone();
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if !matches!(l.content, LayerContent::Shape(_)) { if !matches!(l.content, LayerContent::Shape(_)) {
@@ -359,25 +311,14 @@ pub(super) fn shape_create(s: &mut Session, p: &Value) -> Result<Value> {
}; };
let fg = s.tools.foreground; let fg = s.tools.foreground;
let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]); let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]);
let mut fill = match p.get("fill") { let fill = match p.get("fill") {
Some(v) => parse_fill(v).map_err(|e| bad(CMD, e))?, Some(v) => parse_fill(v).map_err(|e| bad(CMD, e))?,
None => Some(Fill::Solid(fgc)), None => Some(Fill::Solid(fgc)),
}; };
let mut stroke = match p.get("stroke") { let stroke = match p.get("stroke") {
Some(v) => parse_stroke(v, None, fgc).map_err(|e| bad(CMD, e))?, Some(v) => parse_stroke(v, None, fgc).map_err(|e| bad(CMD, e))?,
None => None, None => None,
}; };
let mut patterns = Vec::new();
if let Some(fill) = &mut fill
&& let Some(pattern) = pattern_for_fill(s, fill)?
{
patterns.push(pattern);
}
if let Some(stroke) = &mut stroke
&& let Some(pattern) = pattern_for_fill(s, &mut stroke.paint)?
{
patterns.push(pattern);
}
vector::flatten::validate_shape(&path, stroke.as_ref()).map_err(|e| bad(CMD, e))?; vector::flatten::validate_shape(&path, stroke.as_ref()).map_err(|e| bad(CMD, e))?;
// Add to an existing shape layer with a path operation (Shape tool in combine/subtract… mode). // Add to an existing shape layer with a path operation (Shape tool in combine/subtract… mode).
if let Some(target) = p.get("addTo").and_then(Value::as_u64) { if let Some(target) = p.get("addTo").and_then(Value::as_u64) {
@@ -398,9 +339,6 @@ pub(super) fn shape_create(s: &mut Session, p: &Value) -> Result<Value> {
let name = p.get("name").and_then(Value::as_str).map(str::to_string); let name = p.get("name").and_then(Value::as_str).map(str::to_string);
let mut sh = ShapeLayer { path, fill, stroke, live, ..Default::default() }; let mut sh = ShapeLayer { path, fill, stroke, live, ..Default::default() };
let id = s.edit("New Shape Layer", |doc, active| { let id = s.edit("New Shape Layer", |doc, active| {
for pattern in &patterns {
crate::pattern_cmds::ensure_in_doc(doc, pattern);
}
refresh_shape(doc, &mut sh); refresh_shape(doc, &mut sh);
let name = name.unwrap_or_else(|| doc.next_layer_name(default_name(kind))); let name = name.unwrap_or_else(|| doc.next_layer_name(default_name(kind)));
let id = crate::layer_place::insert_new(doc, *active, Layer::new(name, LayerContent::Shape(sh)), crate::layer_place::Place::AutoEditable); let id = crate::layer_place::insert_new(doc, *active, Layer::new(name, LayerContent::Shape(sh)), crate::layer_place::Place::AutoEditable);
@@ -416,21 +354,7 @@ pub(super) fn shape_edit(s: &mut Session, p: &Value) -> Result<Value> {
let fg = s.tools.foreground; let fg = s.tools.foreground;
let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]); let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]);
let name = p.get("name").and_then(Value::as_str).map(str::to_string); let name = p.get("name").and_then(Value::as_str).map(str::to_string);
let fill = p.get("fill").map(|value| shape_fill(s, value)).transpose()?; with_shape(s, id, "Edit Shape", |sh, layer| {
let base = s.active().and_then(|st| st.doc.layer(id)).and_then(|l| if let LayerContent::Shape(sh) = &l.content { sh.stroke.clone() } else { None });
let mut stroke = p.get("stroke").map(|value| parse_stroke(value, base, fgc).map_err(|e| bad(CMD, e))).transpose()?;
let mut patterns = Vec::new();
if let Some(Some(mut fill)) = fill.clone()
&& let Some(pattern) = pattern_for_fill(s, &mut fill)?
{
patterns.push(pattern);
}
if let Some(Some(stroke)) = &mut stroke
&& let Some(pattern) = pattern_for_fill(s, &mut stroke.paint)?
{
patterns.push(pattern);
}
with_shape_patterns(s, id, "Edit Shape", &patterns, |sh, layer| {
if let Some(v) = p.get("path") { if let Some(v) = p.get("path") {
sh.path = parse_path(v).map_err(|e| bad(CMD, e))?; sh.path = parse_path(v).map_err(|e| bad(CMD, e))?;
sh.live = None; sh.live = None;
@@ -470,11 +394,11 @@ pub(super) fn shape_edit(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(r) = p.get("fillRule").and_then(Value::as_str) { if let Some(r) = p.get("fillRule").and_then(Value::as_str) {
sh.path.fill_rule = if r.eq_ignore_ascii_case("evenodd") { FillRule::EvenOdd } else { FillRule::NonZero }; sh.path.fill_rule = if r.eq_ignore_ascii_case("evenodd") { FillRule::EvenOdd } else { FillRule::NonZero };
} }
if let Some(fill) = fill { if let Some(v) = p.get("fill") {
sh.fill = fill; sh.fill = parse_fill(v).map_err(|e| bad(CMD, e))?;
} }
if let Some(stroke) = stroke { if let Some(v) = p.get("stroke") {
sh.stroke = stroke; sh.stroke = parse_stroke(v, sh.stroke.clone(), fgc).map_err(|e| bad(CMD, e))?;
} }
if let Some(n) = name { if let Some(n) = name {
layer.name = n; layer.name = n;
-6
View File
@@ -7,7 +7,6 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod action_defaults;
pub mod adjust_params; pub mod adjust_params;
mod allocation; mod allocation;
pub mod build_info; pub mod build_info;
@@ -68,7 +67,6 @@ pub use cmd::analysis_cmds;
pub use cmd::artboard_cmds; pub use cmd::artboard_cmds;
pub use cmd::atn_cmds; pub use cmd::atn_cmds;
pub use cmd::automate_cmds; pub use cmd::automate_cmds;
pub use cmd::background_blur_cmds;
pub use cmd::background_cmds; pub use cmd::background_cmds;
pub use cmd::brush_cmds; pub use cmd::brush_cmds;
pub use cmd::brush_key_cmds; pub use cmd::brush_key_cmds;
@@ -94,7 +92,6 @@ pub use cmd::fill_key_cmds;
pub use cmd::float_cmds; pub use cmd::float_cmds;
pub use cmd::font_library_cmds; pub use cmd::font_library_cmds;
use cmd::frame_cmds; use cmd::frame_cmds;
pub use cmd::frequency_separation_cmds;
pub use cmd::fx_view_cmds; pub use cmd::fx_view_cmds;
pub use cmd::fx_visibility_cmds; pub use cmd::fx_visibility_cmds;
pub use cmd::gallery_cmds; pub use cmd::gallery_cmds;
@@ -207,9 +204,6 @@ pub struct Session {
/// Shape path context menu: copied vector fill and stroke styles. /// Shape path context menu: copied vector fill and stroke styles.
pub path_fill_clipboard: Option<photocraft_doc::Fill>, pub path_fill_clipboard: Option<photocraft_doc::Fill>,
pub path_stroke_clipboard: Option<photocraft_doc::ShapeStroke>, pub path_stroke_clipboard: Option<photocraft_doc::ShapeStroke>,
/// Pattern tiles referenced by copied paints; surfaces retain their COW snapshots.
pub(crate) path_fill_pattern: Option<photocraft_doc::Pattern>,
pub(crate) path_stroke_pattern: Option<photocraft_doc::Pattern>,
/// Colour management: proofing state, monitor profile, display transforms. /// Colour management: proofing state, monitor profile, display transforms.
pub color: color_cmds::ColorState, pub color: color_cmds::ColorState,
/// Open Edit Contents documents and the smart objects they update. /// Open Edit Contents documents and the smart objects they update.
-7
View File
@@ -84,7 +84,6 @@ choice!(
LinuxDisplayServer { Auto = "auto", X11 = "x11" } default Auto LinuxDisplayServer { Auto = "auto", X11 = "x11" } default Auto
); );
choice!(UiFontSize { Tiny = "tiny", Small = "small", Medium = "medium", Large = "large" } default Small); choice!(UiFontSize { Tiny = "tiny", Small = "small", Medium = "medium", Large = "large" } default Small);
choice!(ScrollbarVisibility { Default = "default", Always = "always", AutoHide = "autoHide", Hidden = "hidden" } default Default);
choice!(LogDestination { Metadata = "metadata", TextFile = "textFile", Both = "both" } default Metadata); choice!(LogDestination { Metadata = "metadata", TextFile = "textFile", Both = "both" } default Metadata);
choice!(LogDetail { SessionsOnly = "sessionsOnly", Concise = "concise", Detailed = "detailed" } default Concise); choice!(LogDetail { SessionsOnly = "sessionsOnly", Concise = "concise", Detailed = "detailed" } default Concise);
choice!(Ask { Always = "always", Never = "never", Ask = "ask" } default Always); choice!(Ask { Always = "always", Never = "never", Ask = "ask" } default Always);
@@ -280,10 +279,6 @@ pub struct Interface {
/// Command ids and the control protocol stay English. /// Command ids and the control protocol stay English.
pub language: String, pub language: String,
pub ui_font_size: UiFontSize, pub ui_font_size: UiFontSize,
/// Use PhotoCraft glyphs instead of gradient thumbnails in style controls.
pub native_gradient_icons: bool,
/// Default follows the theme; other modes apply to panel and canvas scrollbars (#3262).
pub scrollbar_visibility: ScrollbarVisibility,
pub show_channels_in_color: bool, pub show_channels_in_color: bool,
pub dynamic_color_sliders: bool, pub dynamic_color_sliders: bool,
/// Draw menu item colours set with Edit › Menus. /// Draw menu item colours set with Edit › Menus.
@@ -329,8 +324,6 @@ impl Default for Interface {
ui_scale: UiScale::Auto, ui_scale: UiScale::Auto,
language: "auto".into(), language: "auto".into(),
ui_font_size: UiFontSize::Small, ui_font_size: UiFontSize::Small,
native_gradient_icons: false,
scrollbar_visibility: ScrollbarVisibility::Default,
show_channels_in_color: false, show_channels_in_color: false,
dynamic_color_sliders: true, dynamic_color_sliders: true,
show_menu_colors: true, show_menu_colors: true,
-42
View File
@@ -6,28 +6,6 @@ fn session() -> Session {
s s
} }
#[test]
fn native_gradient_icons_persist_reset_and_preserve_legacy_defaults() {
let mut s = session();
let history = s.active().unwrap().history.past_len();
assert!(!s.prefs().interface.native_gradient_icons);
s.execute("prefs.set", json!({"path": "interface.nativeGradientIcons", "value": true})).unwrap();
let mut restarted = Session::new();
restarted.load_prefs_json(&s.prefs_to_json()).unwrap();
assert!(restarted.prefs().interface.native_gradient_icons);
assert_eq!(s.active().unwrap().history.past_len(), history);
s.execute("prefs.reset", json!({"path": "interface.nativeGradientIcons"})).unwrap();
assert!(!s.prefs().interface.native_gradient_icons);
let before = s.prefs().to_json();
for invalid in [json!("true"), json!(1), Value::Null] {
assert!(s.execute("prefs.set", json!({"values": {"interface.showTooltips": false, "interface.nativeGradientIcons": invalid}})).is_err());
assert_eq!(s.prefs().to_json(), before, "invalid updates are atomic");
}
restarted.load_prefs_json(r#"{"interface":{"showTooltips":false}}"#).unwrap();
assert!(!restarted.prefs().interface.native_gradient_icons);
assert!(!restarted.prefs().interface.show_tooltips);
}
#[test] #[test]
fn defaults_match_photoshop() { fn defaults_match_photoshop() {
let p = Preferences::default(); let p = Preferences::default();
@@ -1055,23 +1033,3 @@ fn a_wrongly_typed_section_or_colour_setting_resets_only_that_part() {
assert!(s.load_prefs_json(&deep).is_ok()); assert!(s.load_prefs_json(&deep).is_ok());
assert!(s.load_prefs_json(r#"{"interface": {"theme": 3, "uiScale": [1]}, "workspaces": {"x": 1}, "menus": 1}"#).is_ok()); assert!(s.load_prefs_json(r#"{"interface": {"theme": 3, "uiScale": [1]}, "workspaces": {"x": 1}, "menus": 1}"#).is_ok());
} }
#[test]
fn scrollbar_visibility_round_trips_and_old_preferences_keep_the_theme_default() {
let old = serde_json::from_value::<Preferences>(json!({"interface": {"uiFontSize": "small"}})).unwrap();
assert_eq!(old.interface.scrollbar_visibility, ScrollbarVisibility::Default);
assert_eq!(choices("interface.scrollbarVisibility"), Some(ScrollbarVisibility::NAMES));
let mut s = Session::new();
for mode in ScrollbarVisibility::NAMES {
s.execute("prefs.set", json!({"values": {"interface.scrollbarVisibility": mode}})).unwrap();
let saved = s.prefs().to_json();
let loaded: Preferences = serde_json::from_value(saved.clone()).unwrap();
assert_eq!(loaded.interface.scrollbar_visibility, s.prefs().interface.scrollbar_visibility);
assert_eq!(saved["interface"]["scrollbarVisibility"], json!(mode));
}
let before = s.prefs().to_json();
assert!(s.execute("prefs.set", json!({"values": {"interface.scrollbarVisibility": "sometimes"}})).is_err());
assert_eq!(s.prefs().to_json(), before);
s.execute("prefs.reset", json!({"path": "interface.scrollbarVisibility"})).unwrap();
assert_eq!(s.prefs().interface.scrollbar_visibility, ScrollbarVisibility::Default);
}
-2
View File
@@ -1,4 +1,3 @@
use super::ScrollbarVisibility;
use super::shortcuts::normalize_shortcut; use super::shortcuts::normalize_shortcut;
use super::{ use super::{
AppearanceMode, Ask, CanvasBorder, CanvasColor, CheckerColors, CheckerSize, ColorPicker, DarkTheme, ExportLocation, ExportMetadata, FontPreview, AppearanceMode, Ask, CanvasBorder, CanvasColor, CheckerColors, CheckerSize, ColorPicker, DarkTheme, ExportLocation, ExportMetadata, FontPreview,
@@ -81,7 +80,6 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> {
"interface.canvasBorder" => CanvasBorder::NAMES, "interface.canvasBorder" => CanvasBorder::NAMES,
"interface.uiScale" => UiScale::NAMES, "interface.uiScale" => UiScale::NAMES,
"interface.uiFontSize" => UiFontSize::NAMES, "interface.uiFontSize" => UiFontSize::NAMES,
"interface.scrollbarVisibility" => ScrollbarVisibility::NAMES,
"historyLog.destination" => LogDestination::NAMES, "historyLog.destination" => LogDestination::NAMES,
"historyLog.detail" => LogDetail::NAMES, "historyLog.detail" => LogDetail::NAMES,
"fileHandling.imagePreviews" | "fileHandling.maximizePsdCompatibility" => Ask::NAMES, "fileHandling.imagePreviews" | "fileHandling.maximizePsdCompatibility" => Ask::NAMES,
+6 -20
View File
@@ -564,8 +564,6 @@ pub struct PresetStore {
index: Option<IndexFile>, index: Option<IndexFile>,
/// [`crate::actions_cmds::ActionState::rev`] last written (or loaded). /// [`crate::actions_cmds::ActionState::rev`] last written (or loaded).
actions_rev: u64, actions_rev: u64,
/// Starter set last offered to this store, independent of later user deletions or renames.
actions_defaults_version: u32,
/// `actions.json` exists but failed to load: left on disk untouched, so it is never written. /// `actions.json` exists but failed to load: left on disk untouched, so it is never written.
actions_skipped: bool, actions_skipped: bool,
/// The pattern files and index (`patterns.json`). /// The pattern files and index (`patterns.json`).
@@ -668,7 +666,7 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
let loaded_actions = load_actions(backend.as_ref(), &mut warnings); let loaded_actions = load_actions(backend.as_ref(), &mut warnings);
let actions_skipped = loaded_actions.is_none(); 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 actions = loaded_actions.unwrap_or_default();
let (patterns, pattern_files) = patterns::load(backend.as_ref(), &mut warnings); let (patterns, pattern_files) = patterns::load(backend.as_ref(), &mut warnings);
let mut store = PresetStore { let mut store = PresetStore {
backend, backend,
@@ -678,7 +676,6 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
tips: HashMap::new(), tips: HashMap::new(),
index: None, index: None,
actions_rev: 0, actions_rev: 0,
actions_defaults_version,
actions_skipped, actions_skipped,
patterns: pattern_files, patterns: pattern_files,
warnings: Vec::new(), warnings: Vec::new(),
@@ -744,17 +741,17 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
/// `actions.json`: a missing file is an empty list. Anything else unreadable is a warning. /// `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. /// 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> { fn load_actions(backend: &dyn PresetBackend, warnings: &mut Vec<String>) -> Option<Vec<crate::actions_cmds::Action>> {
let bytes = match backend.read(ACTIONS_FILE, MAX_ACTIONS_BYTES) { let bytes = match backend.read(ACTIONS_FILE, MAX_ACTIONS_BYTES) {
Ok(b) => b, Ok(b) => b,
Err(e) if missing_file(&e) => return Some(crate::actions_cmds::ActionsFile { version: 1, defaults_version: 0, actions: Vec::new() }), Err(e) if missing_file(&e) => return Some(Vec::new()),
Err(e) => { Err(e) => {
warnings.push(format!("actions: {ACTIONS_FILE} skipped: {e}")); warnings.push(format!("actions: {ACTIONS_FILE} skipped: {e}"));
return None; return None;
} }
}; };
match serde_json::from_slice::<crate::actions_cmds::ActionsFile>(&bytes) { match serde_json::from_slice::<crate::actions_cmds::ActionsFile>(&bytes) {
Ok(file) if file.version == 1 => Some(file), Ok(file) if file.version == 1 => Some(file.actions),
Ok(file) => { Ok(file) => {
warnings.push(format!("actions: {ACTIONS_FILE} skipped: unsupported version {}", file.version)); warnings.push(format!("actions: {ACTIONS_FILE} skipped: unsupported version {}", file.version));
None None
@@ -1106,7 +1103,7 @@ impl PresetStore {
self.actions_rev = rev; self.actions_rev = rev;
return; return;
} }
let file = crate::actions_cmds::ActionsFile { version: 1, defaults_version: self.actions_defaults_version, actions: list.to_vec() }; let file = crate::actions_cmds::ActionsFile { version: 1, actions: list.to_vec() };
let bytes = match serde_json::to_vec_pretty(&file) { let bytes = match serde_json::to_vec_pretty(&file) {
Ok(b) => b, Ok(b) => b,
Err(e) => { Err(e) => {
@@ -1183,8 +1180,7 @@ impl Session {
} }
self.preset_store = Some(store); self.preset_store = Some(store);
// In-memory actions (created before the store finished loading) win on name. Disk-only // In-memory actions (created before the store finished loading) win on name. Disk-only
// names are appended. An empty session takes the disk list without rewriting it, // names are appended. An empty session takes the disk list and does not rewrite it.
// except when the one-time starter set migration below is needed.
if self.actions.list.is_empty() { if self.actions.list.is_empty() {
self.actions.list = disk; self.actions.list = disk;
self.actions.rev = 1; self.actions.rev = 1;
@@ -1199,16 +1195,6 @@ impl Session {
} }
self.actions.rev = self.actions.rev.saturating_add(1).max(1); self.actions.rev = self.actions.rev.saturating_add(1).max(1);
} }
if let Some(st) = self.preset_store.as_mut()
&& !st.actions_skipped
&& st.actions_defaults_version < crate::action_defaults::VERSION
{
// Merge after disk and early in-memory actions so neither is overwritten. Persist
// the marker even when all names collide, so a later deletion stays deleted.
crate::action_defaults::append_missing(&mut self.actions);
st.actions_defaults_version = crate::action_defaults::VERSION;
self.actions.touch();
}
self.brush_presets_changed(); self.brush_presets_changed();
self.sync_preset_store(); self.sync_preset_store();
// A brush remembered from the previous run (or picked before the store loaded) may be // A brush remembered from the previous run (or picked before the store loaded) may be
+2 -2
View File
@@ -112,7 +112,7 @@ fn group_with_sampled_8_and_16_bit_tips_round_trips() {
let dir = TempDir::new("roundtrip"); let dir = TempDir::new("roundtrip");
let (mut s, w) = session(&dir); let (mut s, w) = session(&dir);
assert!(w.is_empty(), "{w:?}"); assert!(w.is_empty(), "{w:?}");
assert_eq!(dir.files(), [ACTIONS_FILE], "only starter actions are written when attaching an empty store"); assert!(dir.files().is_empty(), "attaching an empty store writes nothing");
let mut a = sampled("Eight", "Inks", tip8(64, 48, 1)); let mut a = sampled("Eight", "Inks", tip8(64, 48, 1));
a.brush.dual_brush.tip = TipShape::Sampled(tip8(12, 12, 5)); a.brush.dual_brush.tip = TipShape::Sampled(tip8(12, 12, 5));
a.brush.texture.pattern = Pattern::Tile(tip16(32, 32, 7)); a.brush.texture.pattern = Pattern::Tile(tip16(32, 32, 7));
@@ -163,7 +163,7 @@ fn commands_persist_and_delete_removes_from_disk() {
assert!(photocraft_paint::presets::find(&t.tools.presets, "Doodle").is_some()); assert!(photocraft_paint::presets::find(&t.tools.presets, "Doodle").is_some());
assert!(photocraft_paint::presets::find(&t.tools.presets, "Scribble").is_none()); assert!(photocraft_paint::presets::find(&t.tools.presets, "Scribble").is_none());
s.execute("brush.presets.delete", json!({"name": "Doodle"})).unwrap(); s.execute("brush.presets.delete", json!({"name": "Doodle"})).unwrap();
assert_eq!(dir.files(), [ACTIONS_FILE, INDEX_FILE], "the group file and its tip are gone"); assert_eq!(dir.files(), [INDEX_FILE], "the group file and its tip are gone");
let (t, _) = session(&dir); let (t, _) = session(&dir);
assert!(photocraft_paint::presets::find(&t.tools.presets, "Doodle").is_none()); assert!(photocraft_paint::presets::find(&t.tools.presets, "Doodle").is_none());
} }
-3
View File
@@ -16,8 +16,6 @@ use crate::commands::CommandSpec;
use crate::vector_cmds::path_json; use crate::vector_cmds::path_json;
use crate::{EngineError, Result, Session}; use crate::{EngineError, Result, Session};
mod csh;
/// Group listing Edit › Define Custom Shape results (`EditState::custom_shapes`). /// Group listing Edit › Define Custom Shape results (`EditState::custom_shapes`).
pub const CUSTOM_GROUP: &str = "Custom Shapes"; pub const CUSTOM_GROUP: &str = "Custom Shapes";
@@ -430,7 +428,6 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
csh::spec(),
CommandSpec { CommandSpec {
id: "shape.presets.list", id: "shape.presets.list",
label: "Shape Presets", label: "Shape Presets",
-87
View File
@@ -1,87 +0,0 @@
//! Import a complete custom-shape set before changing the persistent library.
use std::collections::{HashMap, HashSet};
use serde_json::{Value, json};
use super::{CommandSpec, Group, Session, ShapePreset, always, bad, fit};
use crate::Result;
const CMD: &str = "shape.presets.importCsh";
pub(super) fn spec() -> CommandSpec {
CommandSpec {
id: CMD,
label: "Import Shapes",
menu: &[],
shortcut: None,
params: r#"{"path":str (.csh) | "data":str (base64),"group":str?} → {group,count,names}; appends a uniquely named group, never replaces existing presets"#,
enabled: always,
run: import,
journal: true,
}
}
/// Parse and convert the complete set before applying any library changes.
/// Group and shape names are resolved against the live library on apply, not the worker's
/// snapshot, so edits made while the job runs do not introduce duplicate addressable names.
/// Conversion fits each path into a 100-by-100 reference box while preserving its aspect ratio.
fn import(s: &mut Session, p: &Value) -> Result<Value> {
let group = p
.get("group")
.map(|v| v.as_str().map(str::trim).filter(|n| !n.is_empty()).map(str::to_owned).ok_or_else(|| bad(CMD, "`group` must be a non-empty string")))
.transpose()?;
let params = p.clone();
crate::jobs::run(
s,
"Import Shapes",
false,
move |ctx| {
ctx.check()?;
let (bytes, stem) = crate::preset_import_cmds::file_bytes_limited(&params, CMD, ".csh file", photocraft_psd::csh::MAX_FILE_BYTES as u64)?;
let shapes = photocraft_psd::csh::parse(&bytes).map_err(|e| bad(CMD, e.to_string()))?;
if shapes.is_empty() {
return Err(bad(CMD, "the CSH set contains no shapes"));
}
let mut items = Vec::with_capacity(shapes.len());
for shape in shapes {
ctx.check()?;
let [top, left, bottom, right] = shape.bounds;
// The parser validates ordering; widen before subtraction for signed bounds.
let width = u32::try_from(i64::from(right) - i64::from(left)).map_err(|_| bad(CMD, "shape width exceeds supported bounds"))?;
let height = u32::try_from(i64::from(bottom) - i64::from(top)).map_err(|_| bad(CMD, "shape height exceeds supported bounds"))?;
let path = photocraft_io::vector_map::path_from_records(&shape.path, width, height);
if path.is_empty() {
return Err(bad(CMD, format!("shape {:?} has no path", shape.name)));
}
let path = fit(&path, [0.0, 0.0, 100.0, 100.0], true);
// Photoshop stores some preset names as `$$$/…/Key=Shown`. The Shapes panel
// renders the stored name, so keep the text after `=`.
let shown = photocraft_doc::pattern::display_name(&shape.name);
let name = if shown.trim().is_empty() { "Shape".to_owned() } else { shown.to_owned() };
items.push(ShapePreset { name, path });
}
let group = group.unwrap_or_else(|| if stem.is_empty() { "Imported Shapes".into() } else { stem });
Ok((group, items))
},
|s, (group, mut items)| {
let group = super::super::gradients::unique_group(&s.presets.shapes, &group);
let mut taken: HashSet<String> = s.presets.shapes.iter().flat_map(|g| g.items.iter().map(|i| i.name.clone())).collect();
taken.extend(s.edit_state.custom_shapes.iter().map(|c| c.name.clone()));
let mut suffixes: HashMap<String, usize> = HashMap::new();
for item in &mut items {
let base = item.name.clone();
let suffix = suffixes.entry(base.clone()).or_insert(2);
while !taken.insert(item.name.clone()) {
item.name = format!("{base} {suffix}");
*suffix += 1;
}
}
let names: Vec<String> = items.iter().map(|i| i.name.clone()).collect();
let result = json!({"group": group, "count": items.len(), "names": names});
s.presets.shapes.push(Group::new(&group, items));
s.presets_changed();
Ok(result)
},
)
}
-133
View File
@@ -1,133 +0,0 @@
//! Original geometric CSH fixture, assembled from the public container/path layout.
//! No Adobe or third-party shape assets are embedded.
use photocraft_engine::Session;
use serde_json::json;
#[path = "../../psd/tests/common/csh_fixture.rs"]
mod fixture;
use fixture::{polygon_csh, triangle_csh};
#[test]
fn csh_import_stores_the_display_form_of_a_localization_key() {
let bytes = fixture::named_polygon_csh("$$$/Presets/Shapes/Tiger=Tiger", &[&[(0.25, 0.25), (0.75, 0.25), (0.5, 0.75)]]);
let blank = fixture::named_polygon_csh("$$$/Presets/Shapes/Blank= ", &[&[(0.25, 0.25), (0.75, 0.25), (0.5, 0.75)]]);
let mut session = Session::new();
let result = session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes), "group": "Localized CSH"})).unwrap();
assert_eq!(result["group"], "Localized CSH");
assert_eq!(result["names"], json!(["Tiger"]));
assert_eq!(session.presets.shapes.iter().find(|g| g.name == "Localized CSH").unwrap().items[0].name, "Tiger");
let again = session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes), "group": "Localized CSH"})).unwrap();
assert_eq!(again["names"], json!(["Tiger 2"]));
let blanked = session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&blank)})).unwrap();
assert_eq!(blanked["names"], json!(["Shape"]));
}
#[test]
fn csh_import_makes_an_original_shape_available_without_a_document() {
let bytes = triangle_csh();
if let Some(path) = std::env::var_os("PHOTOCRAFT_CSH_FIXTURE") {
std::fs::write(path, &bytes).unwrap();
}
let mut session = Session::new();
session
.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes), "group": "Original CSH"}))
.expect("CSH import must be available without an open document");
let groups = photocraft_engine::presets::shapes::all_groups(&session);
let imported = groups.iter().find(|g| g.name == "Original CSH").expect("imported group");
assert_eq!(imported.items.len(), 1);
assert_eq!(imported.items[0].name, "Triangle");
let path = &imported.items[0].path;
assert_eq!(path.subpaths.len(), 1);
assert!(path.subpaths[0].closed);
assert_eq!(path.subpaths[0].knots.len(), 3);
// The reference bounds are twice as wide as tall; normalization must preserve that.
assert_eq!(path.control_bounds().unwrap(), (0.0, 25.0, 100.0, 75.0));
}
#[test]
fn csh_failed_import_preserves_existing_presets_and_revision() {
let mut session = Session::new();
let before = session.presets.to_json(&session);
let revision = session.presets.rev;
let bytes = triangle_csh();
for end in [0, 11, bytes.len() - 1] {
assert!(session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes[..end])})).is_err());
assert_eq!(session.presets.to_json(&session), before);
assert_eq!(session.presets.rev, revision);
}
assert!(session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes), "group": 12})).is_err());
assert_eq!(session.presets.to_json(&session), before);
}
#[test]
fn csh_invalid_second_shape_does_not_partially_import_a_set() {
let triangle = triangle_csh();
let mut set = triangle.clone();
set[8..12].copy_from_slice(&2u32.to_be_bytes());
set.extend_from_slice(&triangle[12..]);
// The first shape is complete; truncate the second shape's path/padding.
set.pop();
let mut session = Session::new();
session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&triangle)})).unwrap();
let before = session.presets.to_json(&session);
let revision = session.presets.rev;
assert!(session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&set)})).is_err());
assert_eq!(session.presets.to_json(&session), before);
assert_eq!(session.presets.rev, revision);
}
#[test]
fn csh_multiple_shapes_with_matching_names_remain_individually_available() {
let triangle = triangle_csh();
let mut set = triangle.clone();
set[8..12].copy_from_slice(&2u32.to_be_bytes());
set.extend_from_slice(&triangle[12..]);
let mut s = Session::new();
let result = s.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&set), "group": "Pair"})).unwrap();
assert_eq!(result["count"], 2);
assert_eq!(result["names"], json!(["Triangle", "Triangle 2"]));
let group = s.presets.shapes.iter().find(|g| g.name == "Pair").unwrap();
assert_eq!(group.items.len(), 2);
assert_eq!(group.items[0].path, group.items[1].path);
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
for name in ["Triangle", "Triangle 2"] {
s.execute("shape.presets.place", json!({"preset": name, "group": "Pair"})).unwrap();
}
}
#[test]
fn csh_repeat_import_names_are_unique_and_survive_preferences_reload() {
let mut session = Session::new();
let params = json!({"data": photocraft_paint::tile::b64_encode(&triangle_csh()), "group": "Original CSH"});
let first = session.execute("shape.presets.importCsh", params.clone()).unwrap();
let second = session.execute("shape.presets.importCsh", params).unwrap();
assert_eq!(first["count"], 1);
assert_eq!(second["count"], 1);
assert_ne!(first["group"], second["group"]);
assert_ne!(first["names"], second["names"]);
let persisted = session.presets.to_json(&session);
let mut restored = Session::new();
restored.load_presets_json(persisted);
assert_eq!(restored.presets.shapes, session.presets.shapes);
}
#[test]
fn csh_same_winding_inner_subpath_stays_a_hole_after_place_and_reload() {
// Same winding intentionally: a nonzero union would incorrectly fill the hole.
let bytes =
polygon_csh(&[&[(0.125, 0.125), (0.875, 0.125), (0.875, 0.875), (0.125, 0.875)], &[(0.375, 0.375), (0.625, 0.375), (0.625, 0.625), (0.375, 0.625)]]);
let mut imported = Session::new();
let result = imported.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes)})).unwrap();
let mut s = Session::new();
s.load_presets_json(imported.presets.to_json(&imported));
s.execute("file.new", json!({"width": 96, "height": 96, "background": "transparent"})).unwrap();
s.execute("shape.presets.place", json!({"preset": result["names"][0], "rect": [8, 8, 80, 80], "keepAspect": false, "fill": "#ff0000"})).unwrap();
let image = photocraft_compose::flatten(&s.active().unwrap().doc);
assert!(image.px[48 * 96 + 16][0] > 0.99, "ring must remain red");
assert!(image.px[48 * 96 + 16][3] > 0.99, "ring must remain opaque");
assert!(image.px[48 * 96 + 48][3] < 0.01, "inner subpath must remain transparent");
assert!(image.px[4 * 96 + 4][3] < 0.01, "outside shape must remain transparent");
s.execute("edit.undo", json!({})).unwrap();
assert!(photocraft_compose::flatten(&s.active().unwrap().doc).px.iter().all(|pixel| pixel[3] < 0.01));
}
-175
View File
@@ -1,175 +0,0 @@
//! Shape paint JSON preserves model colours and all gradient/pattern placement fields.
use photocraft_color::{Color, ColorMode};
use photocraft_doc::{Fill, GradientStyle, LayerContent, LayerId, ShapeLayer, ShapeStroke};
use photocraft_engine::{
Session,
vector_cmds::{shape_fill, shape_paint_params},
};
use serde_json::json;
fn session(depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width":64,"height":48,"depth":depth,"background":"transparent"})).unwrap();
s
}
fn active_shape(s: &Session) -> &ShapeLayer {
let st = s.active().unwrap();
let LayerContent::Shape(shape) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("shape layer") };
shape
}
#[test]
fn exact_paints_roundtrip_through_create_edit_and_undo() {
for depth in [8, 16, 32] {
let mut s = session(depth);
let pattern = s.patterns.items[0].clone();
let mut fills = vec![
Fill::Solid(Color::rgba(0.1234567, 0.3737, 0.919191, 0.4737)),
Fill::Solid(Color { mode: ColorMode::Grayscale, c: [0.314159, 0.0, 0.0, 0.0], alpha: 0.3737 }),
Fill::Solid(Color { mode: ColorMode::Cmyk, c: [0.17, 0.28, 0.39, 0.41], alpha: 0.73 }),
Fill::Solid(Color { mode: ColorMode::Lab, c: [57.3, -14.8, 23.9, 0.0], alpha: 0.63 }),
Fill::Pattern { name: "Stored pattern alias".into(), id: pattern.id, scale: 0.3737, angle: 27.3, link: false, phase: (-7.3, 4.1) },
];
for style in [GradientStyle::Linear, GradientStyle::Radial, GradientStyle::Angle, GradientStyle::Reflected, GradientStyle::Diamond] {
fills.push(Fill::Gradient {
stops: vec![(0.1737, Color::rgba(0.12345, 0.34567, 0.56789, 0.61)), (0.9137, Color::rgba(0.9191, 0.8181, 0.7171, 0.82))],
angle: 37.3,
scale: 0.3737,
style,
reverse: true,
opacity_stops: vec![(0.07, 0.23), (0.91, 0.79)],
midpoints: vec![0.27],
offset: (-0.13, 0.21),
dither: true,
align: false,
});
}
for fill in fills {
let stroke = ShapeStroke { paint: fill.clone(), opacity: 0.3737, width: 2.3, dashes: vec![2.1, 1.3], ..Default::default() };
let before_parse = s.active().unwrap().doc.clone();
let paint = shape_paint_params(Some(&fill), Some(&stroke));
assert_eq!(shape_fill(&s, &paint["fill"]).unwrap(), Some(fill.clone()));
assert_eq!(s.active().unwrap().doc, before_parse, "view-only paint validation edited the document");
let mut create = paint.clone();
create["rect"] = json!([8, 8, 40, 24]);
s.execute("shape.create", create).unwrap();
assert_eq!(active_shape(&s).fill, Some(fill.clone()));
assert_eq!(active_shape(&s).stroke, Some(stroke.clone()));
s.execute("shape.edit", json!({"fill":"#0000ff","stroke":null})).unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
s.execute("shape.edit", paint).unwrap();
assert_eq!(active_shape(&s).fill, Some(fill));
assert_eq!(active_shape(&s).stroke, Some(stroke));
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
let after = s.active().unwrap().doc.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, after);
}
}
}
#[test]
fn solid_alpha_and_library_patterns_render_and_embed_in_the_same_history_step() {
for depth in [8, 16, 32] {
let mut s = session(depth);
let library = s.patterns.items.clone();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
let id = LayerId(s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":[0.17,0.31,0.43,0.3737]})).unwrap()["layer"].as_u64().unwrap());
let alpha: [f32; 4] = serde_json::from_value(s.execute("document.pixel", json!({"x":24,"y":20})).unwrap()).unwrap();
assert!((alpha[3] - 0.3737).abs() < 0.005, "depth={depth}: {alpha:?}");
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
s.execute("edit.redo", json!({})).unwrap();
assert!(s.active().unwrap().doc.layer(id).is_some());
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
s.execute("shape.edit", json!({"fill":{"pattern":library[0].name},"stroke":{"width":4,"fill":{"pattern":library[1].id}}})).unwrap();
assert_eq!(s.active().unwrap().doc.patterns.len(), 2);
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
let pixel: [f32; 4] = serde_json::from_value(s.execute("document.pixel", json!({"x":24,"y":20})).unwrap()).unwrap();
assert!(pixel[3] > 0.99 && pixel[0] > 0.1, "library pattern did not render: {pixel:?}");
let stroke_pixel: [f32; 4] = serde_json::from_value(s.execute("document.pixel", json!({"x":7,"y":20})).unwrap()).unwrap();
assert!(stroke_pixel[3] > 0.99, "library pattern stroke did not render: {stroke_pixel:?}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
assert!(s.active().unwrap().doc.patterns.is_empty());
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.patterns.len(), 2);
// A freshly created patterned shape carries its own tile too.
let mut fresh = session(depth);
fresh.execute("shape.create", json!({"rect":[8,8,40,24],"fill":{"pattern":library[0].id}})).unwrap();
assert_eq!(fresh.active().unwrap().doc.patterns.len(), 1);
let pixel: [f32; 4] = serde_json::from_value(fresh.execute("document.pixel", json!({"x":24,"y":20})).unwrap()).unwrap();
assert!(pixel[3] > 0.99);
fresh.execute("edit.undo", json!({})).unwrap();
assert!(fresh.active().unwrap().doc.patterns.is_empty());
}
}
#[test]
fn imported_document_pattern_resolves_without_a_global_library_entry() {
let mut s = session(16);
let imported = s.patterns.items[0].clone();
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(imported.clone());
s.patterns.items.clear();
let fill = Fill::Pattern { name: "Imported name preserved".into(), id: imported.id, scale: 0.3737, angle: -17.3, link: false, phase: (3.1, -2.7) };
let paint = shape_paint_params(Some(&fill), None);
let before = s.active().unwrap().doc.clone();
assert_eq!(shape_fill(&s, &paint["fill"]).unwrap(), Some(fill.clone()));
assert_eq!(s.active().unwrap().doc, before);
let mut create = paint;
create["rect"] = json!([8, 8, 40, 24]);
s.execute("shape.create", create).unwrap();
assert_eq!(active_shape(&s).fill, Some(fill));
assert_eq!(s.active().unwrap().doc.patterns.len(), 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
}
#[test]
fn legacy_defaults_and_malformed_paint_inputs_are_explicit_and_atomic() {
let mut s = session(8);
assert_eq!(
shape_fill(&s, &json!({"gradient":{}})).unwrap(),
Some(Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 90.0, 1.0, GradientStyle::Linear, false))
);
assert_eq!(shape_fill(&s, &json!("#ff000080")).unwrap(), Some(Fill::Solid(Color::rgba(1.0, 0.0, 0.0, 128.0 / 255.0))));
s.execute("shape.create", json!({"rect":[8,8,40,24]})).unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
for fill in [
json!([0, "bad", 1, 0.5]),
json!([0, 0, 0, 2]),
json!([0, 0, 0, 1, 1]),
json!({"gradient":null}),
json!({"gradient":{"stops":[[0,"#000000"],["bad","#ffffff"]]}}),
json!({"gradient":{"stops":[[2,"#000000"]]}}),
json!({"gradient":{"opacityStops":[[0,2]]}}),
json!({"gradient":{"midpoints":[-0.1]}}),
json!({"gradient":{"offset":[0]}}),
json!({"gradient":{"scale":0}}),
json!({"gradient":{"scale":1e100}}),
json!({"gradient":{"align":"false"}}),
json!({"gradient":{"dither":null}}),
json!({"gradient":{"style":"unknown"}}),
json!({"pattern":1}),
json!({"pattern":"missing pattern"}),
json!({"pattern":"Checkerboard","link":0}),
json!({"pattern":"Checkerboard","phase":[0,1e100]}),
json!({"pattern":"Checkerboard","id":1}),
json!({"color":{"mode":"Rgb","c":[0,0,0,0],"alpha":-1}}),
json!({"gradient":{"stops":vec![json!([0,"#000000"]);4097]}}),
] {
assert!(shape_fill(&s, &fill).is_err(), "{fill}");
assert!(s.execute("shape.edit", json!({"fill":fill.clone()})).is_err(), "{fill}");
assert!(s.execute("shape.create", json!({"rect":[0,0,5,5],"stroke":{"fill":fill}})).is_err());
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
}
}
@@ -1,222 +0,0 @@
//! Vector style clipboards retain the pattern tile after its source document closes.
use photocraft_doc::{Color, Fill, LayerContent, Pattern, PixelFormat, ShapeLayer, ShapeStroke};
use photocraft_engine::{Session, vector_cmds::shape_fill};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::json;
fn imported_pattern() -> Pattern {
let mut surface = Surface::new(PixelFormat::RGBA16);
surface.fill_rect(Rect::new(0, 0, 4, 4), &[0.13, 0.27, 0.91, 1.0]);
Pattern::new("$$$/Patterns/Test/Blue=Imported Blue", surface, 4, 4)
}
fn new_doc(s: &mut Session, depth: u32) {
s.execute("file.new", json!({"width":64,"height":48,"depth":depth,"background":"transparent"})).unwrap();
}
fn shape(s: &Session) -> &ShapeLayer {
let st = s.active().unwrap();
let LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("shape") };
sh
}
#[test]
fn patterned_fill_and_stroke_clipboards_keep_cow_tiles_after_source_closes() {
for depth in [8, 16, 32] {
for imported in [false, true] {
let mut s = Session::new();
new_doc(&mut s, depth);
let pattern = if imported {
let p = imported_pattern();
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(p.clone());
s.patterns.items.clear();
p
} else {
s.patterns.items[0].clone()
};
let paint = json!({"pattern":pattern.name,"id":pattern.id,"scale":100,"angle":0,"link":false,"phase":[0,0]});
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":paint,"stroke":{"width":4,"fill":paint}})).unwrap();
let copied_fill = shape(&s).fill.clone();
let copied_stroke = shape(&s).stroke.clone();
let fill_pixel = shape(&s).cache.as_ref().unwrap().rgba(24, 20);
let stroke_pixel = shape(&s).cache.as_ref().unwrap().rgba(7, 20);
let original = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
s.execute("path.style.copyFill", json!({})).unwrap();
s.execute("path.style.copyStroke", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, original);
assert_eq!(s.active().unwrap().history.past_len(), history);
// Editing the source tile after Copy must not change the copied COW snapshot.
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns[0].surface.fill_rect(pattern.rect(), &[1.0, 0.0, 0.0, 1.0]);
s.patterns.items.clear();
assert!(s.close(0).is_some());
for (paste, sample, expected) in [("path.style.pasteFill", (24, 20), fill_pixel), ("path.style.pasteStroke", (7, 20), stroke_pixel)] {
new_doc(&mut s, depth);
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":null})).unwrap();
assert!(s.active().unwrap().doc.patterns.is_empty());
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
s.execute(paste, json!({})).unwrap();
let after = s.active().unwrap().doc.clone();
assert_eq!(after.patterns, vec![pattern.clone()]);
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
if paste == "path.style.pasteFill" {
assert_eq!(shape(&s).fill, copied_fill);
} else {
assert_eq!(shape(&s).stroke, copied_stroke);
}
let pixel = shape(&s).cache.as_ref().unwrap().rgba(sample.0, sample.1);
assert!(pixel[3] > 0.99, "{paste}: copied pattern remained transparent");
assert!(
pixel.iter().zip(expected).all(|(a, b)| (*a - b).abs() < 0.005),
"{paste}, imported={imported}, depth={depth}: {pixel:?} != {expected:?}"
);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
assert!(s.active().unwrap().doc.patterns.is_empty());
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, after);
}
}
}
}
#[test]
fn replacing_public_clipboards_with_plain_paints_ignores_old_pattern_snapshots() {
let mut s = Session::new();
new_doc(&mut s, 8);
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":{"pattern":"Checkerboard"},"stroke":{"width":4,"fill":{"pattern":"Checkerboard"}}})).unwrap();
s.execute("path.style.copyFill", json!({})).unwrap();
s.execute("path.style.copyStroke", json!({})).unwrap();
new_doc(&mut s, 8);
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":null})).unwrap();
let fill = Fill::Solid(Color::rgba(0.13, 0.27, 0.91, 0.73));
let stroke = ShapeStroke { paint: fill.clone(), width: 4.0, ..Default::default() };
s.path_fill_clipboard = Some(fill.clone());
s.path_stroke_clipboard = Some(stroke.clone());
s.execute("path.style.pasteFill", json!({})).unwrap();
s.execute("path.style.pasteStroke", json!({})).unwrap();
assert_eq!(shape(&s).fill, Some(fill));
assert_eq!(shape(&s).stroke, Some(stroke));
assert!(s.active().unwrap().doc.patterns.is_empty());
}
#[test]
fn legacy_pattern_clipboard_without_a_snapshot_resolves_the_library() {
let mut s = Session::new();
let p = s.patterns.items[0].clone();
for old_id in ["", "obsolete-library-id"] {
new_doc(&mut s, 16);
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":null})).unwrap();
s.path_fill_clipboard = Some(Fill::Pattern { name: p.name.clone(), id: old_id.into(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) });
s.execute("path.style.pasteFill", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.patterns, vec![p.clone()]);
assert_eq!(shape(&s).fill, Some(Fill::Pattern { name: p.name.clone(), id: p.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }));
assert!(shape(&s).cache.as_ref().unwrap().rgba(24, 20)[3] > 0.99);
}
}
#[test]
fn copied_name_only_and_obsolete_ids_bind_to_the_snapshot_despite_destination_conflicts() {
for old_id in ["", "obsolete-imported-id"] {
for stroke in [false, true] {
let mut s = Session::new();
new_doc(&mut s, 16);
let pattern = imported_pattern();
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(pattern.clone());
let paint = json!({"pattern":pattern.name,"id":pattern.id,"scale":37.37,"angle":17.3,"link":false,"phase":[-2.7,3.1]});
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":paint,"stroke":{"width":4,"fill":paint}})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc);
let LayerContent::Shape(source) = &mut doc.layer_mut(id).unwrap().content else { panic!("source shape") };
if let Some(Fill::Pattern { id, .. }) = &mut source.fill {
*id = old_id.into();
}
if let Some(ShapeStroke { paint: Fill::Pattern { id, .. }, .. }) = &mut source.stroke {
*id = old_id.into();
}
let mut expected_fill = shape(&s).fill.clone().unwrap();
let mut expected_stroke = shape(&s).stroke.clone().unwrap();
if let Fill::Pattern { id, .. } = &mut expected_fill {
*id = pattern.id.clone();
}
if let Fill::Pattern { id, .. } = &mut expected_stroke.paint {
*id = pattern.id.clone();
}
let original = s.active().unwrap().doc.clone();
s.execute(if stroke { "path.style.copyStroke" } else { "path.style.copyFill" }, json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, original, "normalization changes the clipboard, not imported source metadata");
if stroke {
assert_eq!(s.path_stroke_clipboard, Some(expected_stroke.clone()));
} else {
assert_eq!(s.path_fill_clipboard, Some(expected_fill.clone()));
}
s.patterns.items.clear();
new_doc(&mut s, 16);
let mut conflicting = pattern.clone();
conflicting.id = if old_id.is_empty() { "destination-pattern-id".into() } else { old_id.into() };
conflicting.surface.fill_rect(pattern.rect(), &[1.0, 0.0, 0.0, 1.0]);
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(conflicting.clone());
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":null})).unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
s.execute(if stroke { "path.style.pasteStroke" } else { "path.style.pasteFill" }, json!({})).unwrap();
let after = s.active().unwrap().doc.clone();
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
assert_eq!(after.patterns, vec![conflicting, pattern.clone()]);
if stroke {
assert_eq!(shape(&s).stroke, Some(expected_stroke));
} else {
assert_eq!(shape(&s).fill, Some(expected_fill));
}
let pixel = shape(&s).cache.as_ref().unwrap().rgba(if stroke { 7 } else { 24 }, 20);
assert!(
pixel[2] > 0.85 && pixel[0] < 0.2 && pixel[3] > 0.99,
"stroke={stroke}, old_id={old_id:?}: destination pattern replaced copied blue: {pixel:?}"
);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, after);
}
}
}
#[test]
fn unresolved_and_conflicting_pattern_clipboards_fail_atomically() {
let mut s = Session::new();
new_doc(&mut s, 16);
let p = imported_pattern();
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(p.clone());
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":{"pattern":p.name,"id":p.id}})).unwrap();
s.execute("path.style.copyFill", json!({})).unwrap();
new_doc(&mut s, 16);
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":null})).unwrap();
let mut conflicting = p.clone();
conflicting.surface.fill_rect(p.rect(), &[1.0, 0.0, 0.0, 1.0]);
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(conflicting);
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
let error = s.execute("path.style.pasteFill", json!({})).unwrap_err();
assert!(error.to_string().contains("different pattern"));
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
s.path_fill_clipboard = Some(Fill::Pattern { name: "Missing".into(), id: "missing".into(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) });
assert!(s.execute("path.style.pasteFill", json!({})).is_err());
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
}
#[test]
fn pattern_parser_falls_back_to_imported_display_names_and_rejects_conflicting_ids() {
let mut s = Session::new();
new_doc(&mut s, 16);
let p = imported_pattern();
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(p.clone());
let before = s.active().unwrap().doc.clone();
let fill = shape_fill(&s, &json!({"pattern":"Imported Blue","id":"obsolete-id","angle":17,"phase":[3,4]})).unwrap().unwrap();
assert_eq!(fill, Fill::Pattern { name: "Imported Blue".into(), id: p.id.clone(), scale: 1.0, angle: 17.0, link: true, phase: (3.0, 4.0) });
assert!(shape_fill(&s, &json!({"pattern":s.patterns.items[0].name,"id":p.id})).is_err());
assert_eq!(s.active().unwrap().doc, before);
}
-74
View File
@@ -1,74 +0,0 @@
use photocraft_doc::{LayerContent, LayerId, Pattern, PixelFormat};
use photocraft_engine::Session;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::json;
fn library_pattern() -> Pattern {
let mut surface = Surface::new(PixelFormat::RGBA16);
surface.fill_rect(Rect::new(0, 0, 2, 2), &[0.1, 0.2, 0.9, 1.0]);
surface.fill_rect(Rect::new(1, 0, 2, 2), &[0.9, 0.7, 0.1, 1.0]);
Pattern::new("Shape tile", surface, 2, 2)
}
#[test]
fn patterned_shapes_embed_render_and_undo_in_one_command_at_every_depth() {
for depth in [8, 16, 32] {
let mut session = Session::new();
session.execute("file.new", json!({"width":32,"height":32,"depth":depth})).unwrap();
let p = library_pattern();
session.patterns.items.push(p.clone());
let history = session.active().unwrap().history.past_len();
let f = json!({"pattern":p.name,"id":p.id,"scale":100,"angle":0,"link":true});
let id = LayerId(
session.execute("shape.create", json!({"kind":"rect","rect":[8,8,16,16],"fill":f,"stroke":{"width":4,"fill":f}})).unwrap()["layer"]
.as_u64()
.unwrap(),
);
let doc = session.active().unwrap().doc.clone();
assert_eq!(session.active().unwrap().history.past_len(), history + 1);
assert_eq!(doc.patterns, vec![p.clone()]);
let surface = doc.layer(id).unwrap().surface().unwrap();
let q = surface.rgba(10, 10);
assert!(q[2] > 0.85 && q[0] < 0.15 && q[3] > 0.99, "pattern fill is visible: {q:?}");
let q = surface.rgba(6, 12);
assert!(q[2] > 0.85 && q[3] > 0.99, "pattern stroke is visible: {q:?}");
assert!(session.undo());
assert!(session.active().unwrap().doc.patterns.is_empty());
assert!(session.active().unwrap().doc.layer(id).is_none());
assert!(session.redo());
assert_eq!(session.active().unwrap().doc, doc);
let saved = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();
let native = photocraft_format::load_from_bytes(&saved).unwrap();
assert_eq!(&native, doc.as_ref());
let bytes = photocraft_io::export(&doc, "psd", &Default::default()).unwrap().bytes;
let mut imported = photocraft_io::import("shape-pattern.psd", &bytes).unwrap().document;
assert_eq!(imported.patterns.len(), 1);
let imported_id = imported.layers.iter().find(|l| matches!(l.content, LayerContent::Shape(_))).unwrap().id;
// Prove editable paint survives, rather than relying on the PSD's cached pixels.
let source = imported.clone();
if let LayerContent::Shape(sh) = &mut imported.layer_mut(imported_id).unwrap().content {
photocraft_engine::vector_cmds::refresh_shape(&source, sh);
}
let q = imported.layer(imported_id).unwrap().surface().unwrap().rgba(10, 10);
assert!(q[2] > 0.8 && q[0] < 0.2 && q[3] > 0.99, "PSD pattern remains renderable after refresh: {q:?}");
}
}
#[test]
fn editing_a_solid_shape_to_pattern_embeds_and_undoes_the_tile() {
let mut session = Session::new();
session.execute("file.new", json!({"width":32,"height":32})).unwrap();
let p = library_pattern();
session.patterns.items.push(p.clone());
let id = session.execute("shape.create", json!({"kind":"rect","rect":[8,8,16,16],"fill":"#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let original = session.active().unwrap().doc.clone();
session.execute("shape.edit", json!({"layer":id,"fill":{"pattern":p.name,"id":p.id}})).unwrap();
assert_eq!(session.active().unwrap().doc.patterns, vec![p]);
let edited = session.active().unwrap().doc.clone();
assert!(session.undo());
assert_eq!(session.active().unwrap().doc, original);
assert!(session.redo());
assert_eq!(session.active().unwrap().doc, edited);
}
+16 -24
View File
@@ -1,7 +1,6 @@
//! Document ⇄ manifest conversion. Binary data is routed through a //! Document ⇄ manifest conversion. Binary data is routed through a
//! [`Sink`] (save) or [`Fetch`] (load) keyed by BLAKE3 hash. //! [`Sink`] (save) or [`Fetch`] (load) keyed by BLAKE3 hash.
use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use photocraft_color::{PixelFormat, SampleType}; use photocraft_color::{PixelFormat, SampleType};
@@ -562,32 +561,25 @@ impl Loader<'_> {
} }
pub(crate) fn document(&mut self, m: &DocM) -> Result<Document> { pub(crate) fn document(&mut self, m: &DocM) -> Result<Document> {
// Validate the whole layer tree before decoding payloads or allocating ids. Remapping
// cannot repair duplicates: layer comp and slice targets would still be ambiguous.
fn check_ids(layers: &[LayerM], depth: usize, seen: &mut HashSet<u64>, max: &mut u64) -> Result<()> {
if depth > MAX_GROUP_DEPTH && !layers.is_empty() {
return Err(too_deep());
}
for l in layers {
if !seen.insert(l.id) {
return Err(FormatError::corrupt(format!("duplicate layer id {}", l.id)));
}
*max = (*max).max(l.id);
match &l.content {
ContentM::Group { children, .. } => check_ids(children, depth + 1, seen, max)?,
// Contents identities intentionally repeat for shared smart objects; embedded
// documents are opaque blobs with their own layer identity namespace.
ContentM::Smart { contents_id: Some(id), .. } => *max = (*max).max(*id),
_ => {}
}
}
Ok(())
}
let mut max = m.id;
check_ids(&m.layers, 0, &mut HashSet::new(), &mut max)?;
// Reserve every stored id before allocating identities missing from older bundles. If an // Reserve every stored id before allocating identities missing from older bundles. If an
// id is implausibly large, remap the whole document while retaining shared contents groups. // id is implausibly large, remap the whole document while retaining shared contents groups.
if self.preserve_ids { if self.preserve_ids {
fn max_ids(layers: &[LayerM], depth: usize, max: &mut u64) -> Result<()> {
if depth > MAX_GROUP_DEPTH && !layers.is_empty() {
return Err(too_deep());
}
for l in layers {
*max = (*max).max(l.id);
match &l.content {
ContentM::Group { children, .. } => max_ids(children, depth + 1, max)?,
ContentM::Smart { contents_id: Some(id), .. } => *max = (*max).max(*id),
_ => {}
}
}
Ok(())
}
let mut max = m.id;
max_ids(&m.layers, 0, &mut max)?;
self.preserve_ids = reserve_ids_through(max); self.preserve_ids = reserve_ids_through(max);
} }
let layers = m.layers.iter().map(|l| self.layer(l, 0)).collect::<Result<Vec<_>>>()?; let layers = m.layers.iter().map(|l| self.layer(l, 0)).collect::<Result<Vec<_>>>()?;
-177
View File
@@ -1,177 +0,0 @@
//! Layer identities are unique within a document, not across embedded smart documents.
use std::cell::Cell;
use std::collections::HashSet;
use std::sync::Arc;
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{CompLayerState, DocId, Document, Layer, LayerComp, LayerContent, SmartContentsId, SmartObject, SmartSource};
use photocraft_format::{ByteSource, FormatError, LoadOptions, SaveOptions, load_bytes_source, load_from_bytes_with, save_to_bytes, zip};
use photocraft_geom::{Affine, Rect, Size};
fn sample() -> Document {
let mut doc = Document::new("layer identities", Size::new(1, 1), ColorMode::Rgb, SampleType::U8);
let mut first = Layer::raster("same name", doc.pixel_format());
first.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]);
let leaf = || Layer::raster("same name", doc.pixel_format());
doc.layers = vec![first, Layer::group("left", vec![leaf(), Layer::group("nested", vec![leaf()])]), Layer::group("right", vec![leaf()])];
doc.layer_comps.push(LayerComp {
id: 1,
name: "every layer".into(),
comment: String::new(),
apply_visibility: true,
apply_position: false,
apply_appearance: false,
states: doc.walk().into_iter().map(|(_, _, l)| CompLayerState { layer: l.id, visible: Some(l.visible), position: None, appearance: None }).collect(),
});
doc
}
fn rewrite_manifest(bytes: &[u8], edit: impl FnOnce(&mut serde_json::Value)) -> Vec<u8> {
let reader = zip::ZipReader::new(bytes).unwrap();
let mut manifest: serde_json::Value = serde_json::from_slice(&reader.read_by_name("manifest.json", usize::MAX).unwrap()).unwrap();
edit(&mut manifest);
let mut writer = zip::ZipWriter::new();
writer.add("manifest.json", &serde_json::to_vec(&manifest).unwrap()).unwrap();
for entry in reader.entries.iter().filter(|e| e.name != "manifest.json") {
writer.add(&entry.name, &reader.read(entry, usize::MAX).unwrap()).unwrap();
}
writer.finish().unwrap()
}
fn assert_duplicate(error: FormatError, id: u64) {
assert!(matches!(&error, FormatError::Corrupt(message) if message == &format!("duplicate layer id {id}")), "{error}");
}
#[test]
fn duplicate_layer_ids_are_rejected_across_the_whole_tree() {
let doc = sample();
let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap();
// Top-level siblings, ancestor/descendant, nested siblings, and unrelated groups must all
// share one identity namespace. Reidentifying cannot recover an ambiguous comp target.
for (source, target) in [
("/document/layers/1/id", "/document/layers/2/id"),
("/document/layers/1/id", "/document/layers/1/content/children/1/content/children/0/id"),
("/document/layers/1/content/children/0/id", "/document/layers/1/content/children/1/id"),
("/document/layers/1/content/children/0/id", "/document/layers/2/content/children/0/id"),
("/document/layers/0/id", "/document/layers/1/content/children/1/content/children/0/id"),
] {
let mut duplicate = 0;
let damaged = rewrite_manifest(&bytes, |m| {
duplicate = m.pointer(source).unwrap().as_u64().unwrap();
*m.pointer_mut(target).unwrap() = duplicate.into();
});
for preserve_ids in [true, false] {
let error = load_from_bytes_with(&damaged, &LoadOptions { preserve_ids, ..Default::default() }).unwrap_err();
assert_duplicate(error, duplicate);
}
}
}
#[test]
fn duplicate_extreme_layer_ids_are_not_silently_remapped() {
let bytes = save_to_bytes(&sample(), &SaveOptions::default()).unwrap();
let damaged = rewrite_manifest(&bytes, |m| {
m["document"]["layers"][0]["id"] = u64::MAX.into();
m["document"]["layers"][2]["id"] = u64::MAX.into();
});
for preserve_ids in [true, false] {
let error = load_from_bytes_with(&damaged, &LoadOptions { preserve_ids, ..Default::default() }).unwrap_err();
assert_duplicate(error, u64::MAX);
}
}
#[test]
fn duplicate_layer_ids_are_rejected_before_fetching_payloads() {
struct ManifestOnly {
json: Vec<u8>,
payload_reads: Cell<usize>,
}
impl ByteSource for ManifestOnly {
fn get(&self, path: &str, _max: usize) -> photocraft_format::Result<Vec<u8>> {
if path == "manifest.json" {
return Ok(self.json.clone());
}
self.payload_reads.set(self.payload_reads.get() + 1);
Err(FormatError::Corrupt("payload fetched before validating layer ids".into()))
}
}
let doc = sample();
let duplicate = doc.layers[1].id.0;
let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap();
let damaged = rewrite_manifest(&bytes, |m| m["document"]["layers"][2]["id"] = duplicate.into());
let reader = zip::ZipReader::new(&damaged).unwrap();
let src = ManifestOnly { json: reader.read_by_name("manifest.json", usize::MAX).unwrap(), payload_reads: Cell::new(0) };
for preserve_ids in [true, false] {
assert_duplicate(load_bytes_source(&src, &LoadOptions { preserve_ids, ..Default::default() }).unwrap_err(), duplicate);
assert_eq!(src.payload_reads.get(), 0);
}
}
#[test]
fn unique_nested_layer_ids_preserve_comp_targets_in_both_load_modes() {
let doc = sample();
let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap();
let original_ids: HashSet<_> = doc.walk().into_iter().map(|(_, _, l)| l.id).collect();
for preserve_ids in [true, false] {
let loaded = load_from_bytes_with(&bytes, &LoadOptions { preserve_ids, ..Default::default() }).unwrap();
let walk = loaded.walk();
let ids: HashSet<_> = walk.iter().map(|(_, _, l)| l.id).collect();
assert_eq!(ids.len(), walk.len());
for ((_, _, layer), state) in walk.iter().zip(&loaded.layer_comps[0].states) {
assert_eq!(state.layer, layer.id);
assert_eq!(loaded.layer(state.layer), Some(*layer));
}
if preserve_ids {
assert_eq!(loaded, doc);
} else {
assert!(ids.is_disjoint(&original_ids));
}
}
}
#[test]
fn layer_identity_validation_keeps_smart_document_and_contents_scopes_independent() {
let mut doc = sample();
let mut inner = Document::new("inside", doc.size, doc.mode, doc.depth);
inner.layers.push(doc.layers[0].clone());
let bytes = Arc::new(save_to_bytes(&inner, &SaveOptions::default()).unwrap());
let mut smart = SmartObject::new(SmartSource::Embedded { file_name: "inside.pcraft".into(), bytes: bytes.clone() }, Affine::IDENTITY, None);
// These typed identities may have the same number without identifying the same thing.
smart.contents_id = SmartContentsId(doc.layers[0].id.0);
doc.id = DocId(doc.layers[0].id.0);
let first = Layer::new("instance", LayerContent::Smart(smart));
let second = first.duplicate();
doc.layers.extend([first, second]);
let outer = save_to_bytes(&doc, &SaveOptions::default()).unwrap();
for preserve_ids in [true, false] {
let opts = LoadOptions { preserve_ids, ..Default::default() };
let loaded = load_from_bytes_with(&outer, &opts).unwrap();
let instances: Vec<_> = loaded.layers.iter().filter_map(|l| if let LayerContent::Smart(s) = &l.content { Some(s) } else { None }).collect();
assert_eq!(instances.len(), 2);
assert_eq!(instances[0].contents_id, instances[1].contents_id);
for instance in instances {
let SmartSource::Embedded { bytes: stored, .. } = &instance.source else { panic!("embedded source expected") };
assert_eq!(stored, &bytes);
let restored = load_from_bytes_with(stored, &opts).unwrap();
assert_eq!(restored.layers.len(), 1);
if preserve_ids {
assert_eq!(restored, inner);
assert_eq!(restored.layers[0].id, loaded.layers[0].id);
}
}
if preserve_ids {
assert_eq!(loaded, doc);
}
}
}
#[test]
fn an_empty_layer_tree_loads_in_both_modes() {
let doc = Document::new("empty", Size::new(1, 1), ColorMode::Rgb, SampleType::U8);
let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap();
for preserve_ids in [true, false] {
let loaded = load_from_bytes_with(&bytes, &LoadOptions { preserve_ids, ..Default::default() }).unwrap();
assert!(loaded.layers.is_empty());
}
}
+2 -2
View File
@@ -55,8 +55,8 @@ impl B {
GlowTechnique::Softer => { GlowTechnique::Softer => {
// Inner glows spread 1 - alpha. // Inner glows spread 1 - alpha.
let src = if inner { self.shift(In::Shape, 0.0, 0.0, 0.0, true) } else { In::Shape }; let src = if inner { self.shift(In::Shape, 0.0, 0.0, 0.0, true) } else { In::Shape };
let m = self.spread_blur(src, (g.size, g.spread), inner, 0.0, 0.0); let m=self.spread_blur(src, (g.size,g.spread),inner,0.0,0.0);
if inner { m } else { self.softer_glow_tail(m, g.size, g.range) } if inner {m} else {self.softer_glow_tail(m,g.size,g.range)}
} }
}; };
self.finish_glow(m, fx::glow_lut(g), inner, center); self.finish_glow(m, fx::glow_lut(g), inner, center);
+1 -1
View File
@@ -1,6 +1,6 @@
use super::*; use super::*;
use photocraft_color::BlendMode; use photocraft_color::BlendMode;
use photocraft_doc::{FxCommon, GlowSource, GlowTechnique}; use photocraft_doc::FxCommon;
/// Filled-shape identity survives integer moves, including fractional anchors and handles. /// Filled-shape identity survives integer moves, including fractional anchors and handles.
#[test] #[test]
+2 -5
View File
@@ -485,8 +485,7 @@ impl<'a> Planner<'a> {
if let LayerContent::Shape(sh) = &layer.content if let LayerContent::Shape(sh) = &layer.content
&& !visible_clipped.is_empty() && !visible_clipped.is_empty()
&& let Some((fill, stroke)) = && let Some((fill, stroke)) = photocraft_compose::shape_split::split(sh, self.cx.canvas, self.cx.depth)
photocraft_compose::shape_split::split_with_patterns(sh, self.cx.canvas, self.cx.depth, self.cx.patterns, self.cx.mode)
{ {
// The vector stroke goes above the clipped layers: the fill is the clipping base, // The vector stroke goes above the clipped layers: the fill is the clipping base,
// the stroke is laid over the clipped result, then the masks apply to both // the stroke is laid over the clipped result, then the masks apply to both
@@ -788,9 +787,7 @@ impl<'a> Planner<'a> {
// A stroked shape's vector stroke goes above its clipped layers and interior effects // A stroked shape's vector stroke goes above its clipped layers and interior effects
// (compose::split_parts): the fill and the stroke unmasked, the masks applied after. // (compose::split_parts): the fill and the stroke unmasked, the masks applied after.
let split = match &layer.content { let split = match &layer.content {
LayerContent::Shape(sh) if sh.stroke.is_some() => { LayerContent::Shape(sh) if sh.stroke.is_some() => photocraft_compose::shape_split::split(sh, canvas, self.cx.depth),
photocraft_compose::shape_split::split_with_patterns(sh, canvas, self.cx.depth, self.cx.patterns, self.cx.mode)
}
_ => None, _ => None,
}; };
let (mut content, vstroke) = match split { let (mut content, vstroke) = match split {
-34
View File
@@ -1606,40 +1606,6 @@ fn checker_pattern() -> Pattern {
Pattern::new("checker", s, 6, 5) Pattern::new("checker", s, 6, 5)
} }
#[test]
fn patterned_shape_fill_and_stroke_clip_and_effects_match_cpu() {
let Some(mut g) = gpu() else { return };
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk] {
for effects in [false, true] {
let mut d = Document::new("pattern shape", Size::new(90, 70), mode, depth);
let p = checker_pattern();
let fill = Fill::Pattern { id: p.id.clone(), name: p.name.clone(), scale: 1.3, angle: 23.0, link: true, phase: (2.0, -3.0) };
d.patterns.push(p);
let mut sh = photocraft_doc::ShapeLayer {
path: photocraft_vector::shapes::rounded_rect(12.0, 12.0, 60.0, 40.0, [4.0; 4]),
fill: Some(fill.clone()),
stroke: Some(photocraft_doc::ShapeStroke { width: 4.0, paint: fill, align: photocraft_doc::StrokeAlign::Outside, ..Default::default() }),
..Default::default()
};
sh.cache = Some(photocraft_vector::render_shape_with_patterns(&sh, d.pixel_format(), d.bounds(), &d.patterns));
assert!(sh.cache.as_ref().unwrap().rgba(30, 30)[3] > 0.5, "pattern fill must be visible before parity");
assert!(sh.cache.as_ref().unwrap().rgba(9, 30)[3] > 0.5, "pattern stroke must be visible before parity");
let mut layer = Layer::new("pattern", LayerContent::Shape(sh));
if effects {
layer.mask = Some(mask(d.bounds(), 7, 0.6));
layer.effects.items.push(Effect::DropShadow(shadow(BlendMode::Multiply, 0.6, 120.0, 4.0, 3.0, 0.0)));
}
d.layers.push(layer);
let mut clipped = noise_layer("clipped", d.pixel_format(), Rect::new(28, 0, 50, 70), 9, 0.5);
clipped.clipped = true;
d.layers.push(clipped);
fx_check(&mut g, &d, &format!("pattern shape {depth:?}/{mode:?}, effects {effects}"));
}
}
}
}
/// Every effect kind with several option combinations, one effect stack per case. /// Every effect kind with several option combinations, one effect stack per case.
fn effect_cases() -> Vec<(&'static str, Vec<Effect>)> { fn effect_cases() -> Vec<(&'static str, Vec<Effect>)> {
let pat = checker_pattern(); let pat = checker_pattern();
-5
View File
@@ -718,11 +718,6 @@ pub fn parse_tysh(data: &[u8]) -> Option<(String, Affine)> {
for (i, v) in m.iter_mut().enumerate() { for (i, v) in m.iter_mut().enumerate() {
*v = f(2 + i * 8)?; *v = f(2 + i * 8)?;
} }
// This best-effort fallback cannot bypass the text parser's geometry safety gate.
// Unusable matrices use identity instead of corrupting canvas or panel rectangles.
if !photocraft_text::psd::usable_tysh_transform(&m) {
return None;
}
// text version (2) at 50, descriptor version (4) at 52, descriptor at 56. // text version (2) at 50, descriptor version (4) at 52, descriptor at 56.
let rest = data.get(52..)?; let rest = data.get(52..)?;
let text = parse_prefix_versioned(rest).and_then(|d| match d.get("Txt ") { let text = parse_prefix_versioned(rest).and_then(|d| match d.get("Txt ") {
-14
View File
@@ -490,20 +490,6 @@ fn effects_switch() {
assert!(effects_enabled(&[0, 0, 0, 0])); assert!(effects_enabled(&[0, 0, 0, 0]));
} }
#[test]
fn tysh_fallback_cannot_reintroduce_unusable_finite_or_nonfinite_transform() {
let mut tysh = vec![0u8; 52];
tysh[..2].copy_from_slice(&1u16.to_be_bytes());
for (i, value) in [1.0f64, 0.0, 0.0, 1.0, 30.0, 40.0].iter().enumerate() {
tysh[2 + 8 * i..10 + 8 * i].copy_from_slice(&value.to_be_bytes());
}
assert!(parse_tysh(&tysh).is_some(), "normal matrices still import");
for value in [1e62f64, -1e62, f64::INFINITY, f64::NAN] {
tysh[2..10].copy_from_slice(&value.to_be_bytes());
assert!(parse_tysh(&tysh).is_none(), "invalid matrix {value} cannot reach UI");
}
}
#[test] #[test]
fn tysh_garbage_is_none() { fn tysh_garbage_is_none() {
assert!(parse_tysh(&[0; 10]).is_none()); assert!(parse_tysh(&[0; 10]).is_none());
-27
View File
@@ -107,33 +107,6 @@ fn alignment_from_the_paragraph_style_the_run_names() {
} }
} }
/// The paragraph sheets' `Hanging` key (Photoshop wrote it after `LeadingType` in the corpus
/// files) reads into `ParagraphStyle.hanging`; missing means off, as Photoshop's own files
/// write `/Hanging false` for the default paragraph sheet.
#[test]
fn hanging_punctuation_comes_from_the_paragraph_sheet() {
let on = engine(
"Text",
d(vec![
("Name", E::String("T".into())),
("DefaultStyleSheet", E::Int(0)),
("Properties", d(vec![("AutoHyphenate", E::Bool(true)), ("Hanging", E::Bool(true)), ("LeadingType", E::Int(0))])),
]),
d(vec![("StyleSheetData", d(vec![]))]),
vec![para_sheet("Normal RGB", 0, 0), para_sheet("T", 0, 0)],
vec![normal_char(48.0)],
);
assert!(layer("Text", &on).paragraphs[0].style.hanging, "on by the sheet");
let off = engine(
"Text",
d(vec![("Name", E::String("T".into())), ("DefaultStyleSheet", E::Int(0)), ("Properties", d(vec![("LeadingType", E::Int(0))]))]),
d(vec![("StyleSheetData", d(vec![]))]),
vec![para_sheet("Normal RGB", 0, 0), para_sheet("T", 0, 0)],
vec![normal_char(48.0)],
);
assert!(!layer("Text", &off).paragraphs[0].style.hanging, "off without the key");
}
/// A run's own value still wins over its paragraph style, and an unknown style name or an /// A run's own value still wins over its paragraph style, and an unknown style name or an
/// out-of-range sheet index falls back to Normal. /// out-of-range sheet index falls back to Normal.
#[test] #[test]
-126
View File
@@ -1,126 +0,0 @@
//! Photoshop custom shape sets (`.csh`, version 2).
//!
//! Container layout: Michel Mariani's public format research,
//! <https://tonton-pixel.codeberg.page/photoshop-file-formats/custom-shapes-file-format.html>.
//! Geometry uses Adobe's path record format. No external implementation or shape assets copied.
use crate::error::{PsdError, Result};
use crate::io::Reader;
use crate::path::{PathData, RECORD_LEN};
/// Maximum encoded file size accepted by this reader.
pub const MAX_FILE_BYTES: usize = 64 << 20;
/// Maximum presets in a set.
pub const MAX_SHAPES: usize = 10_000;
/// Maximum path records across all presets, not per shape.
pub const MAX_RECORDS: usize = 1_000_000;
/// Maximum UTF-16 units in a preset name, including its optional trailing null.
pub const MAX_NAME_UNITS: usize = 32_768;
/// A named shape with its reference bounds and normalized path records.
#[derive(Debug, Clone, PartialEq)]
pub struct CshShape {
/// Display name.
pub name: String,
/// Source shape identifier (usually a UUID).
pub id: String,
/// Reference rectangle: top, left, bottom, right. Coordinates may be negative.
pub bounds: [i32; 4],
/// Coordinates are fractions of the reference rectangle's width and height.
pub path: PathData,
}
/// Read a complete set before callers modify their preset libraries.
/// Unsupported versions, truncated records and excessive aggregate sizes return errors.
pub fn parse(bytes: &[u8]) -> Result<Vec<CshShape>> {
if bytes.len() > MAX_FILE_BYTES {
return Err(PsdError::LimitExceeded("CSH file bytes"));
}
let mut r = Reader::new(bytes);
let signature = r.array()?;
if &signature != b"cush" {
return Err(PsdError::InvalidSignature { expected: "cush", found: signature });
}
if r.u32()? != 2 {
return Err(PsdError::Unsupported("CSH file version (expected 2)".into()));
}
let count = r.u32()? as usize;
if count > MAX_SHAPES {
return Err(PsdError::LimitExceeded("CSH shape count"));
}
r.check_count(count as u64, 32)?;
let mut shapes = Vec::new();
let mut remaining_records = MAX_RECORDS;
for _ in 0..count {
let units = r.u32()? as usize;
if units > MAX_NAME_UNITS {
return Err(PsdError::LimitExceeded("CSH name length"));
}
let raw = r.bytes(units * 2)?;
let mut name: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
if name.last() == Some(&0) {
name.pop();
}
let name = String::from_utf16(&name).map_err(|_| PsdError::invalid("CSH name is not valid UTF-16"))?;
let padding = (4 - r.pos() % 4) % 4;
zero_padding(r.bytes(padding)?)?;
if r.u32()? != 1 {
return Err(PsdError::Unsupported("CSH shape version (expected 1)".into()));
}
let length = r.u32()?;
if !length.is_multiple_of(4) {
return Err(PsdError::invalid("CSH shape length must be aligned to four bytes"));
}
let mut shape = r.sub(u64::from(length))?;
let id_len = usize::from(shape.u8()?);
let id = std::str::from_utf8(shape.bytes(id_len)?).map_err(|_| PsdError::invalid("CSH identifier is not UTF-8"))?.to_owned();
let bounds = [shape.i32()?, shape.i32()?, shape.i32()?, shape.i32()?];
if bounds[2] <= bounds[0] || bounds[3] <= bounds[1] {
return Err(PsdError::invalid("CSH reference bounds are empty or reversed"));
}
let records = shape.remaining() / RECORD_LEN;
remaining_records = remaining_records.checked_sub(records).ok_or(PsdError::LimitExceeded("CSH aggregate path records"))?;
let path = PathData::from_bytes(shape.bytes(records * RECORD_LEN)?)?;
if shape.remaining() > 3 {
return Err(PsdError::invalid("CSH incomplete path record"));
}
zero_padding(shape.peek_rest())?;
validate_path(&path)?;
shapes.push(CshShape { name, id, bounds, path });
}
if !r.is_empty() {
return Err(PsdError::invalid("CSH trailing data"));
}
Ok(shapes)
}
fn zero_padding(bytes: &[u8]) -> Result<()> {
if bytes.iter().any(|&b| b != 0) {
return Err(PsdError::invalid("CSH nonzero padding"));
}
Ok(())
}
/// Enforce subpath structure more strictly than the generic PSD path reader.
/// Each length record must be followed by exactly its declared number of knots, with matching
/// open/closed selectors. Fill and clipboard records are accepted only between subpaths.
/// Rejecting inconsistent records prevents a damaged preset from silently changing geometry.
fn validate_path(path: &PathData) -> Result<()> {
let mut pending = 0u16;
let mut closed = false;
for record in &path.records {
match record.selector {
0 | 3 if pending == 0 => {
pending = u16::from_be_bytes([record.data[0], record.data[1]]);
closed = record.selector == 0;
}
1 | 2 | 4 | 5 if pending > 0 && closed == (record.selector <= 2) => pending -= 1,
6..=8 if pending == 0 => {}
_ => return Err(PsdError::invalid("CSH path selectors or knot counts are inconsistent")),
}
}
if pending != 0 {
return Err(PsdError::invalid("CSH subpath is missing knots"));
}
Ok(())
}
-1
View File
@@ -49,7 +49,6 @@ pub mod atn;
pub mod blend; pub mod blend;
pub mod builder; pub mod builder;
pub mod compression; pub mod compression;
pub mod csh;
pub mod descriptor; pub mod descriptor;
pub mod error; pub mod error;
pub mod file; pub mod file;
-49
View File
@@ -1,49 +0,0 @@
pub fn triangle_csh() -> Vec<u8> {
polygon_csh(&[&[(0.25, 0.25), (0.75, 0.25), (0.5, 0.75)]])
}
pub fn polygon_csh(polygons: &[&[(f64, f64)]]) -> Vec<u8> {
named_polygon_csh("Triangle", polygons)
}
pub fn named_polygon_csh(label: &str, polygons: &[&[(f64, f64)]]) -> Vec<u8> {
let mut out = b"cush".to_vec();
out.extend_from_slice(&2u32.to_be_bytes());
out.extend_from_slice(&1u32.to_be_bytes());
let name: Vec<u16> = label.encode_utf16().chain(std::iter::once(0)).collect();
out.extend_from_slice(&(name.len() as u32).to_be_bytes());
for unit in &name {
out.extend_from_slice(&unit.to_be_bytes());
}
if !name.len().is_multiple_of(2) {
out.extend_from_slice(&[0, 0]);
}
out.extend_from_slice(&1u32.to_be_bytes());
let mut body = vec![36];
body.extend_from_slice(b"00000000-0000-4000-8000-000000000001");
for bound in [0i32, 0, 100, 200] {
body.extend_from_slice(&bound.to_be_bytes());
}
for selector in [6u16, 8] {
body.extend_from_slice(&selector.to_be_bytes());
body.extend_from_slice(&[0; 24]);
}
for polygon in polygons {
body.extend_from_slice(&0u16.to_be_bytes());
body.extend_from_slice(&(polygon.len() as u16).to_be_bytes());
body.extend_from_slice(&[0; 22]);
for &(x, y) in *polygon {
body.extend_from_slice(&2u16.to_be_bytes());
for _ in 0..3 {
body.extend_from_slice(&((y * 16_777_216.0) as i32).to_be_bytes());
body.extend_from_slice(&((x * 16_777_216.0) as i32).to_be_bytes());
}
}
}
while !body.len().is_multiple_of(4) {
body.push(0);
}
out.extend_from_slice(&(body.len() as u32).to_be_bytes());
out.extend_from_slice(&body);
out
}
-88
View File
@@ -1,88 +0,0 @@
use photocraft_psd::csh;
use photocraft_psd::path::{PathData, PsdKnot, PsdSubpath};
fn fixture(names: &[&str]) -> Vec<u8> {
let mut bytes = b"cush".to_vec();
bytes.extend_from_slice(&2u32.to_be_bytes());
bytes.extend_from_slice(&(names.len() as u32).to_be_bytes());
for name in names {
let units: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
bytes.extend_from_slice(&(units.len() as u32).to_be_bytes());
for unit in units {
bytes.extend_from_slice(&unit.to_be_bytes());
}
while !bytes.len().is_multiple_of(4) {
bytes.push(0);
}
bytes.extend_from_slice(&1u32.to_be_bytes());
let mut body = vec![2, b'i', b'd'];
for bound in [-10i32, -20, 90, 180] {
body.extend_from_slice(&bound.to_be_bytes());
}
let knot = PsdKnot { linked: true, pre: (0.125, 0.25), anchor: (0.5, 0.5), post: (0.75, 0.875) };
let path = PathData::from_subpaths(&[PsdSubpath { closed: false, operation: 0, knots: vec![knot] }], false);
body.extend_from_slice(&path.to_bytes());
while !body.len().is_multiple_of(4) {
body.push(0);
}
bytes.extend_from_slice(&(body.len() as u32).to_be_bytes());
bytes.extend_from_slice(&body);
}
bytes
}
#[test]
fn reads_multiple_names_padding_signed_bounds_and_cubic_controls() {
let bytes = fixture(&["Even", "Odd", "形😀"]);
let shapes = csh::parse(&bytes).unwrap();
assert_eq!(shapes.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Even", "Odd", "形😀"]);
for shape in shapes {
assert_eq!(shape.bounds, [-10, -20, 90, 180]);
assert_eq!(shape.id, "id");
let subpaths = shape.path.subpaths();
assert_eq!(subpaths.len(), 1);
assert!(!subpaths[0].closed);
let knot = subpaths[0].knots[0];
assert!(knot.linked);
assert_eq!(knot.pre, (0.125, 0.25));
assert_eq!(knot.anchor, (0.5, 0.5));
assert_eq!(knot.post, (0.75, 0.875));
}
}
#[test]
fn rejects_every_truncated_prefix_without_panicking() {
let bytes = fixture(&["One", "Two"]);
for end in 0..bytes.len() {
assert!(csh::parse(&bytes[..end]).is_err(), "accepted truncated prefix {end}");
}
}
#[test]
fn rejects_unsupported_headers_excessive_counts_and_invalid_unicode() {
let bytes = fixture(&["A"]);
for (offset, value) in [(4, 3u32), (8, u32::MAX), (12, u32::MAX), (24, u32::MAX)] {
let mut broken = bytes.clone();
broken[offset..offset + 4].copy_from_slice(&value.to_be_bytes());
assert!(csh::parse(&broken).is_err(), "offset {offset}");
}
let mut broken = bytes.clone();
broken[16..18].copy_from_slice(&0xd800u16.to_be_bytes());
assert!(csh::parse(&broken).is_err());
let mut trailing = bytes;
trailing.push(0);
assert!(csh::parse(&trailing).is_err());
}
#[test]
fn rejects_missing_knots_and_mismatched_open_closed_records() {
let bytes = fixture(&["A"]);
// Header/name/version/length = 28; id = 3; bounds = 16; two fill records = 52.
let length_record = 28 + 3 + 16 + 52;
let mut missing = bytes.clone();
missing[length_record + 2..length_record + 4].copy_from_slice(&2u16.to_be_bytes());
assert!(csh::parse(&missing).is_err());
let mut mismatched = bytes;
mismatched[length_record..length_record + 2].copy_from_slice(&0u16.to_be_bytes());
assert!(csh::parse(&mismatched).is_err());
}
-3
View File
@@ -336,9 +336,6 @@ impl Layouter {
flow.stop = true; flow.stop = true;
break; break;
} }
if para.merge_hanging_row(out, &line, li, nlines) {
continue;
}
let Some(frame) = para.frame_line(&line, li, nlines, slot, flow) else { let Some(frame) = para.frame_line(&line, li, nlines, slot, flow) else {
flow.stop = true; flow.stop = true;
break; break;
+8 -244
View File
@@ -15,13 +15,6 @@ type Line<'a> = parley::Line<'a, RunBrush>;
type GlyphRun<'a> = parley::GlyphRun<'a, RunBrush>; type GlyphRun<'a> = parley::GlyphRun<'a, RunBrush>;
/// A cluster of a line in visual order, for kerning. /// A cluster of a line in visual order, for kerning.
#[derive(Default)]
struct HangTrim {
advance: f32,
kern: f32,
active: bool,
}
struct KernSlot { struct KernSlot {
/// Index of the run in the paragraph layout. /// Index of the run in the paragraph layout.
run: usize, run: usize,
@@ -34,7 +27,6 @@ struct KernSlot {
size: f32, size: f32,
rtl: bool, rtl: bool,
blank: bool, blank: bool,
hang_w: f32,
/// Advance along the line (px, horizontal scale included). /// Advance along the line (px, horizontal scale included).
advance: f32, advance: f32,
/// What horizontal scale adds to the shaped advance (px; negative below 100%). /// What horizontal scale adds to the shaped advance (px; negative below 100%).
@@ -151,7 +143,6 @@ fn collect_kern_slots(line: &Line<'_>, para: &Para<'_>) -> Vec<KernSlot> {
size: run.font_size(), size: run.font_size(),
rtl: c.is_rtl(), rtl: c.is_rtl(),
blank: first.is_none() || c.is_space_or_nbsp() || c.text_range().end <= para.prefix.len(), blank: first.is_none() || c.is_space_or_nbsp() || c.text_range().end <= para.prefix.len(),
hang_w: hang_weight(para.text_of(c.text_range())),
upright: env.vertical && c.first_style().brush.1 != VClass::Rotate as u8, upright: env.vertical && c.first_style().brush.1 != VClass::Rotate as u8,
advance: c.advance() * hs, advance: c.advance() * hs,
growth: c.advance() * (hs - 1.0), growth: c.advance() * (hs - 1.0),
@@ -328,59 +319,34 @@ impl Para<'_> {
(trailing_tabs, trailing_growth) (trailing_tabs, trailing_growth)
} }
fn hanging_trim(&self, slots: &[KernSlot], kern_px: &[f32], f: &LineFrame) -> HangTrim {
if !self.ps.hanging || self.env.vertical {
return HangTrim::default();
}
let Some((start, tail)) = hang_runs(slots) else {
return HangTrim::default();
};
let advance = slots[start..tail].iter().map(|s| s.advance * s.hang_w).sum();
let kern = (start..tail).map(|j| kern_px.get(j).copied().unwrap_or(0.0) * slots[j].hang_w).sum();
let edge = slots[..start].iter().map(|s| s.advance).sum::<f32>() + kern_px[..start].iter().sum::<f32>();
let active = advance > 0.0
&& f.slot.is_none()
&& (!self.env.is_box || matches!(self.ps.align, TextAlign::Center | TextAlign::Right) || edge >= self.avail.unwrap_or(0.0) - 0.5);
if active { HangTrim { advance, kern, active } } else { HangTrim::default() }
}
/// Where the line starts along its extent: box and area lines at their box or span edge, /// Where the line starts along its extent: box and area lines at their box or span edge,
/// point lines around the anchor. /// point lines around the anchor.
fn line_start(&self, f: &LineFrame, offset: f32, adv: f32, line_kern: f32, trim: &HangTrim) -> f32 { fn line_start(&self, f: &LineFrame, offset: f32, adv: f32, line_kern: f32) -> f32 {
if !self.env.wraps { if !self.env.wraps {
let target = match self.ps.align { let target = match self.ps.align {
TextAlign::Center | TextAlign::JustifyCenter => -(adv + line_kern - trim.advance - trim.kern) / 2.0, TextAlign::Center | TextAlign::JustifyCenter => -(adv + line_kern) / 2.0,
TextAlign::Right | TextAlign::JustifyRight => -(adv + line_kern - trim.advance - trim.kern), TextAlign::Right | TextAlign::JustifyRight => -(adv + line_kern),
_ => self.indent_start, _ => self.indent_start,
}; };
return target - offset; return target - offset;
} }
// Parley aligned box lines without the kerning. // Parley aligned box lines without the kerning.
let kern_align = match self.ps.align { let kern_align = match self.ps.align {
TextAlign::Center => (line_kern - trim.kern) / 2.0, TextAlign::Center => line_kern / 2.0,
TextAlign::Right => line_kern - trim.kern, TextAlign::Right => line_kern,
_ => 0.0, _ => 0.0,
}; };
let origin = f.slot.map_or(self.line_origin, |s| s.x); let origin = f.slot.map_or(self.line_origin, |s| s.x);
let base = origin + self.indent_start - kern_align; let base = origin + self.indent_start - kern_align;
// The line's own width takes the justify-last-line slack. // The line's own width takes the justify-last-line slack.
let slack = self.line_avail(f).unwrap_or(0.0) - (adv * self.break_scale - trim.advance) - (line_kern - trim.kern); let slack = self.line_avail(f).unwrap_or(0.0) - adv * self.break_scale - line_kern;
// Parley aligned the unscaled line in `avail / break_scale`: scale its // Parley aligned the unscaled line in `avail / break_scale`: scale its
// indent and alignment offset back to the box. // indent and alignment offset back to the box.
let scaled_offset = offset * (self.break_scale - 1.0); let scaled_offset = offset * (self.break_scale - 1.0);
let hung = trim.active && matches!(self.ps.align, TextAlign::Center | TextAlign::Right);
base + if f.last { base + if f.last {
match self.ps.align { match self.ps.align {
TextAlign::JustifyCenter => slack * 0.5 - offset, TextAlign::JustifyCenter => slack * 0.5 - offset,
TextAlign::JustifyRight => slack - offset, TextAlign::JustifyRight => slack - offset,
TextAlign::Center if hung => slack * 0.5 + kern_align - offset,
TextAlign::Right if hung => slack + kern_align - offset,
_ => scaled_offset,
}
} else if hung {
match self.ps.align {
TextAlign::Center => slack * 0.5 + kern_align - offset,
TextAlign::Right => slack + kern_align - offset,
_ => scaled_offset, _ => scaled_offset,
} }
} else { } else {
@@ -393,165 +359,6 @@ impl Para<'_> {
f.slot.map(|s| s.width).or(self.avail) f.slot.map(|s| s.width).or(self.avail)
} }
pub(super) fn merge_hanging_row(&self, out: &mut TextLayout, line: &Line<'_>, li: usize, nlines: usize) -> bool {
let ps = &self.ps;
let vertical = self.env.vertical;
let area = &self.env.area;
let pi = self.index;
let prefix = self.prefix;
let ptext = &self.ptext;
let style_at = |byte| self.env.style_at(byte);
// Hang-row detection needs the byte mapping the rest of the body uses.
let map = |o: usize| self.map_offset(o);
// Hanging punctuation: a row of nothing but marks was pushed here because they
// didn't fit the line above; with the paragraph's hanging on, step them back
// onto that line so they hang past the box edge (Photoshop's Roman Hanging
// Punctuation): a centered line keeps its centering, a left line keeps filling
// the box. Horizontal lines only (vertical type has Burasagari instead) and no
// RTL marks; the text between the line above and the marks stays contiguous, so
// carets and selection keep their rows. The marks keep their style's manual
// kerning but drop optical pair kerning and underline decorations along the way.
let hang_row = ps.hanging
&& !vertical
// Area type plans its rows and slots ahead of the layout; merging a mark
// row back would leave a slot for a row that no longer exists, so hanging
// applies to point and box lines only there.
&& area.is_none()
// An empty row (two forced breaks in a row) has no items: `all` would read
// it as a hanging run and merge it into the line above, dropping the blank
// row.
&& line.items().next().is_some()
&& line.items().all(|it| match it {
PositionedLayoutItem::GlyphRun(gr) => gr.run().visual_clusters().all(|c| {
!c.is_rtl()
&& !c.is_space_or_nbsp()
&& c.text_range().end > prefix.len()
&& hang_cluster(ptext.get(c.text_range().clone()).unwrap_or(""))
}),
_ => false,
});
if hang_row
&& let Some(prev) = out.lines.last()
&& prev.paragraph == pi
{
// The author's own break owns the row start: a forced break becomes '\n'
// in `ptext` and stays in the paragraph, so a marks-only row after it is
// deliberate, not a run the line breaker pushed out. Without the guard
// that row would merge into the line above and the break would vanish;
// the breaks of a normal wrapped line are spaces, never this.
let after_forced = line
.items()
.find_map(|it| match it {
PositionedLayoutItem::GlyphRun(gr) => gr.run().visual_clusters().next().map(|c| c.text_range().start),
_ => None,
})
.is_some_and(|s| ptext.as_bytes().get(s.wrapping_sub(1)) == Some(&b'\n'));
// Count the row's clusters first: the merge takes the edge tip only.
let merged_line = out.lines.len() - 1;
let total = line
.items()
.map(|it| match it {
PositionedLayoutItem::GlyphRun(gr) => gr.run().visual_clusters().count(),
_ => 0,
})
.sum::<usize>();
// Photoshop hangs the marks at the edge, not the run: a row of a couple of
// marks was pushed whole because the row above ran out and hangs as one
// tip, but a row split out of a long run carries most of that run and
// keeps its break instead, or a paragraph of dots or hyphens in a narrow
// box would fold into single lines running past the edge by almost the
// whole run's width.
let tip = total <= 2;
// Photoshop hangs once per edge: the row above already ends in a hangable
// mark (its own run's tip, or a mark row already stepped back up), so no
// mark row may chain onto it and grow one line without bound.
let ends_in_mark = out
.clusters
.iter()
.rev()
.find(|c| c.line == merged_line && self.env.text.get(c.range.clone()).is_some_and(|s| !s.chars().all(char::is_whitespace)))
.and_then(|c| self.env.text.get(c.range.clone()))
.is_some_and(hang_cluster);
// A marks-only row merged into the last row of a justified paragraph would
// keep the justified (spread) spacing the row above was set out with: the
// placed clusters already carry the spread, but Photoshop centers, rights
// or lefts that last line. The row keeps its own last-line alignment; the
// measure-side hang that follows will not need the merge at all.
let justified_last = ps.align.is_justified() && li + 1 == nlines;
if tip && !ends_in_mark && !justified_last && !after_forced {
// Start past the row above's trailing breaking space: that space's cluster
// stays on the row, and `prev.x1` excludes trailing whitespace, so starting
// there would put the marks on top of the space cluster and carets could
// not tell them apart.
let mut cx = out.clusters.iter().rev().find(|c| c.line == merged_line).map_or(prev.x1, |c| c.x + c.advance);
let merged_baseline = prev.baseline;
// The kern of a pair comes from the pair's left cluster's style, as the
// line's kern_px does; it moves the following cluster and lands in the left
// cluster's reported advance. The pair with the row above's last cluster
// crosses a row boundary there, so it contributes none and the first
// cluster starts un-kerned.
let mut left: Option<(usize, f32)> = None; // (byte, font size) of the pair's left cluster
let mut n = 0usize;
let mut merged_end = cx;
for item in line.items() {
let PositionedLayoutItem::GlyphRun(gr) = item else {
continue;
};
let run = gr.run();
let si = gr.style().brush.0;
let st = &self.env.styles[si as usize];
let hs = if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 };
let synth = run.synthesis();
let face = out.faces.len() as u32;
out.faces.push(GlyphFace {
font: run.font().clone(),
coords: run.normalized_coords().to_vec(),
size_px: run.font_size(),
embolden: synthetic_bold(synth.embolden(), st.weight, run.font_attrs().weight.value()),
skew_deg: synth.skew().unwrap_or(0.0),
});
for c in run.visual_clusters() {
n += 1;
cx += left.take().map_or(0.0, |(byte, size)| {
let st_p = &self.env.styles[style_at(byte)];
let units = if st_p.kern.is_finite() { st_p.kern } else { 0.0 };
units / 1000.0 * size
});
let r = c.text_range();
let st_h = &self.env.styles[style_at(map(r.start))];
let kern = if st_h.kern.is_finite() { st_h.kern } else { 0.0 } / 1000.0 * run.font_size();
let drawn = c.advance() * hs;
// The glyph pen is local to the cluster: the growth is already in
// the drawn advance, and each cluster starts where the pen is.
let mut gp = 0.0f32;
for g in c.glyphs() {
out.glyphs.push(PlacedGlyph {
face,
id: g.id,
x: cx + gp + g.x,
y: merged_baseline + g.y,
style: si,
orient: GlyphOrient::Horizontal,
});
gp += g.advance * hs;
}
let adv = drawn + if n == total { 0.0 } else { kern };
out.clusters.push(ClusterInfo { range: map(r.start)..map(r.end), x: cx, advance: adv, line: merged_line, rtl: false });
cx += drawn;
merged_end = cx;
left = Some((map(r.start), run.font_size()));
}
}
if let Some(p) = out.lines.last_mut() {
p.x1 = merged_end;
p.range.end = map(line.text_range().end);
}
return true;
}
}
false
}
/// Where line `index` of `nlines` goes, advancing `flow` past it; `None` if a box clips it. /// Where line `index` of `nlines` goes, advancing `flow` past it; `None` if a box clips it.
pub(super) fn frame_line(&self, line: &Line<'_>, index: usize, nlines: usize, slot: Option<Slot>, flow: &mut Flow) -> Option<LineFrame> { pub(super) fn frame_line(&self, line: &Line<'_>, index: usize, nlines: usize, slot: Option<Slot>, flow: &mut Flow) -> Option<LineFrame> {
let env = self.env; let env = self.env;
@@ -593,8 +400,7 @@ impl Para<'_> {
let mut kern_px = self.kerning(optical, &slots); let mut kern_px = self.kerning(optical, &slots);
let (trailing_tabs, trailing_growth) = self.apply_tab_stops(&slots, &mut kern_px, f.index); let (trailing_tabs, trailing_growth) = self.apply_tab_stops(&slots, &mut kern_px, f.index);
let line_kern: f32 = kern_px.iter().sum::<f32>() - trailing_tabs; let line_kern: f32 = kern_px.iter().sum::<f32>() - trailing_tabs;
let trim = self.hanging_trim(&slots, &kern_px, f); let dx = self.line_start(f, m.offset, adv, line_kern);
let dx = self.line_start(f, m.offset, adv, line_kern, &trim);
let lc = LineCtx { dx, baseline: f.baseline, slots: &slots, kern_px: &kern_px, line_index: out.lines.len() }; let lc = LineCtx { dx, baseline: f.baseline, slots: &slots, kern_px: &kern_px, line_index: out.lines.len() };
let (g0, c0) = (out.glyphs.len(), out.clusters.len()); let (g0, c0) = (out.glyphs.len(), out.clusters.len());
let mut state = LineState { cursors: kern_cursors(line, &slots), seen_runs: Vec::new(), vinfo: Vec::new(), extra: 0.0 }; let mut state = LineState { cursors: kern_cursors(line, &slots), seen_runs: Vec::new(), vinfo: Vec::new(), extra: 0.0 };
@@ -607,7 +413,7 @@ impl Para<'_> {
state.extra -= squeeze_punctuation(env.text, &mut out.clusters[c0..], &mut out.glyphs[g0..]); state.extra -= squeeze_punctuation(env.text, &mut out.clusters[c0..], &mut out.glyphs[g0..]);
} }
if env.wraps && f.last && self.ps.align == TextAlign::JustifyAll { if env.wraps && f.last && self.ps.align == TextAlign::JustifyAll {
let slack = self.line_avail(f).unwrap_or(0.0) - (adv + state.extra - trim.advance - trim.kern); let slack = self.line_avail(f).unwrap_or(0.0) - (adv + state.extra);
state.extra += justify_line(out, c0, g0, slack); state.extra += justify_line(out, c0, g0, slack);
} }
if env.vertical { if env.vertical {
@@ -717,45 +523,3 @@ impl Para<'_> {
} }
} }
} }
/// A cluster consisting only of characters that may hang at the line's end with hanging
/// punctuation on. Photoshop's checkbox is Roman Hanging Punctuation; the left-margin marks
/// (an opening quote hangs at the left edge; apostrophes and hyphens) are in the list, but
/// the run here covers a line's trailing marks.
fn hang_cluster(text: &str) -> bool {
hang_weight(text) > 0.0
}
/// A cluster's hang weight: every character must be a hangable mark for it to hang at all,
/// and the weakest mark governs: 1.0 hangs fully outside the margin, 0.5 hangs half, 0.0
/// doesn't hang.
fn hang_weight(text: &str) -> f32 {
match text.is_empty() {
true => 0.0,
false => text.chars().map(hang_mark).fold(1.0, f32::min),
}
}
fn hang_mark(c: char) -> f32 {
match c {
// Photoshop's Help: quotes, apostrophes, hyphens, periods and commas hang 100%.
'.' | ',' | '-' | '\u{2010}' | '-' | '\'' | '"' | '‘' | '’' | '“' | '”' | '«' | '»' | ',' | '.' => 1.0,
// Colons, semicolons, ellipses and dashes hang 50%; `!` and `?` aren't in the list.
':' | ';' | '…' | '–' | '—' | ':' | ';' => 0.5,
_ => 0.0,
}
}
/// Where the hanging run at the line's tail starts: the first slot after the last non-hang,
/// non-blank cluster. Returns `(run start, tail)` — the tail sits after the last non-blank
/// slot, because a line parley wrapped ends with the breaking space, whose slot must not
/// count into the sums (nor stop the run walk). `None` when nothing hangs there or when the
/// whole line is marks (an all-mark line is the merge-back case, and its trim would fight it).
fn hang_runs(slots: &[KernSlot]) -> Option<(usize, usize)> {
let tail = slots.iter().rposition(|s| !s.blank).map_or(0, |i| i + 1);
let mut end = tail;
while end > 0 && slots[end - 1].hang_w > 0.0 {
end -= 1;
}
(end > 0 && end < tail).then_some((end, tail))
}
+1 -1
View File
@@ -189,7 +189,7 @@ pub(super) struct Para<'a> {
pub(super) start: usize, pub(super) start: usize,
pub(super) content_end: usize, pub(super) content_end: usize,
pub(super) prefix: &'static str, pub(super) prefix: &'static str,
pub(super) ptext: String, ptext: String,
synthetic_small_caps: Vec<(Range<usize>, f32)>, synthetic_small_caps: Vec<(Range<usize>, f32)>,
/// Offsets in the paragraph text of the spaces standing in for tabs. /// Offsets in the paragraph text of the spaces standing in for tabs.
pub(super) tabs: Vec<usize>, pub(super) tabs: Vec<usize>,
+1 -6
View File
@@ -52,7 +52,7 @@ pub struct TySh {
/// Whether a type transform can reach rendering or egui. Pixel coordinates use i32 throughout /// Whether a type transform can reach rendering or egui. Pixel coordinates use i32 throughout
/// the document, so non-finite and unrepresentable affine terms are rejected (#4758). /// the document, so non-finite and unrepresentable affine terms are rejected (#4758).
pub fn transform_is_safe(t: &Affine) -> bool { pub fn transform_is_safe(t: &Affine) -> bool {
t.m.iter().all(|v| v.is_finite() && v.abs() <= 1.0e9) t.m.iter().all(|v| v.is_finite() && v.abs() <= f64::from(i32::MAX))
} }
/// Parses a `TySh` block. /// Parses a `TySh` block.
@@ -292,8 +292,3 @@ mod nonfinite_tests {
assert!(out.contains("/FontSize 12.0"), "{out}"); assert!(out.contains("/FontSize 12.0"), "{out}");
} }
} }
/// Bounded finite affine terms accepted before type layout.
pub fn usable_tysh_transform(matrix: &[f64; 6]) -> bool {
transform_is_safe(&Affine { m: *matrix })
}
-2
View File
@@ -85,8 +85,6 @@ pub(crate) fn paragraph_properties(p: &ParagraphStyle, k: f32) -> E {
("SpaceBefore".into(), real(p.space_before_pt / k)), ("SpaceBefore".into(), real(p.space_before_pt / k)),
("SpaceAfter".into(), real(p.space_after_pt / k)), ("SpaceAfter".into(), real(p.space_after_pt / k)),
("AutoHyphenate".into(), E::Bool(p.hyphenate)), ("AutoHyphenate".into(), E::Bool(p.hyphenate)),
// Roman Hanging Punctuation; right after the leading keys, as Photoshop writes it.
("Hanging".into(), E::Bool(p.hanging)),
("AutoLeading".into(), real(p.auto_leading)), ("AutoLeading".into(), real(p.auto_leading)),
("LeadingType".into(), E::Int(0)), ("LeadingType".into(), E::Int(0)),
]) ])
-48
View File
@@ -298,9 +298,6 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option<TextLayer> {
layer.transform.m = corrected; layer.transform.m = corrected;
} }
} }
if !super::usable_tysh_transform(&layer.transform.m) {
return None;
}
layer.sync_summary(); layer.sync_summary();
Some(layer) Some(layer)
} }
@@ -361,9 +358,6 @@ fn legacy_point_origin(t: &TySh) -> Option<[f64; 2]> {
return None; return None;
} }
let shifted = [a * bounds[0] + c * bounds[1] + e, b * bounds[0] + d * bounds[1] + f]; let shifted = [a * bounds[0] + c * bounds[1] + e, b * bounds[0] + d * bounds[1] + f];
if !shifted.iter().all(|v| v.is_finite()) {
return None;
}
let origin_distance = e.abs().max(f.abs()); let origin_distance = e.abs().max(f.abs());
let shifted_distance = shifted[0].abs().max(shifted[1].abs()); let shifted_distance = shifted[0].abs().max(shifted[1].abs());
// A local bounding-box origin in the thousands and a transform origin beyond a normal canvas // A local bounding-box origin in the thousands and a transform origin beyond a normal canvas
@@ -635,9 +629,6 @@ pub(crate) fn para_style(base: &[Option<&E>], d: &E, k: f32) -> ParagraphStyle {
space_after_pt: numk("SpaceAfter", k).unwrap_or(0.0), space_after_pt: numk("SpaceAfter", k).unwrap_or(0.0),
auto_leading: numk("AutoLeading", 1.0).unwrap_or(1.2), auto_leading: numk("AutoLeading", 1.0).unwrap_or(1.2),
hyphenate: lookup(d, base, "AutoHyphenate").and_then(E::as_bool).unwrap_or(false), hyphenate: lookup(d, base, "AutoHyphenate").and_then(E::as_bool).unwrap_or(false),
// Roman Hanging Punctuation: Photoshop writes `/Hanging` after `/LeadingType`
// (corpus PSDs), default off when missing.
hanging: lookup(d, base, "Hanging").and_then(E::as_bool).unwrap_or(false),
..Default::default() ..Default::default()
} }
} }
@@ -795,42 +786,3 @@ pub const OPENTYPE_KEYS: [(&str, &str); 8] = [
("Ordinals", "ordn"), ("Ordinals", "ordn"),
("Fractions", "frac"), ("Fractions", "frac"),
]; ];
#[cfg(test)]
mod tysh_geometry_tests {
use super::*;
use crate::psd::{usable_tysh_transform, write_tysh};
use photocraft_psd::descriptor::UnicodeString;
#[test]
fn overflowing_legacy_origin_does_not_escape_as_a_nonfinite_transform() {
let bounds = Descriptor::new("boundingBox")
.with("Left", D::Double(1.0e308))
.with("Top ", D::Double(1.0e308))
.with("Rght", D::Double(1.5e308))
.with("Btom", D::Double(1.5e308));
let text = Descriptor::new("TxLr").with("Txt ", D::Text(UnicodeString::new_nul("x"))).with("boundingBox", D::Descriptor(bounds)).with(
"EngineData",
D::RawData(
b"<< /EngineDict << /Rendered << /Shapes << /Children [ << /Cookie << /Photoshop << /PointBase [0.0 0.0] >> >> >> ] >> >> >> >>".to_vec(),
),
);
let tysh = TySh { transform: Affine { m: [1.0e9, 0.0, -1.0e9, 0.0, 5000.0, 0.0] }, text, warp: None, bounds: [0.0; 4] };
assert!(engine_data(&tysh.text).is_some());
assert!(legacy_point_origin(&tysh).is_none());
let layer = text_layer_from_tysh(&write_tysh(&tysh), 72.0).unwrap();
assert!(usable_tysh_transform(&layer.transform.m));
}
#[test]
fn reject_unusable_matrices_before_type_layout() {
let original = TySh { transform: Affine { m: [1.0, 0.0, 0.0, 1.0, 30.0, 40.0] }, text: Descriptor::new("TxLr"), warp: None, bounds: [0.0; 4] };
let clean = write_tysh(&original);
assert_eq!(parse_tysh(&clean).unwrap().transform, original.transform);
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1.315776645269349e62] {
let mut bytes = clean.clone();
bytes[2..10].copy_from_slice(&bad.to_be_bytes());
assert!(parse_tysh(&bytes).is_none(), "invalid matrix must not be rendered: {bad}");
}
}
}
-360
View File
@@ -368,349 +368,6 @@ fn alignment_point_and_box() {
assert!((l.lines[0].x1 - 200.0).abs() < 0.5, "{:?}", l.lines[0]); assert!((l.lines[0].x1 - 200.0).abs() < 0.5, "{:?}", l.lines[0]);
} }
/// Hanging punctuation (Roman Hanging Punctuation): trailing marks don't steer the alignment.
/// A centered line keeps its text centered and lets the marks hang right instead of pulling
/// everything left — a centered text ending in an ellipsis no longer sits off-center.
#[test]
fn hanging_marks_do_not_steer_the_alignment() {
let mut e = TextEngine::new();
// The comma hangs fully and the ellipsis half (Photoshop's list), so the kept composition
// is "Hello" plus half the ellipsis.
let mut centered =
|hanging: bool| e.layout(&with_para(point("Hello,…", 20.0), ParagraphStyle { align: TextAlign::Center, hanging, ..Default::default() }), 72.0);
let off = centered(false);
let on = centered(true);
let lo = &off.lines[0];
let ln = &on.lines[0];
assert!(((lo.x0 + lo.x1) * 0.5).abs() < 0.01, "without the flag the whole line centers: {lo:?}");
// With hanging, the composition centers: the drawn extent keeps the marks' full width,
// and dropping the trimmed advance (full mark + half mark) from the drawn line leaves
// content + kept halves centered on the anchor.
let comma = on.clusters.iter().find(|c| c.range.start == 5).unwrap_or_else(|| panic!("the comma is its own cluster: {:?}", on.clusters));
let ell = on.clusters.iter().find(|c| c.range.start == 6).unwrap_or_else(|| panic!("the ellipsis is its own cluster: {:?}", on.clusters));
let trim = comma.advance + ell.advance * 0.5;
assert!(((ln.x0 + ln.x1 - trim) * 0.5).abs() < 0.05, "the drawn line less the trimmed advance centers: {ln:?}");
assert!(ln.x1 > comma.x, "the marks hang past the anchor: {ln:?}");
assert!(ln.x0 > lo.x0 + 0.5, "the content sits further right than with the marks counted: {ln:?} vs {lo:?}");
// Right-aligned point text: the content stays flush at the anchor, marks hang past it.
let mut right =
|hanging: bool| e.layout(&with_para(point("Hello,…", 20.0), ParagraphStyle { align: TextAlign::Right, hanging, ..Default::default() }), 72.0);
let roff = right(false);
let ron = right(true);
assert!(roff.lines[0].x1.abs() < 0.01, "everything ends at the anchor: {:?}", roff.lines[0]);
assert!(ron.lines[0].x1 > 0.5, "the marks hang past the anchor: {:?}", ron.lines[0]);
assert!((ron.lines[0].x0 - roff.lines[0].x0 - trim).abs() < 0.05, "the whole line shifts by the trimmed advance: {:?}", ron.lines[0]);
}
/// A hung centered line keeps its center even with inner kerning: the hung arms add
/// `kern_align` back, or the kerning runs twice.
#[test]
fn hanging_centers_a_kerned_line_exactly() {
let mut e = TextEngine::new();
let mut boxed = |hanging: bool| {
let mut t = point("Hello,…", 24.0);
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 400.0, height: 100.0 };
// Manual kerning after every character of the single run (a quarter em each): inner
// kerning is what the hung arms must not apply twice.
t.runs = vec![TextRun { len: t.text.len(), style: CharStyle { kern: 250.0, ..Default::default() } }];
e.layout(&with_para(t, ParagraphStyle { align: TextAlign::Center, hanging, ..Default::default() }), 72.0)
};
let off = boxed(false);
let on = boxed(true);
let lo = &off.lines[0];
let ln = &on.lines[0];
// Without hanging, the kerned centered line centers: `kern_align` compensates parley's
// kern-blind offset.
assert!(((lo.x0 + lo.x1) * 0.5 - 200.0).abs() < 0.01, "{lo:?}");
// With hanging, the drawn extent less the trimmed advance centers exactly. The hung arm
// adds `kern_align` back: its `slack` already deducts the kerning, so `base`'s shift
// would apply it twice and park the line left of centre.
let comma = on.clusters.iter().find(|c| c.range.start == 5).unwrap_or_else(|| panic!("the comma is its own cluster: {:?}", on.clusters));
let ell = on.clusters.iter().find(|c| c.range.start == 6).unwrap_or_else(|| panic!("the ellipsis is its own cluster: {:?}", on.clusters));
let trim = comma.advance + ell.advance * 0.5;
assert!(((ln.x0 + ln.x1 - trim) * 0.5 - 200.0).abs() < 0.05, "kerned hung line centers: {ln:?} trim {trim}");
}
/// Justify-all's last line spreads to the box edge exactly, even at horizontal scale ≠ 100%:
/// the slack counts the drawn width once (`extra` already carries the scale growth and the
/// kerning, so scaling `adv` would count the growth twice). A justify-all last line is
/// left-aligned in Photoshop, so its trailing mark stays inside for now — its trim belongs to
/// the left/justified measure gap, not to this PR's focused paths.
#[test]
fn justify_all_spreads_to_the_edge_exactly() {
let mut e = TextEngine::new();
let mut boxed = |hs: f32, hanging: bool| {
let mut t = point("ab cd,", 24.0);
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 400.0, height: 60.0 };
t.runs = vec![TextRun { len: t.text.len(), style: CharStyle { horizontal_scale: hs, ..Default::default() } }];
e.layout(&with_para(t, ParagraphStyle { align: TextAlign::JustifyAll, hanging, ..Default::default() }), 72.0)
};
let end = |l: &crate::TextLayout| l.clusters.iter().filter(|c| c.line == 0).map(|c| c.x + c.advance).fold(0.0f32, f32::max);
let base = boxed(1.0, false);
assert_eq!(base.lines.len(), 1, "{base:?}");
assert!((end(&base) - 400.0).abs() < 0.5, "spread to the edge: {}", end(&base));
// At 150% the old slack scaled `adv` on top of the growth `extra` already carries: the
// line then fell short of the edge by the whole grown amount.
let scaled = boxed(1.5, false);
assert_eq!(scaled.lines.len(), 1, "{scaled:?}");
assert!((end(&scaled) - 400.0).abs() < 0.5, "150% still spreads to the edge: {}", end(&scaled));
let hung = boxed(1.0, true);
assert!((end(&hung) - 400.0).abs() < 0.5, "the mark stays inside for now: {}", end(&hung));
}
/// Photoshop's Roman Hanging Punctuation list doesn't include `!` and `?`: they stay in the
/// measure, a comma hangs fully and a colon or semicolon hangs half ("dashes hang 50%").
#[test]
fn hanging_only_the_mark_list_trims() {
let mut e = TextEngine::new();
let mut right =
|text: &str, hanging: bool| e.layout(&with_para(point(text, 20.0), ParagraphStyle { align: TextAlign::Right, hanging, ..Default::default() }), 72.0);
// `!` isn't in the list: hanging changes nothing.
let off = right("Hi!", false);
let on = right("Hi!", true);
assert_eq!(off.lines.len(), on.lines.len());
let (lo, ln) = (&off.lines[0], &on.lines[0]);
assert!((lo.x0 - ln.x0).abs() < 1e-3 && (lo.x1 - ln.x1).abs() < 1e-3, "an exclamation stays in the measure: {lo:?} vs {ln:?}");
// A comma hangs fully: the content ends flush at the anchor and the whole mark sits
// past it.
right("Hi,", false);
let on = right("Hi,", true);
let ln = &on.lines[0];
let comma = on.clusters.iter().find(|c| c.range.start == 2).unwrap_or_else(|| panic!("the comma is its own cluster: {:?}", on.clusters));
assert!(comma.x.abs() < 0.05, "the content ends at the anchor: {ln:?}");
assert!(ln.x1 > 0.5, "the full mark hangs past the anchor: {ln:?}");
// A colon hangs half: half its width stays in the measure (the mark straddles the
// anchor), half hangs out.
right("Hi:", false);
let on = right("Hi:", true);
let ln = &on.lines[0];
let colon = on.clusters.iter().find(|c| c.range.start == 2).unwrap_or_else(|| panic!("the colon is its own cluster: {:?}", on.clusters));
assert!((colon.x + colon.advance * 0.5).abs() < 0.05, "half the colon stays, half hangs: {ln:?} mark width {}", colon.advance);
}
/// A marks-only row after a forced break is the author's row: the merge-back must not pull
/// those marks onto the line above, or the break would vanish.
#[test]
fn hanging_keeps_marks_after_a_forced_break_on_their_row() {
let mut e = TextEngine::new();
let mut boxed = |hanging: bool| {
let mut t = point("abc\u{3}…", 16.0);
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 300.0 };
e.layout(&with_para(t, ParagraphStyle { hanging, ..Default::default() }), 72.0)
};
let off = boxed(false);
let on = boxed(true);
assert_eq!(off.lines.len(), on.lines.len(), "the forced break keeps its row: {:?} vs {:?}", off.lines, on.lines);
assert_eq!(on.lines.len(), 2, "two rows: {on:?}");
let ell = on.clusters.iter().find(|c| c.range.start == 4).unwrap_or_else(|| panic!("the ellipsis is its own cluster: {:?}", on.clusters));
assert_eq!(ell.line, 1, "the marks stay on the author's row: {ell:?}");
}
/// Hanging punctuation steps a wrapped run of marks back onto the line above: a box holding
/// text plus a mark nobody could fit keeps one row, the mark hanging past the box edge, and
/// the mark belongs to that row for carets and selection.
#[test]
fn a_hanging_run_steps_back_onto_the_line_above() {
let mut e = TextEngine::new();
// A box just wide enough for "xx" (probed left-aligned at the same size).
let probe = e.layout(&point("xx", 20.0), 72.0);
let w = probe.lines[0].x1 - probe.lines[0].x0;
let mut boxed = |hanging: bool| {
let mut t = point("xx …", 20.0);
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: w + 0.5, height: 100.0 };
e.layout(&with_para(t, ParagraphStyle { hanging, ..Default::default() }), 72.0)
};
let off = boxed(false);
assert_eq!(off.lines.len(), 2, "the ellipsis wraps onto its own row: {:?}", off.lines);
let on = boxed(true);
assert_eq!(on.lines.len(), 1, "the mark steps back up: {:?}", on.lines);
let ln = &on.lines[0];
assert!(ln.x1 > w + 0.5, "the mark hangs past the box edge: {ln:?}");
assert_eq!(ln.range.end, 6, "the row's range now covers the mark ({})", ln.range.end);
let mark = on.clusters.iter().find(|c| c.range.start == 3).unwrap_or_else(|| panic!("the ellipsis is its own cluster: {:?}", on.clusters));
// The row above keeps its trailing breaking space (as a blank cluster); the mark starts
// past it, so hit-testing can tell the space from the mark.
let space = on.clusters.iter().find(|c| c.range.start == 2).unwrap_or_else(|| panic!("the space is its own cluster: {:?}", on.clusters));
assert!((mark.x - (space.x + space.advance)).abs() < 1e-3, "mark follows the space: {mark:?} {space:?}");
assert_eq!(mark.line, 0, "the mark's caret lives on the merged row: {mark:?}");
assert!(on.glyphs.iter().any(|g| g.x >= w && (g.y - ln.baseline).abs() < 1e-3), "the mark's glyphs draw on that row past the edge: {ln:?}");
}
/// The edge tip that steps back keeps its clusters and glyphs in step: each cluster starts
/// where the previous one's advance ended, and a mark's glyphs sit relative to its own cluster
/// (a per-cluster pen), not wherever a shared pen would drop them.
#[test]
fn a_stepped_mark_row_keeps_its_clusters_and_glyphs_in_step() {
let mut e = TextEngine::new();
let probe = e.layout(&point("xx", 20.0), 72.0);
let w = probe.lines[0].x1 - probe.lines[0].x0;
let mut t = point("xx ……", 20.0);
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: w + 0.5, height: 100.0 };
let on = e.layout(&with_para(t, ParagraphStyle { hanging: true, ..Default::default() }), 72.0);
// The edge tip is one mark: the first ellipsis steps back up, the second keeps its row
// (Photoshop hangs the marks at the edge, not the run — a row that merges must not
// chain another one onto it, or pushed mark rows would fold runs into one line).
assert_eq!(on.lines.len(), 2, "the tip steps up, the rest keeps its row: {:?}", on.lines);
// The row above keeps its trailing breaking space (as a blank cluster); the mark starts
// past it, so hit-testing can tell the space from the mark.
let space = on.clusters.iter().find(|c| c.range.start == 2).unwrap_or_else(|| panic!("the space is its own cluster: {:?}", on.clusters));
let first = on.clusters.iter().find(|c| c.range.start == 3).unwrap_or_else(|| panic!("the first mark is its own cluster: {:?}", on.clusters));
let second = on.clusters.iter().find(|c| c.range.start == 6).unwrap_or_else(|| panic!("the second mark is its own cluster: {:?}", on.clusters));
assert_eq!(second.line, 1, "the second mark keeps its own row: {second:?}");
assert!((first.x - (space.x + space.advance)).abs() < 1e-3, "run starts past the space: {first:?} {space:?}");
// The same glyph in either cluster carries the same offset from its own cluster: a shared
// (run-wide) pen would leave the second mark's glyph an advance too far right.
let glyph_shift = |c: &crate::ClusterInfo| on.glyphs.iter().filter(|g| g.x >= c.x - 0.5).map(|g| g.x - c.x).min_by(|a, b| a.partial_cmp(b).unwrap());
let d1 = glyph_shift(first).unwrap_or(f32::NAN);
let d2 = glyph_shift(second).unwrap_or(f32::NAN);
assert!((d1 - d2).abs() < 1e-3, "glyph pens belong to their clusters: {d1:?} vs {d2:?}");
}
/// A long run of marks the box had to split over several rows keeps its breaks with hanging
/// on: Photoshop hangs the edge tip, not the run, so each row keeps its own row and no line
/// overflows the box by almost the whole run. A full row of marks (more than two clusters)
/// does not merge back into the line above; only tipped rows of a couple of marks do.
#[test]
fn hanging_marks_of_a_split_run_keep_their_breaks() {
let mut e = TextEngine::new();
let mut boxed = |hanging: bool| {
let mut t = point("Loading..................................", 16.0); // 34 dots
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 200.0, height: 400.0 };
e.layout(&with_para(t, ParagraphStyle { hanging, ..Default::default() }), 72.0)
};
let off = boxed(false);
assert!(off.lines.len() >= 2, "the run wraps over several rows: {:?}", off.lines);
let on = boxed(true);
assert!(on.lines.len() >= 2, "the breaks stay: {:?}", on.lines);
// No line runs past the box edge by more than an edge tip (a couple of marks): the fold
// that merged every row into one line overflowed by almost the whole run.
let adv = on.clusters.iter().map(|c| c.advance).fold(0.0f32, f32::max);
for (i, l) in on.lines.iter().enumerate() {
assert!(l.x1 - l.x0 <= 200.0 + 2.0 * adv + 0.5, "line {i} overflows the box past the tip: {l:?} (tip {adv})");
}
}
/// The edge-tip cap pins a case the chain guard alone doesn't: a run of marks pushed whole
/// as one row after a line that does NOT end in a mark. The chain guard only refuses the
/// second mark row onwards, so without the at-most-two-cluster cap that full row would step
/// back up and a `word` followed by thirty hyphens would make a 200 px box overflow by a
/// whole row's worth of marks. With the cap the rows keep their breaks, as with hanging off.
#[test]
fn a_full_mark_row_does_not_step_back_onto_a_short_line() {
let mut e = TextEngine::new();
let hyphens = "word ------------------------------"; // 30 marks, 200 px holds ~28
let mut boxed = |hanging: bool| {
let mut t = point(hyphens, 16.0);
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 200.0, height: 400.0 };
e.layout(&with_para(t, ParagraphStyle { hanging, ..Default::default() }), 72.0)
};
let off = boxed(false);
assert!(off.lines.len() >= 3, "the run wraps over rows: {:?}", off.lines);
let on = boxed(true);
assert_eq!(on.lines.len(), off.lines.len(), "the full mark row keeps its break: {:?} vs {:?}", on.lines, off.lines);
for (a, b) in off.lines.iter().zip(&on.lines) {
assert!((a.x0 - b.x0).abs() < 1e-3 && (a.x1 - b.x1).abs() < 1e-3, "same rows either way: {a:?} vs {b:?}");
}
let adv = on.clusters.iter().map(|c| c.advance).fold(0.0f32, f32::max);
for (i, l) in on.lines.iter().enumerate() {
assert!(l.x1 - l.x0 <= 200.0 + 2.0 * adv + 0.5, "line {i} steps a full mark row back up: {l:?} (tip {adv})");
}
}
/// The last line of a justified paragraph keeps its own last-line alignment: a marks-only
/// row that the box pushed out must not merge into the justified row above, whose placed
/// clusters already carry the spread — that would spread the real last line edge to edge
/// instead of centering, righting or lefting it.
#[test]
fn hanging_marks_stay_off_a_justified_paragraphs_last_line() {
let mut e = TextEngine::new();
let probe = e.layout(&point("aa bb cc", 20.0), 72.0);
let w = probe.lines[0].x1 - probe.lines[0].x0;
let mut boxed = |align: TextAlign, hanging: bool| {
let mut t = point("aa bb cc …", 20.0);
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: w + 1.0, height: 100.0 };
e.layout(&with_para(t, ParagraphStyle { align, hanging, ..Default::default() }), 72.0)
};
for (align, placed) in [(TextAlign::JustifyCenter, "centered"), (TextAlign::JustifyRight, "right"), (TextAlign::JustifyAll, "left")] {
let off = boxed(align, false);
let on = boxed(align, true);
assert_eq!(off.lines.len(), 2, "{align:?}: the ellipsis wraps to its own row: {off:?}");
assert_eq!(on.lines.len(), off.lines.len(), "{align:?}: the mark row keeps its row: {on:?}");
for (a, b) in off.lines.iter().zip(&on.lines) {
assert!((a.x0 - b.x0).abs() < 1e-3 && (a.x1 - b.x1).abs() < 1e-3, "{align:?}: the {placed} last line keeps its place: {a:?} vs {b:?}");
}
// The last line sits where its alignment puts it, in either setting.
let ell = on.clusters.iter().find(|c| c.range.start == 9).unwrap_or_else(|| panic!("{align:?}: the ellipsis is its own cluster: {:?}", on.clusters));
match align {
TextAlign::JustifyCenter => {
assert!((ell.x + ell.advance * 0.5 - (w + 1.0) * 0.5).abs() < 0.5, "{align:?}: the {placed} last line centers: {ell:?}")
}
TextAlign::JustifyRight => assert!((ell.x + ell.advance - (w + 1.0)).abs() < 0.5, "{align:?}: the {placed} last line rights: {ell:?}"),
_ => assert!(ell.x.abs() < 0.5, "{align:?}: the {placed} last line lefts: {ell:?}"),
}
}
}
/// Hanging punctuation trims out of centered box lines too, not only ones at the margin: a
/// centered `Hello…` line that doesn't come near the box edges still centers its text and
/// lets the mark hang.
#[test]
fn hanging_centers_the_box_content_behind_its_marks() {
let mut e = TextEngine::new();
let mut boxed = |hanging: bool| {
let mut t = point("Hello…", 20.0);
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 400.0, height: 60.0 };
e.layout(&with_para(t, ParagraphStyle { align: TextAlign::Center, hanging, ..Default::default() }), 72.0)
};
let off = boxed(false);
let on = boxed(true);
let lo = &off.lines[0];
let ln = &on.lines[0];
assert_eq!(off.lines.len(), 1);
assert_eq!(on.lines.len(), 1);
assert!(((lo.x0 + lo.x1) * 0.5 - 200.0).abs() < 0.01, "without the flag the whole line centers: {lo:?}");
// With hanging, the composition centers: dropping the kept advance (half the ellipsis)
// from the drawn extent leaves content + kept half centered in the box.
let ell = on.clusters.iter().find(|c| c.range.start == 5).unwrap_or_else(|| panic!("the mark is its own cluster: {:?}", on.clusters));
let trim = ell.advance * 0.5;
assert!(((ln.x0 + ln.x1 - trim) * 0.5 - 200.0).abs() < 0.05, "drawn less trim centers: {ln:?} mark at {}", ell.x);
assert!(ln.x1 > ell.x, "the mark hangs past the content: {ln:?}");
}
/// Two forced breaks in a row keep their blank paragraph row with hanging on: a row with no
/// glyphs must not be read as a hanging run and merged into the line above.
#[test]
fn hanging_keeps_empty_rows_between_forced_breaks() {
let mut e = TextEngine::new();
let mut boxed = |hanging: bool| {
let mut t = point("abc\u{3}\u{3}def", 16.0);
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 300.0 };
e.layout(&with_para(t, ParagraphStyle { hanging, ..Default::default() }), 72.0)
};
assert_eq!(boxed(false).lines.len(), boxed(true).lines.len(), "same rows either way");
}
/// Hanging changes nothing when no line ends in a mark: the same breaks at the same place.
#[test]
fn hanging_leaves_plain_paragraphs_untouched() {
let mut e = TextEngine::new();
let mut boxed = |hanging: bool| {
let mut t = point("The quick brown fox jumps over the lazy dog", 16.0);
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 160.0, height: 300.0 };
e.layout(&with_para(t, ParagraphStyle { hanging, ..Default::default() }), 72.0)
};
let off = boxed(false);
let on = boxed(true);
assert_eq!(off.lines.len(), on.lines.len(), "same breaks");
for (a, b) in off.lines.iter().zip(&on.lines) {
assert!((a.x0 - b.x0).abs() < 1e-3 && (a.x1 - b.x1).abs() < 1e-3, "same place: {a:?} vs {b:?}");
}
}
#[test] #[test]
fn rtl_text_is_reordered() { fn rtl_text_is_reordered() {
let mut e = TextEngine::new(); let mut e = TextEngine::new();
@@ -2145,23 +1802,6 @@ fn legacy_origin_uses_the_rendering_engines_registered_face() {
assert!((placed.x - 100.0).abs() < 1e-6 && (placed.y - 200.0).abs() < 1e-6, "{placed:?}"); assert!((placed.x - 100.0).abs() < 1e-6 && (placed.y - 200.0).abs() < 1e-6, "{placed:?}");
} }
/// Hanging travels in the text engine data under the paragraph sheet's `Hanging` key, written
/// explicitly on and off so Photoshop keeps the flag; the corpus files show it right after
/// `/LeadingType` (false), as Photoshop's own writer places it.
#[test]
fn hanging_punctuation_writes_into_the_paragraph_sheet() {
let raw = |t: &TextLayer, what: &str| {
let data = crate::engine_data::write(&crate::psd::build_engine_data(t, None, 72.0));
String::from_utf8_lossy(&data).lines().find(|l| l.contains("Hanging")).unwrap_or("<no Hanging key>").to_owned() + format!(" ({what})").as_str()
};
let on = with_para(point("Hello, world.", 12.0), ParagraphStyle { hanging: true, ..Default::default() });
let got = raw(&on, "on");
assert!(got.contains("/Hanging true"), "flag on: {got}");
let off = with_para(point("Hello, world.", 12.0), ParagraphStyle::default());
let got = raw(&off, "off");
assert!(got.contains("/Hanging false"), "flag off: {got}");
}
/// Regression: a PSD whose text engine data has no `EngineDict` (or isn't a dictionary) /// Regression: a PSD whose text engine data has no `EngineDict` (or isn't a dictionary)
/// panicked with `expect("EngineDict")` when the layer was written back (PSD export). /// panicked with `expect("EngineDict")` when the layer was written back (PSD export).
#[test] #[test]
-42
View File
@@ -1,42 +0,0 @@
//! RTL label regression compatibility for native egui paragraph ordering.
//! Keep logical text to prevent a second bidi pre-pass from reversing words twice.
use std::borrow::Cow;
/// Does `text` contain a character from a right-to-left script?
pub fn has_rtl(text: &str) -> bool {
text.chars().any(rtl_script)
}
/// Characters egui shapes right to left: those of the right-to-left scripts (HarfRust guesses a
/// run's direction from its script), including their marks and digits. The joiners count too,
/// so a Persian word with a ZWNJ stays one stretch.
fn rtl_script(c: char) -> bool {
matches!(
c as u32,
0x0590..=0x05FF // Hebrew
| 0x0600..=0x06FF // Arabic (letters, marks, Arabic-Indic and Persian digits)
| 0x0700..=0x074F // Syriac
| 0x0750..=0x077F // Arabic Supplement
| 0x0780..=0x07BF // Thaana
| 0x07C0..=0x07FF // NKo
| 0x0860..=0x08FF // Syriac Supplement, Arabic Extended-A/B
| 0xFB1D..=0xFB4F // Hebrew presentation forms
| 0xFB50..=0xFDFF // Arabic Presentation Forms-A
| 0xFE70..=0xFEFF // Arabic Presentation Forms-B
| 0x10800..=0x10FFF // Historic RTL scripts
| 0x1E800..=0x1EFFF // Mende Kikakui, Adlam, Arabic mathematical symbols
) || matches!(c, '\u{200C}' | '\u{200D}')
}
/// `text` arranged so that egui draws it in visual order. Text without right-to-left characters
/// is returned unchanged.
/// Native egui 0.37 at the pinned revision owns UAX #9 ordering. A second pre-pass would
/// reverse multiword RTL labels twice. Keep the logical text for shaping and accessibility.
pub fn for_egui(text: &str) -> Cow<'_, str> {
Cow::Borrowed(text)
}
#[cfg(test)]
#[path = "bidi_label_tests.rs"]
mod tests;
-153
View File
@@ -1,153 +0,0 @@
//! The bidi pre-pass: string rules everywhere, and what egui actually draws when an Arabic or
//! Hebrew system font is installed.
use super::*;
use egui::epaint::text::{FontData, FontDefinitions, FontFamily, Fonts, LayoutJob, TextFormat, TextOptions};
use std::sync::Arc;
#[test]
fn text_without_right_to_left_characters_is_untouched() {
for s in ["Layer 1", "", "Background copy 2", "图层 1", "Ебать"] {
assert!(matches!(for_egui(s), Cow::Borrowed(t) if t == s), "{s}");
}
}
#[test]
fn right_to_left_words_swap_places_but_keep_their_letters() {
// egui reverses each word's letters itself; only the word order changes here.
assert_eq!(for_egui("سلام عليكم"), "سلام عليكم");
assert_eq!(for_egui("שלום עולם"), "שלום עולם");
assert_eq!(for_egui("ئەمە تێستە بۆ ئیش"), "ئەمە تێستە بۆ ئیش");
// One word: nothing to reorder.
assert_eq!(for_egui("طبقة"), "طبقة");
}
#[test]
fn numbers_and_latin_keep_reading_left_to_right() {
// European digits and Latin are runs of their own, in the right visual place.
assert_eq!(for_egui("عدد 123 مرحبا"), "عدد 123 مرحبا");
assert_eq!(for_egui("طبقة 1 Layer"), "طبقة 1 Layer");
// Arabic-Indic digits are Arabic-script, which egui reverses: pre-reversed so they read ١٢٣.
assert_eq!(for_egui("شلام ١٢٣ x"), "شلام ١٢٣ x");
// A left-to-right label keeps its order; a number right after an Arabic word belongs to it
// (UAX #9 W2), as in browsers.
assert_eq!(for_egui("Layer طبقة"), "Layer طبقة");
assert_eq!(for_egui("Layer طبقة 2"), "Layer طبقة 2");
}
#[test]
fn persian_joiners_stay_inside_their_word() {
// "می‌خواهم" has a ZWNJ: still one word, letters untouched.
assert_eq!(for_egui("می\u{200C}خواهم سلام"), "می\u{200C}خواهم سلام");
}
/// An installed font covering `sample`, if any of the usual ones is present.
fn system_font(candidates: &[&str]) -> Option<Vec<u8>> {
candidates.iter().find_map(|p| {
let bytes = std::fs::read(p).ok()?;
eprintln!("rtl-test font: {p}");
Some(bytes)
})
}
const ARABIC_FONTS: [&str; 6] = [
"/System/Library/Fonts/GeezaPro.ttc",
"/System/Library/Fonts/SFArabic.ttf",
"/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf",
"/usr/share/fonts/noto/NotoSansArabic-Regular.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"C:\\Windows\\Fonts\\arial.ttf",
];
fn fonts_with(bytes: Vec<u8>) -> Fonts {
let mut defs = FontDefinitions::default();
defs.font_data.insert("rtl-test".into(), Arc::new(FontData::from_owned(bytes)));
if let Some(family) = defs.families.get_mut(&FontFamily::Proportional) {
family.push("rtl-test".into());
}
Fonts::new(TextOptions::default(), defs)
}
/// x of each drawn (non-continuation) glyph, with the character it was attributed to.
fn drawn(fonts: &mut Fonts, text: &str) -> Vec<(char, f32)> {
let mut job = LayoutJob::default();
job.append(text, 0.0, TextFormat { font_id: egui::FontId::proportional(20.0), ..Default::default() });
let galley = fonts.with_pixels_per_point(1.0).layout_job(job);
galley.rows.iter().flat_map(|r| r.glyphs.iter()).filter(|g| !g.uv_rect.is_nothing()).map(|g| (g.chr, g.pos.x)).collect()
}
fn x_of(glyphs: &[(char, f32)], c: char) -> f32 {
glyphs.iter().find(|(g, _)| *g == c).map(|(_, x)| *x).unwrap_or_else(|| panic!("{c} drawn: {glyphs:?}"))
}
#[test]
fn arabic_draws_first_word_rightmost_with_letters_joined() {
let Some(bytes) = system_font(&ARABIC_FONTS) else {
eprintln!("skipped: no Arabic system font");
return;
};
let mut fonts = fonts_with(bytes);
let shown = drawn(&mut fonts, &for_egui("سلام عليكم"));
// The first word (سلام) is on the right: its first letter right of the second word's.
assert!(x_of(&shown, 'س') > x_of(&shown, 'ع'), "{shown:?}");
// سلام draws as three glyphs (س, the لا ligature, م): the letters kept their logical order.
// Reversed letters (مالس) can't form the ligature and draw four.
let word = drawn(&mut fonts, &for_egui("سلام"));
let mirrored = drawn(&mut fonts, "مالس");
assert!(word.len() < mirrored.len(), "ligature: {word:?} vs {mirrored:?}");
}
#[test]
fn hebrew_draws_first_word_rightmost() {
let Some(bytes) = system_font(&[
"/System/Library/Fonts/SFHebrew.ttf",
"/System/Library/Fonts/ArialHB.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"C:\\Windows\\Fonts\\arial.ttf",
]) else {
eprintln!("skipped: no Hebrew system font");
return;
};
let mut fonts = fonts_with(bytes);
let shown = drawn(&mut fonts, &for_egui("שלום עולם"));
// ש starts the first word, ע the second: ש is further right.
assert!(x_of(&shown, 'ש') > x_of(&shown, 'ע'), "{shown:?}");
}
#[test]
fn a_long_right_to_left_layer_name_keeps_its_beginning() {
let Some(bytes) = system_font(&ARABIC_FONTS) else {
eprintln!("skipped: no Arabic system font");
return;
};
let name = "سلام عليكم يا أصدقاء الطبقات";
let mut defs = FontDefinitions::default();
defs.font_data.insert("rtl-test".into(), Arc::new(FontData::from_owned(bytes)));
if let Some(family) = defs.families.get_mut(&FontFamily::Proportional) {
family.push("rtl-test".into());
}
let long_fonts = defs.clone();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 100.0)).with_context_setup(move |ctx| ctx.set_fonts(long_fonts)).build_ui_state(
|ui, out: &mut Option<Arc<egui::Galley>>| {
*out = crate::layer_row_ui::truncated(ui.painter(), name, egui::FontId::proportional(20.0), egui::Color32::WHITE, false, 90.0);
},
None,
);
h.run_steps(3);
let g = h.state().clone().expect("room for a cut name");
let shown = g.job.text.clone();
assert!(g.size().x <= 90.0, "{} wide", g.size().x);
// Cut in logical order: the first word survives, and "…" sits at the reading end (left).
assert!(shown.ends_with('…') && shown.starts_with("سلام") && !shown.contains("الطبقات"), "{shown}");
let glyphs: Vec<_> = g.rows.iter().flat_map(|r| r.glyphs.iter()).filter(|g| !g.uv_rect.is_nothing()).map(|g| (g.chr, g.pos.x)).collect();
assert!(x_of(&glyphs, '…') < x_of(&glyphs, 'س'), "ellipsis stays at the visual reading end: {glyphs:?}");
// Short enough to fit: drawn whole.
let mut short = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 100.0)).with_context_setup(move |ctx| ctx.set_fonts(defs)).build_ui_state(
|ui, out: &mut Option<Arc<egui::Galley>>| {
*out = crate::layer_row_ui::truncated(ui.painter(), "سلام عليكم", egui::FontId::proportional(20.0), egui::Color32::WHITE, false, 300.0);
},
None,
);
short.run_steps(1);
assert_eq!(short.state().as_ref().map(|g| g.job.text.clone()).as_deref(), Some("سلام عليكم"));
}
+4 -19
View File
@@ -1656,14 +1656,7 @@ fn finish_canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, view: &mut Vie
// Scrollbars (scrollbars.rs): drawn over the canvas edges, they take the pointer there. // Scrollbars (scrollbars.rs): drawn over the canvas edges, they take the pointer there.
let t0 = crate::gpu_canvas::now_ms(); let t0 = crate::gpu_canvas::now_ms();
let before = view.center; let before = view.center;
let mode = app.session.prefs().interface.scrollbar_visibility; let over_bars = app.ui.view.shows_scrollbars()
let visible = match mode {
photocraft_engine::prefs::ScrollbarVisibility::Hidden => false,
photocraft_engine::prefs::ScrollbarVisibility::AutoHide => ui.rect_contains_pointer(rect) || ui.input(|i| i.pointer.any_down()),
_ => true,
};
let over_bars = visible
&& app.ui.view.shows_scrollbars()
&& crate::scrollbars::show(ui, rect, view, flip, aspect, app.session.prefs().tools.overscroll, egui::Id::unique(("pc-canvas", idx))); && crate::scrollbars::show(ui, rect, view, flip, aspect, app.session.prefs().tools.overscroll, egui::Id::unique(("pc-canvas", idx)));
ctx.data_mut(|d| d.insert_temp(bars_id, over_bars)); ctx.data_mut(|d| d.insert_temp(bars_id, over_bars));
if view.center != before { if view.center != before {
@@ -1962,8 +1955,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// (Space) and a middle-button drag still pan, and a secondary window's Hand never stops, as the // (Space) and a middle-button drag still pan, and a secondary window's Hand never stops, as the
// box is only drawn and driven on the primary canvas. // box is only drawn and driven on the primary canvas.
let transform_owns_pointer = primary && app.ui.transform.is_some() && temporary.is_none() && !middle; let transform_owns_pointer = primary && app.ui.transform.is_some() && temporary.is_none() && !middle;
let middle_rotate = middle && ui.input(|i| i.modifiers.shift); let hand_pan = tool == Tool::Hand && !transform_owns_pointer;
let hand_pan = tool == Tool::Hand && !transform_owns_pointer && !middle_rotate;
// Preferences › Tools › Enable Flick Panning: a Hand drag let go while moving glides on. // Preferences › Tools › Enable Flick Panning: a Hand drag let go while moving glides on.
let flick_id = egui::Id::unique(("pc-flick-pan", idx, primary, ctx.viewport_id())); let flick_id = egui::Id::unique(("pc-flick-pan", idx, primary, ctx.viewport_id()));
let flick_on = app.session.prefs().tools.enable_flick_panning; let flick_on = app.session.prefs().tools.enable_flick_panning;
@@ -1980,14 +1972,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
ctx.data_mut(|m| m.get_temp_mut_or_default::<crate::flick_pan::Flick>(flick_id).stop()); ctx.data_mut(|m| m.get_temp_mut_or_default::<crate::flick_pan::Flick>(flick_id).stop());
} }
} }
if middle_rotate && response.dragged() && !stroke_dismissal { if hand_pan && response.dragged() && !stroke_dismissal {
if let Some(pointer) = response.interact_pointer_pos()
&& crate::rotate_view::middle_drag(&mut view, &xf, pointer, response.drag_delta().x)
{
ctx.request_repaint();
}
ctx.set_cursor_icon(crate::rotate_view::cursor(true));
} else if hand_pan && response.dragged() && !stroke_dismissal {
let d = xf.unmap_vec(response.drag_delta()) / (view.zoom / ppp); let d = xf.unmap_vec(response.drag_delta()) / (view.zoom / ppp);
view.center[0] -= d.x; view.center[0] -= d.x;
view.center[1] -= d.y; view.center[1] -= d.y;
@@ -1995,7 +1980,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
ctx.set_cursor_icon(crate::tool_cursor::hand(&ctx, true)); ctx.set_cursor_icon(crate::tool_cursor::hand(&ctx, true));
} else if primary && !stroke_dismissal && temporary.is_none() && !middle && crate::layer_picker::armed(app).is_some() { } else if primary && !stroke_dismissal && temporary.is_none() && !middle && crate::layer_picker::armed(app).is_some() {
layer_picker_eyedropper(app, ui, &ctx, &response, &xf); layer_picker_eyedropper(app, ui, &ctx, &response, &xf);
} else if primary && !stroke_dismissal && !middle_rotate { } else if primary && !stroke_dismissal {
let input = CanvasToolInput { let input = CanvasToolInput {
ctx: &ctx, ctx: &ctx,
response: &response, response: &response,
@@ -210,10 +210,6 @@ const CROP_SHOW: &str = "crop.overlayShow.";
pub const CROP_CLEAR: &str = "crop.clear"; pub const CROP_CLEAR: &str = "crop.clear";
pub const CROP_NEW: &str = "crop.newPreset"; pub const CROP_NEW: &str = "crop.newPreset";
pub const CROP_DELETE: &str = "crop.deletePreset"; pub const CROP_DELETE: &str = "crop.deletePreset";
pub const CROP_CLASSIC: &str = "crop.overlay.classicMode";
pub const CROP_SHOW_CROPPED: &str = "crop.overlay.showCroppedArea";
pub const CROP_AUTO_CENTER: &str = "crop.overlay.autoCenterPreview";
pub const CROP_RESET: &str = "crop.reset";
pub const CROP_CANCEL: &str = "crop.cancel"; pub const CROP_CANCEL: &str = "crop.cancel";
pub const CROP_COMMIT: &str = "crop.commit"; pub const CROP_COMMIT: &str = "crop.commit";
@@ -237,13 +233,6 @@ pub fn crop_rows(menu: &CanvasToolMenu) -> Vec<Row<String>> {
rows.push(None); rows.push(None);
rows.extend(OverlayShow::ALL.iter().map(|k| Some((k.label().to_string(), format!("{CROP_SHOW}{}", k.id()))))); rows.extend(OverlayShow::ALL.iter().map(|k| Some((k.label().to_string(), format!("{CROP_SHOW}{}", k.id())))));
rows.push(None); rows.push(None);
rows.extend([
Some(("Use Classic Mode".into(), CROP_CLASSIC.into())),
Some(("Show Cropped Area".into(), CROP_SHOW_CROPPED.into())),
Some(("Auto Center Preview".into(), CROP_AUTO_CENTER.into())),
None,
Some(("Reset".into(), CROP_RESET.into())),
]);
rows.push(Some(("Cancel".into(), CROP_CANCEL.into()))); rows.push(Some(("Cancel".into(), CROP_CANCEL.into())));
rows.push(Some(("Crop".into(), CROP_COMMIT.into()))); rows.push(Some(("Crop".into(), CROP_COMMIT.into())));
rows rows
@@ -270,12 +259,6 @@ fn crop_preset<'a>(menu: &'a CanvasToolMenu, command: &str) -> Option<&'a String
/// the overlay and when it shows. /// the overlay and when it shows.
fn crop_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { fn crop_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool {
let o = &app.ui.tool_options; let o = &app.ui.tool_options;
match command {
CROP_CLASSIC => return o.crop_shield.classic_mode,
CROP_SHOW_CROPPED => return o.crop_shield.shows_cropped_area(),
CROP_AUTO_CENTER => return o.crop_shield.auto_center_preview,
_ => {}
}
let user = crate::crop_presets::current_user(app); let user = crate::crop_presets::current_user(app);
if let Some(k) = command.strip_prefix(CROP_RATIO) { if let Some(k) = command.strip_prefix(CROP_RATIO) {
return user.is_none() && k == o.crop_ratio; return user.is_none() && k == o.crop_ratio;
@@ -291,9 +274,6 @@ fn crop_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bo
fn crop_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { fn crop_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool {
match command { match command {
CROP_CLASSIC => true,
CROP_SHOW_CROPPED | CROP_AUTO_CENTER => !app.ui.tool_options.crop_shield.classic_mode,
CROP_RESET | CROP_CANCEL | CROP_COMMIT => crate::crop_ui::pending(app),
CROP_NEW => crate::crop_presets::savable(&app.ui.tool_options).is_some(), CROP_NEW => crate::crop_presets::savable(&app.ui.tool_options).is_some(),
CROP_DELETE => menu.crop.as_ref().is_some_and(|u| !u.is_empty()), CROP_DELETE => menu.crop.as_ref().is_some_and(|u| !u.is_empty()),
c => is_crop(menu, c), c => is_crop(menu, c),
@@ -333,21 +313,6 @@ fn choose_crop(app: &mut PhotocraftApp, menu: &CanvasToolMenu, command: &str) ->
use crate::crop_overlay::{CropOverlay, OverlayShow}; use crate::crop_overlay::{CropOverlay, OverlayShow};
use crate::crop_presets::{Choice, apply_choice}; use crate::crop_presets::{Choice, apply_choice};
match command { match command {
CROP_RESET => crate::crop_ui::reset(app),
CROP_CLASSIC => {
app.ui.tool_options.crop_shield.classic_mode = !app.ui.tool_options.crop_shield.classic_mode;
crate::crop_mode::sync(app, None);
}
CROP_SHOW_CROPPED => {
if !app.ui.tool_options.crop_shield.classic_mode {
app.ui.tool_options.crop_shield.show_cropped_area = !app.ui.tool_options.crop_shield.show_cropped_area;
}
}
CROP_AUTO_CENTER => {
if !app.ui.tool_options.crop_shield.classic_mode {
app.ui.tool_options.crop_shield.auto_center_preview = !app.ui.tool_options.crop_shield.auto_center_preview;
}
}
CROP_CANCEL => crate::crop_ui::cancel(app), CROP_CANCEL => crate::crop_ui::cancel(app),
CROP_COMMIT => crate::canvas::commit_crop(app), CROP_COMMIT => crate::canvas::commit_crop(app),
CROP_NEW => apply_choice(app, Choice::New), CROP_NEW => apply_choice(app, Choice::New),
@@ -1218,130 +1218,3 @@ fn crop_menu_rows_act_like_the_options_bar() {
choose(&mut app, &ctx, CROP_CANCEL); choose(&mut app, &ctx, CROP_CANCEL);
assert!(!crate::crop_ui::pending(&app)); assert!(!crate::crop_ui::pending(&app));
} }
/// The Crop tool with a real pending frame on the 32 x 32 document.
fn integration_crop_app() -> PhotocraftApp {
let mut app = app();
app.ui.tool = Tool::Crop;
app.ui.crop_rect = Some([10.0, 5.0, 30.0, 25.0]);
app.crop.default_frame = false;
app.crop.editing = true;
assert!(crate::crop_ui::pending(&app));
app
}
fn crop_open(app: &mut PhotocraftApp) {
assert!(open_crop(app, &Context::default(), Tool::Crop, [10.0, 10.0]));
}
fn frame_ratio(app: &PhotocraftApp) -> f64 {
let r = app.ui.crop_rect.unwrap();
(r[2] - r[0]) / (r[3] - r[1])
}
#[test]
fn crop_menu_ratio_rows_fit_the_frame_like_the_options_bar() {
let mut app = integration_crop_app();
crop_open(&mut app);
let menu = app.ui.canvas_tool_menu.clone().unwrap();
assert!(menu.crop.is_some() && available(&app, &menu, "crop.ratio.16:9"));
choose(&mut app, &Context::default(), "crop.ratio.16:9");
assert_eq!(app.ui.tool_options.crop_ratio, "16:9");
assert!((frame_ratio(&app) - 16.0 / 9.0).abs() < 1e-9, "the frame is fitted to the ratio");
// The current ratio shows checked; the others do not.
crop_open(&mut app);
let menu = app.ui.canvas_tool_menu.clone().unwrap();
assert!(mode_checked(&app, &menu, "crop.ratio.16:9") && !mode_checked(&app, &menu, "crop.ratio.1:1"));
choose(&mut app, &Context::default(), "crop.ratio.1:1");
assert!((frame_ratio(&app) - 1.0).abs() < 1e-9);
}
#[test]
fn crop_menu_gear_rows_toggle_the_shield_options() {
let mut app = integration_crop_app();
crop_open(&mut app);
choose(&mut app, &Context::default(), CROP_SHOW_CROPPED);
assert!(!app.ui.tool_options.crop_shield.show_cropped_area);
crop_open(&mut app);
assert!(!mode_checked(&app, app.ui.canvas_tool_menu.as_ref().unwrap(), CROP_SHOW_CROPPED), "the row shows the option's state");
choose(&mut app, &Context::default(), CROP_CLASSIC);
assert!(app.ui.tool_options.crop_shield.classic_mode);
// As in the gear menu, Classic Mode greys out the other two options.
crop_open(&mut app);
let menu = app.ui.canvas_tool_menu.clone().unwrap();
assert!(!entry_enabled(&app, &menu, CROP_SHOW_CROPPED) && !entry_enabled(&app, &menu, CROP_AUTO_CENTER));
assert!(entry_enabled(&app, &menu, CROP_CLASSIC));
choose(&mut app, &Context::default(), CROP_CLASSIC);
assert!(!app.ui.tool_options.crop_shield.classic_mode);
}
#[test]
fn crop_menu_reset_cancel_and_commit_act_on_the_frame() {
// Reset: the whole canvas, still a pending frame.
let mut app = integration_crop_app();
crop_open(&mut app);
choose(&mut app, &Context::default(), CROP_RESET);
assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 32.0, 32.0]));
assert!(crate::crop_ui::pending(&app), "Reset keeps the crop active, as in Photoshop");
// Cancel: the frame is dropped (the tool's next frame is the default one).
crop_open(&mut app);
choose(&mut app, &Context::default(), CROP_CANCEL);
assert!(app.ui.crop_rect.is_none() && !crate::crop_ui::pending(&app));
// Commit: the document is cropped to the frame.
let mut app = integration_crop_app();
crop_open(&mut app);
choose(&mut app, &Context::default(), CROP_COMMIT);
assert_eq!(app.session.active().unwrap().doc.size, photocraft_doc::Size::new(20, 20));
assert!(app.ui.crop_rect.is_none());
}
#[test]
fn crop_menu_lists_saved_presets_and_applies_one() {
let mut app = integration_crop_app();
app.run("tool.presets.new", json!({"name": "Wide", "tool": "crop", "options": {"toolOptions": {"crop_ratio": "16:9"}}})).unwrap();
crop_open(&mut app);
let menu = app.ui.canvas_tool_menu.clone().unwrap();
assert_eq!(menu.crop.clone().unwrap_or_default(), ["Wide"]);
assert!(available(&app, &menu, "crop.preset.0"));
assert!(crop_rows(&menu).iter().flatten().any(|(label, _)| label == "Wide"), "the preset is a row: {:?}", crop_rows(&menu));
choose(&mut app, &Context::default(), "crop.preset.0");
// As in the options bar's dropdown: the preset fills the fields (the frame follows on the
// next resize or ratio apply).
assert_eq!(app.ui.tool_options.crop_ratio, "16:9");
}
#[test]
fn crop_menu_closes_when_the_crop_is_no_longer_pending() {
let mut app = integration_crop_app();
crop_open(&mut app);
assert!(app.ui.canvas_tool_menu.is_some());
crate::crop_ui::cancel(&mut app);
show(&mut app, &Context::default());
assert!(app.ui.canvas_tool_menu.is_none(), "the menu does not outlive the pending frame");
}
#[test]
fn right_click_on_a_pending_crop_opens_the_crop_menu() {
let app = integration_crop_app();
let mut h = harness(app);
let center = h.state().last_canvas_rect.center();
right_click(&mut h, center, Modifiers::NONE);
let menu = h.state().ui.canvas_tool_menu.clone().expect("right-click on a pending crop opens a menu");
assert!(menu.crop.is_some() && !menu.pasteboard && !menu.transform);
let ctx = h.ctx.clone();
choose(h.state_mut(), &ctx, "crop.ratio.16:9");
let r = h.state().ui.crop_rect.unwrap();
assert!(((r[2] - r[0]) / (r[3] - r[1]) - 16.0 / 9.0).abs() < 1e-9, "choosing 16:9 refits the frame");
}
#[test]
fn right_click_without_a_pending_crop_opens_no_menu() {
let mut app = app();
app.ui.tool = Tool::Crop;
crate::crop_ui::ensure_frame(&mut app);
assert!(app.ui.crop_rect.is_some() && !crate::crop_ui::pending(&app), "the untouched default frame is not pending");
let mut h = harness(app);
let center = h.state().last_canvas_rect.center();
right_click(&mut h, center, Modifiers::NONE);
assert!(h.state().ui.canvas_tool_menu.is_none(), "no menu on the untouched default frame");
}
-25
View File
@@ -163,31 +163,6 @@ pub fn trackpad_delta(ctx: &egui::Context, enabled: bool, touch_gestures: bool)
apply_trackpad(0.0, true, rad).filter(|_| rad != 0.0) apply_trackpad(0.0, true, rad).filter(|_| rad != 0.0)
} }
/// Shift + middle-drag rotates the view instead of panning. Keep the document point
/// under the pointer in place, including when the view is flipped or stretched.
pub fn middle_drag(view: &mut View, xf: &ViewXform, pointer: Pos2, delta_x: f32) -> bool {
if !pointer.x.is_finite() || !pointer.y.is_finite() || !delta_x.is_finite() || delta_x == 0.0 {
return false;
}
let current = ViewXform { center: view.center, rotation: view.rotation, ..*xf };
let before = current.to_doc(pointer);
let rotation = wrap_deg(view.rotation + delta_x * 0.35);
if rotation == view.rotation {
return false;
}
let after = ViewXform { rotation, ..current }.to_doc(pointer);
if !before.into_iter().chain(after).all(f64::is_finite) {
return false;
}
let center = [view.center[0] + (before[0] - after[0]) as f32, view.center[1] + (before[1] - after[1]) as f32];
if !center.into_iter().all(f32::is_finite) {
return false;
}
view.center = center;
view.rotation = rotation;
true
}
/// `view.rotateView`: set or add degrees on the active document's camera. /// `view.rotateView`: set or add degrees on the active document's camera.
pub fn command(app: &mut PhotocraftApp, p: &Value) -> Result<Value, String> { pub fn command(app: &mut PhotocraftApp, p: &Value) -> Result<Value, String> {
let i = app.session.active_index().ok_or("no document open")?; let i = app.session.active_index().ok_or("no document open")?;
@@ -3,32 +3,6 @@ use egui::{Event, PointerButton, pos2};
use egui_kittest::Harness; use egui_kittest::Harness;
use serde_json::json; use serde_json::json;
#[test]
fn middle_drag_rotates_about_the_pointer_without_panning() {
let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
let pointer = pos2(600.0, 180.0);
for flip in [false, true] {
let mut view = View { zoom: 2.0, center: [130.0, 80.0], ..Default::default() };
let xf = ViewXform { rect, zoom: 2.0, center: view.center, flip, rotation: 0.0, aspect: 1.25 };
let original = xf.to_doc(pointer);
assert!(middle_drag(&mut view, &xf, pointer, 90.0));
assert!((view.rotation - 31.5).abs() < 0.001);
let next = ViewXform { center: view.center, rotation: view.rotation, ..xf }.to_doc(pointer);
assert!((next[0] - original[0]).abs() < 0.001 && (next[1] - original[1]).abs() < 0.001, "{original:?} {next:?}");
}
}
#[test]
fn middle_drag_ignores_invalid_or_stationary_input() {
let mut view = View::default();
let xf =
ViewXform { rect: Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)), zoom: 1.0, center: view.center, flip: false, rotation: 0.0, aspect: 1.0 };
assert!(!middle_drag(&mut view, &xf, pos2(200.0, 120.0), 0.0));
assert!(!middle_drag(&mut view, &xf, pos2(200.0, 120.0), f32::NAN));
assert!(!middle_drag(&mut view, &xf, pos2(f32::INFINITY, 120.0), 20.0));
assert_eq!(view.rotation, 0.0);
}
#[test] #[test]
fn wrap_deg_folds_huge_and_hostile_angles() { fn wrap_deg_folds_huge_and_hostile_angles() {
assert_eq!(wrap_deg(0.0), 0.0); assert_eq!(wrap_deg(0.0), 0.0);
+8 -21
View File
@@ -108,22 +108,17 @@ pub struct GuideDrag {
/// Whether the pointer was over a ruler when the guide was let go (#6286): a guide released /// Whether the pointer was over a ruler when the guide was let go (#6286): a guide released
/// there is removed, whatever its own axis reads. /// there is removed, whatever its own axis reads.
pub over_ruler: bool, pub over_ruler: bool,
pub flipped: bool, pub flipped:bool,
} }
impl GuideDrag { impl GuideDrag {
pub fn shows_vertical(&self) -> bool { pub fn shows_vertical(&self)->bool {self.vertical!=self.flipped}
self.vertical != self.flipped pub fn track(&mut self,at:[f64;2],alt:bool) {self.flipped=alt;self.pos=if self.shows_vertical(){at[0]}else{at[1]};}
}
pub fn track(&mut self, at: [f64; 2], alt: bool) {
self.flipped = alt;
self.pos = if self.shows_vertical() { at[0] } else { at[1] };
}
/// An existing guide taken at document point `p`. /// An existing guide taken at document point `p`.
pub fn existing(vertical: bool, index: usize, p: [f64; 2]) -> Self { pub fn existing(vertical: bool, index: usize, p: [f64; 2]) -> Self {
let at = if vertical { p[0] } else { p[1] }; let at = if vertical { p[0] } else { p[1] };
Self { vertical, index: Some(index), pos: at, press: at, over_ruler: false, flipped: false } Self { vertical, index: Some(index), pos: at, press: at, over_ruler: false, flipped:false }
} }
} }
@@ -302,14 +297,13 @@ pub fn finish_drag(app: &mut PhotocraftApp, d: GuideDrag) {
// A drop on a ruler removes the guide even when the drag held its own axis, which otherwise // A drop on a ruler removes the guide even when the drag held its own axis, which otherwise
// reads as a click: dragged sideways onto the left ruler, a horizontal guide never moves in y. // reads as a click: dragged sideways onto the left ruler, a horizontal guide never moves in y.
if let Some(i) = d.index if let Some(i) = d.index
&& !d.over_ruler && !d.over_ruler && !d.flipped
&& !d.flipped
&& (d.pos - d.press).abs() * f64::from(app.point_zoom().max(0.01)) < 0.5 && (d.pos - d.press).abs() * f64::from(app.point_zoom().max(0.01)) < 0.5
{ {
crate::guide_select::select(app, d.vertical, i); crate::guide_select::select(app, d.vertical, i);
return; return;
} }
let shows = d.shows_vertical(); let shows=d.shows_vertical();
let extent = if shows { size.width } else { size.height } as f64; let extent = if shows { size.width } else { size.height } as f64;
let orientation = if d.vertical { "vertical" } else { "horizontal" }; let orientation = if d.vertical { "vertical" } else { "horizontal" };
let inside = !d.over_ruler && d.pos >= 0.0 && d.pos <= extent; let inside = !d.over_ruler && d.pos >= 0.0 && d.pos <= extent;
@@ -318,14 +312,7 @@ pub fn finish_drag(app: &mut PhotocraftApp, d: GuideDrag) {
(None, true) => app.run("view.newGuide", json!({"orientation": if shows {"vertical"}else{"horizontal"}, "position": pos})), (None, true) => app.run("view.newGuide", json!({"orientation": if shows {"vertical"}else{"horizontal"}, "position": pos})),
(Some(i), true) => app (Some(i), true) => app
.run("view.moveGuide", json!({"orientation": orientation,"toOrientation":if shows {"vertical"}else{"horizontal"}, "index": i, "position": pos})) .run("view.moveGuide", json!({"orientation": orientation,"toOrientation":if shows {"vertical"}else{"horizontal"}, "index": i, "position": pos}))
.inspect(|_| { .inspect(|_| {let index=if d.flipped {app.session.active().map_or(0,|st|if shows {st.doc.guides.vertical.len()}else{st.doc.guides.horizontal.len()}).saturating_sub(1)} else {i};crate::guide_select::select(app,shows,index);}),
let index = if d.flipped {
app.session.active().map_or(0, |st| if shows { st.doc.guides.vertical.len() } else { st.doc.guides.horizontal.len() }).saturating_sub(1)
} else {
i
};
crate::guide_select::select(app, shows, index);
}),
(Some(i), false) => app.run("view.deleteGuide", json!({"orientation": orientation, "index": i})), (Some(i), false) => app.run("view.deleteGuide", json!({"orientation": orientation, "index": i})),
(None, false) => Ok(serde_json::Value::Null), (None, false) => Ok(serde_json::Value::Null),
}; };
@@ -537,7 +524,7 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: &
let d = xf.to_doc(pos); let d = xf.to_doc(pos);
let along = if vertical { d[0] } else { d[1] }; let along = if vertical { d[0] } else { d[1] };
let pos = if ui.input(|i| i.modifiers.shift) { snap_to_tick(app, xf, vertical, along) } else { crate::snap_ui::snap_guide(app, vertical, along) }; let pos = if ui.input(|i| i.modifiers.shift) { snap_to_tick(app, xf, vertical, along) } else { crate::snap_ui::snap_guide(app, vertical, along) };
app.guide_drag = Some(GuideDrag { vertical, index: None, pos, press: pos, over_ruler: false, flipped: false }); app.guide_drag = Some(GuideDrag { vertical, index: None, pos, press: pos, over_ruler: false, flipped:false });
} }
if resp.drag_stopped() if resp.drag_stopped()
&& let Some(mut d) = app.guide_drag.take() && let Some(mut d) = app.guide_drag.take()
+8 -8
View File
@@ -192,9 +192,9 @@ fn filter_event_before_tool(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::
if let ToolEvent::Down { x, y, .. } = raw if let ToolEvent::Down { x, y, .. } = raw
&& let Some((vertical, i)) = command_guide_at(app, app.active_tool(), [x, y], mods) && let Some((vertical, i)) = command_guide_at(app, app.active_tool(), [x, y], mods)
{ {
let mut guide = crate::rulers::GuideDrag::existing(vertical, i, [x, y]); let mut guide=crate::rulers::GuideDrag::existing(vertical,i,[x,y]);
guide.track([x, y], mods.alt); guide.track([x,y],mods.alt);
app.guide_drag = Some(guide); app.guide_drag=Some(guide);
crate::snap_ui::begin_guide(app, [x, y]); crate::snap_ui::begin_guide(app, [x, y]);
return None; return None;
} }
@@ -217,9 +217,9 @@ fn handle_move_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tool: Tool, mods:
crate::guide_select::flip(app, vertical, i, [x, y]); crate::guide_select::flip(app, vertical, i, [x, y]);
return true; return true;
} }
let mut guide = crate::rulers::GuideDrag::existing(vertical, i, [x, y]); let mut guide=crate::rulers::GuideDrag::existing(vertical,i,[x,y]);
guide.track([x, y], mods.alt); guide.track([x,y],mods.alt);
app.guide_drag = Some(guide); app.guide_drag=Some(guide);
return true; return true;
} }
// Auto-Select (or ⌘-click while it is off) picks the layer under the pointer first // Auto-Select (or ⌘-click while it is off) picks the layer under the pointer first
@@ -278,14 +278,14 @@ fn handle_move_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tool: Tool, mods:
} }
ToolEvent::Move { x, y, .. } => { ToolEvent::Move { x, y, .. } => {
if let Some(d) = app.guide_drag.as_mut().filter(|d| d.index.is_some()) { if let Some(d) = app.guide_drag.as_mut().filter(|d| d.index.is_some()) {
d.track([x, y], mods.alt); d.track([x,y],mods.alt);
return true; return true;
} }
} }
ToolEvent::Up { x, y } => { ToolEvent::Up { x, y } => {
if let Some(mut d) = app.guide_drag.filter(|d| d.index.is_some()) { if let Some(mut d) = app.guide_drag.filter(|d| d.index.is_some()) {
app.guide_drag = None; app.guide_drag = None;
d.track([x, y], mods.alt); d.track([x,y],mods.alt);
crate::rulers::finish_drag(app, d); crate::rulers::finish_drag(app, d);
return true; return true;
} }
-1
View File
@@ -1092,7 +1092,6 @@ pub fn open_command_dialog(app: &mut PhotocraftApp, command: &str, label: &str)
pub mod adjust_dialog; pub mod adjust_dialog;
pub mod adjust_editors; pub mod adjust_editors;
pub(crate) mod adjust_presets;
pub mod adjust_preview; pub mod adjust_preview;
pub(crate) mod bitmap_dialog; pub(crate) mod bitmap_dialog;
pub mod blur_pins; pub mod blur_pins;
@@ -405,54 +405,6 @@ fn curves_pencil_drag_draws_the_map_and_confirm_applies_it() {
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1); assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1);
} }
/// #9263, #9265, #9268, #9270: Levels, Exposure, Black & White and Channel Mixer open on the
/// Default preset; choosing another loads its settings (the preview follows) and an edit by
/// hand reads Custom.
#[test]
fn adjustment_preset_menus_load_presets_and_read_custom_after_an_edit() {
use egui_kittest::kittest::{NodeT, Queryable};
for (kind, label, key) in [
("levels", "Increase Contrast 2", "inBlack"),
("exposure", "Plus 1.0", "exposure"),
("blackWhite", "Red Filter", "reds"),
("channelMixer", "Black & White with Red Filter (RGB)", "monochrome"),
] {
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image());
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
let id = open(h.state_mut(), &format!("image.adjustments.{kind}")).unwrap();
h.run_steps(4);
let preset = |h: &Harness<'_, PhotocraftApp>| {
h.query_all_by_role(egui::accesskit::Role::ComboBox).next().and_then(|n| n.accesskit_node().value().map(|s| s.to_string()))
};
assert_eq!(preset(&h).as_deref(), Some("Default"), "{kind} opens on Default");
h.query_all_by_role(egui::accesskit::Role::ComboBox).next().unwrap().click();
h.run_steps(2);
h.get_by_label(label).click();
h.run_steps(3);
assert_eq!(preset(&h).as_deref(), Some(label), "{kind}: the menu shows the preset");
let want = crate::adjust_presets::presets(kind, false)
.iter()
.find(|p| p.label == label)
.and_then(|p| crate::adjust_presets::settings(kind, p, false))
.unwrap();
let fields = &h.state().ui.dialogs[0].fields;
for (k, v) in want.as_object().expect("preset settings") {
assert_eq!(fields[k], *v, "{kind}: the preset's `{k}` is loaded");
}
assert_eq!(fields[key], want[key]);
let edit = match kind {
"levels" => ("gamma", json!(1.37)),
"exposure" => ("offset", json!(0.05)),
"blackWhite" => ("blues", json!(33)),
_ => ("gray", json!([90, 10, 0, 0])),
};
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert(edit.0.into(), edit.1);
h.run_steps(3);
assert_eq!(preset(&h).as_deref(), Some("Custom"), "{kind}: an edit is Custom");
crate::dialogs::confirm(h.state_mut(), id).unwrap();
}
}
#[test] #[test]
fn curves_pointer_drag_previews_until_confirm_and_cancel_preserves_document() { fn curves_pointer_drag_previews_until_confirm_and_cancel_preserves_document() {
for confirm in [false, true] { for confirm in [false, true] {
@@ -139,43 +139,6 @@ pub fn editor(ui: &mut egui::Ui, kind: &str, v: &mut Value, cx: &EditorCx) -> Ed
if !v.is_object() { if !v.is_object() {
*v = json!({}); *v = json!({});
} }
let mut e = preset_row(ui, kind, v, cx);
e.add(kind_editor(ui, kind, v, cx));
e
}
/// The Preset menu above Levels, Exposure, Black & White and Channel Mixer (#9263, #9265, #9268,
/// #9270): PhotoCraft's own presets (`adjust_presets`), Custom once edited; a choice is one edit.
fn preset_row(ui: &mut egui::Ui, kind: &str, v: &mut Value, cx: &EditorCx) -> Edit {
let list = crate::adjust_presets::presets(kind, cx.gray);
if list.is_empty() || (kind == "levels" && space_of(v) != ToneSpace::Rgb) {
return Edit::default();
}
let mut e = Edit::default();
ui.horizontal(|ui| {
// The collection is PhotoCraft's own (adjust_presets): the caption says so, so a tutorial's
// preset of the same name isn't taken for this one.
let t = Tokens::get(ui.ctx());
ui.label(RichText::new(tl!("PhotoCraft Preset:")).color(t.text_dim).size(12.0))
.on_hover_text(tl!("PhotoCraft's own settings for these looks, not Photoshop's presets of the same names."));
let current = crate::adjust_presets::matching(kind, v, cx.gray).map_or("custom", |p| p.id);
let mut options: Vec<(&str, &str)> = list.iter().map(|p| (p.id, tl!(p.label))).collect();
if current == "custom" {
options.push(("custom", tl!("Custom")));
}
let mut picked = current;
if widgets::dropdown(ui, &format!("{kind}-preset"), tl!("Preset"), &mut picked, &options, ui.available_width().min(220.0))
&& picked != current
&& let Some(settings) = list.iter().find(|p| p.id == picked).and_then(|p| crate::adjust_presets::settings(kind, p, cx.gray))
{
*v = settings;
e.add(Edit::discrete(true));
}
});
e
}
fn kind_editor(ui: &mut egui::Ui, kind: &str, v: &mut Value, cx: &EditorCx) -> Edit {
match kind { match kind {
"brightnessContrast" => brightness_contrast(ui, v), "brightnessContrast" => brightness_contrast(ui, v),
"levels" => levels(ui, v, cx), "levels" => levels(ui, v, cx),
@@ -912,48 +912,3 @@ fn row_values_round_to_their_own_decimals() {
assert!((row_value(1.234, 0.01, 9.99, None) - 1.23).abs() < 1e-6, "small ranges keep two decimals"); assert!((row_value(1.234, 0.01, 9.99, None) - 1.23).abs() < 1e-6, "small ranges keep two decimals");
assert!(row_value(f32::NAN, -20.0, 20.0, Some(u32::MAX)).is_nan(), "no panic on odd input"); assert!(row_value(f32::NAN, -20.0, 20.0, Some(u32::MAX)).is_nan(), "no panic on odd input");
} }
/// CodeRabbit on #9282: the Properties panel's Preset menu loads every setting of each preset
/// into the adjustment layer, in one history step.
#[test]
fn properties_preset_menus_load_every_setting_in_one_step() {
use egui_kittest::kittest::NodeT;
for (kind, label) in
[("levels", "Increase Contrast 2"), ("exposure", "Plus 1.0"), ("blackWhite", "Red Filter"), ("channelMixer", "Black & White with Red Filter (RGB)")]
{
let mut h = app_harness(kind, "rgb", 8);
let steps = h.state().session.active().unwrap().history.past_len();
let menu = h
.query_all_by_role(egui::accesskit::Role::ComboBox)
.find(|n| n.accesskit_node().value().as_deref() == Some("Default"))
.unwrap_or_else(|| panic!("{kind}: the Preset menu, at Default"));
menu.click();
h.run_steps(2);
h.get_by_label(label).click();
h.run_steps(4);
let want = crate::adjust_presets::presets(kind, false)
.iter()
.find(|p| p.label == label)
.and_then(|p| crate::adjust_presets::settings(kind, p, false))
.unwrap();
let got = photocraft_engine::adjust_params::to_params(&layer(&h).1);
for (k, v) in want.as_object().expect("preset settings") {
assert_eq!(got[k], *v, "{kind}: the layer's `{k}`");
}
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "{kind}: one step");
}
}
/// FredApps on #9282: the menu says these are PhotoCraft's own presets, not Photoshop's presets of
/// the same names (whose settings differ), so a tutorial's "Increase Contrast 1" isn't mistaken
/// for this one.
#[test]
fn preset_menus_say_they_are_photocrafts_own() {
for kind in ["levels", "exposure", "blackWhite", "channelMixer"] {
let mut h = app_harness(kind, "rgb", 8);
let caption = h.get_by_label("PhotoCraft Preset:");
caption.hover();
h.run_steps(30);
assert!(h.query_by_label_contains("not Photoshop's presets").is_some(), "{kind}: the caption explains the presets are PhotoCraft's own");
}
}
@@ -1,172 +0,0 @@
//! Preset menus for Levels, Exposure, Black & White and Channel Mixer (#9263, #9265, #9268,
//! #9270): starting settings named for the looks they give, as the Preset menu at the top of
//! these adjustments in Photoshop.
//!
//! The settings are PhotoCraft's own. Each was designed for its look: Levels presets lift, lower or
//! stretch the tones; Exposure presets are whole stops; the Black & White "filter" presets follow
//! what a coloured lens filter does on black-and-white film (it lightens its own colour and darkens
//! its complement); the Channel Mixer presets mix the channels a filter would let through. None is
//! taken or measured from another application's preset files.
use photocraft_doc::ColorMode;
use photocraft_engine::adjust_params::{from_params, to_params};
use serde_json::{Value, json};
/// A preset: its id (the menu value), label and the adjustment params it sets (the rest default).
pub struct AdjustPreset {
pub id: &'static str,
pub label: &'static str,
pub params: fn() -> Value,
}
macro_rules! preset {
($id:literal, $label:literal, $params:tt) => {
AdjustPreset { id: $id, label: $label, params: || json!($params) }
};
}
const LEVELS: &[AdjustPreset] = &[
preset!("default", "Default", {}),
// The whole image darker: the white output comes down.
preset!("darker", "Darker", {"outWhite": 220}),
// Black and white input points pulled in, a step further each time.
preset!("increaseContrast1", "Increase Contrast 1", {"inBlack": 12, "inWhite": 243}),
preset!("increaseContrast2", "Increase Contrast 2", {"inBlack": 24, "inWhite": 231}),
preset!("increaseContrast3", "Increase Contrast 3", {"inBlack": 36, "inWhite": 219}),
// Shadows lifted off black and opened up a little; highlights left where they are.
preset!("lightenShadows", "Lighten Shadows", {"outBlack": 20, "gamma": 1.1}),
// The whole image lighter: the white input point comes in.
preset!("lighter", "Lighter", {"inWhite": 220}),
// The midtones alone, up or down; black and white stay put.
preset!("midtonesBrighter", "Midtones Brighter", {"gamma": 1.3}),
preset!("midtonesDarker", "Midtones Darker", {"gamma": 0.77}),
];
const EXPOSURE: &[AdjustPreset] = &[
preset!("default", "Default", {}),
preset!("minus1", "Minus 1.0", {"exposure": -1.0}),
preset!("minus2", "Minus 2.0", {"exposure": -2.0}),
preset!("plus1", "Plus 1.0", {"exposure": 1.0}),
preset!("plus2", "Plus 2.0", {"exposure": 2.0}),
];
// Black & White weights per colour range: reds, yellows, greens, cyans, blues, magentas.
const BLACK_WHITE: &[AdjustPreset] = &[
preset!("default", "Default", {}),
// A blue filter: blues and cyans light, reds and yellows dark.
preset!("blueFilter", "Blue Filter", {"reds": 0, "yellows": 20, "greens": 40, "cyans": 100, "blues": 110, "magentas": 80}),
// Every colour a little darker.
preset!("darker", "Darker", {"reds": 20, "yellows": 40, "greens": 20, "cyans": 40, "blues": 0, "magentas": 60}),
// A green filter: greens light (foliage), reds and magentas dark.
preset!("greenFilter", "Green Filter", {"reds": 10, "yellows": 80, "greens": 110, "cyans": 60, "blues": 20, "magentas": 0}),
// The blue filter pushed further: deep reds, glowing blues.
preset!("highContrastBlueFilter", "High Contrast Blue Filter", {"reds": -40, "yellows": 0, "greens": 40, "cyans": 140, "blues": 160, "magentas": 100}),
// The red filter pushed further: near-white reds, near-black blue skies.
preset!("highContrastRedFilter", "High Contrast Red Filter", {"reds": 140, "yellows": 120, "greens": 0, "cyans": -40, "blues": -60, "magentas": 80}),
// Infrared film: foliage greens and yellows glow, skies go dark.
preset!("infrared", "Infrared", {"reds": -20, "yellows": 190, "greens": 200, "cyans": -30, "blues": -60, "magentas": 40}),
// Every colour a little lighter.
preset!("lighter", "Lighter", {"reds": 60, "yellows": 80, "greens": 60, "cyans": 80, "blues": 40, "magentas": 100}),
// Colours as dark as they go.
preset!("maximumBlack", "Maximum Black", {"reds": 0, "yellows": 0, "greens": 0, "cyans": 0, "blues": 0, "magentas": 0}),
// Colours as light as they go.
preset!("maximumWhite", "Maximum White", {"reds": 150, "yellows": 150, "greens": 150, "cyans": 150, "blues": 150, "magentas": 150}),
// A neutral filter: every colour weighed the same.
preset!("neutralDensity", "Neutral Density", {"reds": 50, "yellows": 50, "greens": 50, "cyans": 50, "blues": 50, "magentas": 50}),
// A red filter: reds and yellows light, blues and cyans dark (dramatic skies).
preset!("redFilter", "Red Filter", {"reds": 110, "yellows": 100, "greens": 0, "cyans": -20, "blues": -30, "magentas": 70}),
// A yellow filter: a gentle darkening of blue skies.
preset!("yellowFilter", "Yellow Filter", {"reds": 70, "yellows": 100, "greens": 50, "cyans": 30, "blues": 0, "magentas": 50}),
];
// Channel Mixer monochrome mixes `[red %, green %, blue %, constant %]`: what a filter lets through.
const CHANNEL_MIXER: &[AdjustPreset] = &[
preset!("default", "Default", {}),
preset!("bwInfrared", "Black & White Infrared (RGB)", {"monochrome": true, "gray": [-60, 190, -30, 0]}),
preset!("bwBlueFilter", "Black & White with Blue Filter (RGB)", {"monochrome": true, "gray": [0, 0, 100, 0]}),
preset!("bwGreenFilter", "Black & White with Green Filter (RGB)", {"monochrome": true, "gray": [0, 100, 0, 0]}),
preset!("bwOrangeFilter", "Black & White with Orange Filter (RGB)", {"monochrome": true, "gray": [50, 50, 0, 0]}),
preset!("bwRedFilter", "Black & White with Red Filter (RGB)", {"monochrome": true, "gray": [100, 0, 0, 0]}),
preset!("bwYellowFilter", "Black & White with Yellow Filter (RGB)", {"monochrome": true, "gray": [34, 66, 0, 0]}),
];
/// The presets of adjustment `kind` for a document (`gray`: Grayscale, where the colour presets
/// don't apply); empty for a kind without a Preset menu.
pub fn presets(kind: &str, gray: bool) -> &'static [AdjustPreset] {
match kind {
"levels" => LEVELS,
"exposure" => EXPOSURE,
"blackWhite" if !gray => BLACK_WHITE,
"channelMixer" if !gray => CHANNEL_MIXER,
_ => &[],
}
}
fn mode(gray: bool) -> ColorMode {
if gray { ColorMode::Grayscale } else { ColorMode::Rgb }
}
/// `params` as the engine reads them: every key, defaults filled in.
fn normal(kind: &str, params: &Value, gray: bool) -> Option<Value> {
from_params(kind, params, None, mode(gray)).ok().map(|a| to_params(&a))
}
/// The settings `preset` gives, with every other key at its default.
pub fn settings(kind: &str, preset: &AdjustPreset, gray: bool) -> Option<Value> {
normal(kind, &(preset.params)(), gray)
}
/// The preset the values `v` are (Default for untouched values), or `None` (Custom).
pub fn matching(kind: &str, v: &Value, gray: bool) -> Option<&'static AdjustPreset> {
let now = normal(kind, v, gray)?;
presets(kind, gray).iter().find(|p| settings(kind, p, gray).as_ref() == Some(&now))
}
#[cfg(test)]
mod tests {
use super::*;
/// Every preset is valid, distinct and recognised once applied; an edit makes it Custom.
#[test]
fn presets_apply_and_are_recognised() {
for kind in ["levels", "exposure", "blackWhite", "channelMixer"] {
let all = presets(kind, false);
assert!(all.len() >= 5 && all[0].id == "default", "{kind}");
for p in all {
let s = settings(kind, p, false).unwrap_or_else(|| panic!("{kind} {}: valid params", p.id));
assert_eq!(matching(kind, &s, false).map(|m| m.id), Some(p.id), "{kind} {}", p.id);
}
let distinct: std::collections::HashSet<String> = all.iter().map(|p| settings(kind, p, false).unwrap().to_string()).collect();
assert_eq!(distinct.len(), all.len(), "{kind}: no two presets are the same");
assert_eq!(matching(kind, &json!({}), false).map(|p| p.id), Some("default"), "{kind}: untouched is Default");
}
let mut v = settings("levels", &LEVELS[3], false).unwrap();
v["gamma"] = json!(1.37);
assert!(matching("levels", &v, false).is_none(), "an edit is Custom");
assert!(presets("blackWhite", true).is_empty() && presets("channelMixer", true).is_empty(), "colour presets need colour");
assert_eq!(presets("levels", true).len(), LEVELS.len());
}
/// The looks hold: contrast steps widen, filters lighten their own colour and darken the
/// complement, exposure presets are whole stops.
#[test]
fn presets_give_their_looks() {
let lv = |id: &str| settings("levels", LEVELS.iter().find(|p| p.id == id).unwrap(), false).unwrap();
let span = |v: &Value| v["inWhite"].as_f64().unwrap() - v["inBlack"].as_f64().unwrap();
assert!(span(&lv("increaseContrast1")) > span(&lv("increaseContrast2")) && span(&lv("increaseContrast2")) > span(&lv("increaseContrast3")));
assert!(lv("midtonesBrighter")["gamma"].as_f64().unwrap() > 1.0 && lv("midtonesDarker")["gamma"].as_f64().unwrap() < 1.0);
let bw = |id: &str| settings("blackWhite", BLACK_WHITE.iter().find(|p| p.id == id).unwrap(), false).unwrap();
let w = |v: &Value, k: &str| v[k].as_f64().unwrap();
assert!(w(&bw("redFilter"), "reds") > w(&bw("redFilter"), "cyans"), "a red filter lightens reds over cyans");
assert!(w(&bw("blueFilter"), "blues") > w(&bw("blueFilter"), "yellows"), "a blue filter lightens blues over yellows");
assert!(
w(&bw("highContrastRedFilter"), "reds") - w(&bw("highContrastRedFilter"), "blues") > w(&bw("redFilter"), "reds") - w(&bw("redFilter"), "blues")
);
let ex = |id: &str| settings("exposure", EXPOSURE.iter().find(|p| p.id == id).unwrap(), false).unwrap()["exposure"].as_f64().unwrap();
assert_eq!([ex("minus2"), ex("minus1"), ex("plus1"), ex("plus2")], [-2.0, -1.0, 1.0, 2.0]);
for p in &CHANNEL_MIXER[1..] {
let s = settings("channelMixer", p, false).unwrap();
assert_eq!(s["monochrome"], json!(true), "{}", p.id);
}
}
}
@@ -325,7 +325,6 @@ pub fn has_dialog(command: &str) -> bool {
| "image.mode.colorTable" | "image.mode.colorTable"
| "edit.definePattern" | "edit.definePattern"
| "select.convertToShape" | "select.convertToShape"
| "layer.frequencySeparation"
| "layer.new.artboardFromLayers" | "layer.new.artboardFromLayers"
)) ))
&& photocraft_engine::commands::find(command).is_some_and(|c| !parse_spec(c.params).is_empty()) && photocraft_engine::commands::find(command).is_some_and(|c| !parse_spec(c.params).is_empty())
+11 -22
View File
@@ -194,9 +194,8 @@ pub struct LiquifyDialog {
pub dab_ms: f64, pub dab_ms: f64,
/// CPU time spent refreshing the zoomed-in source-resolution preview. /// CPU time spent refreshing the zoomed-in source-resolution preview.
pub preview_ms: f64, pub preview_ms: f64,
/// The smart filter being edited, with its `smart_filter_dialog::target`: OK checks the target /// The smart filter being edited: OK updates it instead of adding a new Liquify filter.
/// and updates that filter instead of adding a new Liquify filter. smart_filter: Option<usize>,
smart_filter: Option<(usize, Value)>,
} }
fn union(a: Option<[usize; 4]>, b: [usize; 4]) -> Option<[usize; 4]> { fn union(a: Option<[usize; 4]>, b: [usize; 4]) -> Option<[usize; 4]> {
@@ -234,7 +233,7 @@ impl LiquifyDialog {
"dabMs": self.dab_ms, "dabMs": self.dab_ms,
"fullResolutionPreview": self.view_bounds.is_some(), "fullResolutionPreview": self.view_bounds.is_some(),
"previewMs": self.preview_ms, "previewMs": self.preview_ms,
"smartFilter": self.smart_filter.as_ref().map(|(index, _)| index), "smartFilter": self.smart_filter,
}) })
} }
@@ -499,7 +498,7 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
/// Re-opens Liquify smart filter `index` of smart object `layer` with its recorded strokes and /// Re-opens Liquify smart filter `index` of smart object `layer` with its recorded strokes and
/// mesh size, previewing on the pixels below it; OK updates that filter /// mesh size, previewing on the pixels below it; OK updates that filter
/// (`layer.smartFilter.setParams`) unless the document or the filter changed in the meantime. /// (`layer.smartFilter.setParams`).
pub fn open_smart_filter(app: &mut PhotocraftApp, ctx: &egui::Context, layer: LayerId, index: usize) -> Result<(), String> { pub fn open_smart_filter(app: &mut PhotocraftApp, ctx: &egui::Context, layer: LayerId, index: usize) -> Result<(), String> {
use photocraft_engine::distort_cmds::{LIQUIFY, liquify_params}; use photocraft_engine::distort_cmds::{LIQUIFY, liquify_params};
let st = app.session.active().ok_or("no document")?; let st = app.session.active().ok_or("no document")?;
@@ -514,8 +513,7 @@ pub fn open_smart_filter(app: &mut PhotocraftApp, ctx: &egui::Context, layer: La
let surf = photocraft_engine::smart_cmds::render_below_filter(&st.doc, sm, index) let surf = photocraft_engine::smart_cmds::render_below_filter(&st.doc, sm, index)
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.ok_or("the smart object's contents are unavailable (missing linked file?)")?; .ok_or("the smart object's contents are unavailable (missing linked file?)")?;
let target = crate::smart_filter_dialog::target(app, layer, index)?; open_on(app, ctx, layer, surf, strokes, cell, Some(index))
open_on(app, ctx, layer, surf, strokes, cell, Some((index, target)))
} }
fn open_on( fn open_on(
@@ -525,7 +523,7 @@ fn open_on(
surf: Surface, surf: Surface,
strokes: Vec<LiquifyStroke>, strokes: Vec<LiquifyStroke>,
cell: f64, cell: f64,
smart_filter: Option<(usize, Value)>, smart_filter: Option<usize>,
) -> Result<(), String> { ) -> Result<(), String> {
let st = app.session.active().ok_or("no document")?; let st = app.session.active().ok_or("no document")?;
let canvas = st.doc.bounds(); let canvas = st.doc.bounds();
@@ -595,25 +593,16 @@ pub fn cancel(app: &mut PhotocraftApp) {
} }
/// OK: runs `filter.liquify` with the recorded strokes (one history step). When the dialog edits /// OK: runs `filter.liquify` with the recorded strokes (one history step). When the dialog edits
/// a smart filter, OK updates that filter instead (also when Restore All left no strokes), after /// a smart filter, OK updates that filter instead (also when Restore All left no strokes). The
/// `smart_filter_dialog::validate_target` checks that the document and the filter did not change. /// dialog closes either way. `app.run` shows a failed command's error in the status bar, and
/// The dialog closes either way. The status bar shows the error of a failed check or command /// `commit` returns it.
/// (`app.run` shows the latter), and `commit` returns it.
pub fn commit(app: &mut PhotocraftApp) -> Result<(), String> { pub fn commit(app: &mut PhotocraftApp) -> Result<(), String> {
let Some(mut d) = app.distort.liquify.take() else { return Ok(()) }; let Some(mut d) = app.distort.liquify.take() else { return Ok(()) };
remember(app, &d.opts); remember(app, &d.opts);
d.end(); d.end();
if let Some((_, target)) = &d.smart_filter { if let Some(index) = d.smart_filter {
let (layer, index) = match crate::smart_filter_dialog::validate_target(app, target) {
Ok(edit) => edit,
Err(e) => {
app.ui.status = e.clone();
app.ui.status_error = true;
return Err(e);
}
};
let params = json!({"strokes": d.strokes, "meshSize": d.cell}); let params = json!({"strokes": d.strokes, "meshSize": d.cell});
return app.run("layer.smartFilter.setParams", json!({"layer": layer.0, "index": index, "params": params})).map(|_| ()); return app.run("layer.smartFilter.setParams", json!({"layer": d.layer.0, "index": index, "params": params})).map(|_| ());
} }
if d.strokes.is_empty() { if d.strokes.is_empty() {
return Ok(()); return Ok(());
+2 -27
View File
@@ -759,7 +759,7 @@ fn smart_filter_double_click_edits_the_recorded_liquify() {
crate::smart_ui::open_editor(&mut app, &ctx, layer.0, &sm, 0); crate::smart_ui::open_editor(&mut app, &ctx, layer.0, &sm, 0);
assert!(app.ui.dialogs.is_empty(), "no generic parameter form"); assert!(app.ui.dialogs.is_empty(), "no generic parameter form");
let d = app.distort.liquify.as_ref().expect("the Liquify workspace opens"); let d = app.distort.liquify.as_ref().expect("the Liquify workspace opens");
assert_eq!((d.smart_filter.as_ref().map(|(index, _)| *index), d.cell), (Some(0), 3.0)); assert_eq!((d.smart_filter, d.cell), (Some(0), 3.0));
assert_eq!(d.strokes, recorded); assert_eq!(d.strokes, recorded);
assert_eq!(d.field, LiquifyField::from_strokes(d.canvas, 3.0, &recorded)); assert_eq!(d.field, LiquifyField::from_strokes(d.canvas, 3.0, &recorded));
// The preview starts from the pixels below the filter (no liquify, no blur). // The preview starts from the pixels below the filter (no liquify, no blur).
@@ -828,37 +828,12 @@ fn smart_filter_edit_that_cannot_be_applied_reports_the_error() {
let r = control(&mut app, &json!({"commit": true})); let r = control(&mut app, &json!({"commit": true}));
assert!(app.distort.liquify.is_none()); assert!(app.distort.liquify.is_none());
assert!(app.ui.status_error); assert!(app.ui.status_error);
assert!(app.ui.status.contains("no longer exists"), "{}", app.ui.status); assert!(app.ui.status.contains("no smart filters"), "{}", app.ui.status);
assert!(smart(&app, layer).smart_filters.is_empty()); assert!(smart(&app, layer).smart_filters.is_empty());
assert_eq!(app.session.active().unwrap().history.past_len(), steps); assert_eq!(app.session.active().unwrap().history.past_len(), steps);
assert_eq!(r, Err(app.ui.status.clone())); assert_eq!(r, Err(app.ui.status.clone()));
} }
/// OK after the stack was reordered while the workspace was open: the filter now at the
/// edited index (the Gaussian Blur) is left alone and the status bar asks to reopen.
#[test]
fn smart_filter_edit_after_the_stack_changed_updates_nothing() {
let ctx = egui::Context::default();
let (mut app, layer) = smart_liquify_app();
open_smart_filter(&mut app, &ctx, layer, 0).unwrap();
drag(&mut app, 70.0, 84.0);
app.session.execute("layer.smartFilter.move", json!({"layer": layer.0, "index": 0, "to": 1})).unwrap();
let before = smart(&app, layer).smart_filters;
assert_eq!(before[0].command, "filter.blur.gaussianBlur");
let steps = app.session.active().unwrap().history.past_len();
let r = control(&mut app, &json!({"commit": true}));
assert!(app.distort.liquify.is_none());
assert!(app.ui.status_error);
assert!(app.ui.status.contains("Reopen its dialog"), "{}", app.ui.status);
let after = smart(&app, layer).smart_filters;
assert_eq!(after.len(), before.len());
for (a, b) in after.iter().zip(&before) {
assert_eq!((&a.command, &a.params), (&b.command, &b.params));
}
assert_eq!(app.session.active().unwrap().history.past_len(), steps);
assert_eq!(r, Err(app.ui.status.clone()));
}
/// OK after the layer was deleted: the dialog closes, the status bar shows the error and the /// OK after the layer was deleted: the dialog closes, the status bar shows the error and the
/// control channel returns it. /// control channel returns it.
#[test] #[test]
+1 -3
View File
@@ -61,9 +61,7 @@ impl Group {
match self { match self {
Group::Color => 190.0, Group::Color => 190.0,
Group::Properties => 250.0, Group::Properties => 250.0,
// Roman Hanging Punctuation added a bottom row to the Paragraph tab, so the Group::Character => 270.0,
// group grows by one row to keep its content unscrolled.
Group::Character => 306.0,
Group::Navigator => 210.0, Group::Navigator => 210.0,
Group::History => 200.0, Group::History => 200.0,
Group::Layers => 320.0, Group::Layers => 320.0,
-17
View File
@@ -1358,7 +1358,6 @@
Italic Kurzíva Italic Kurzíva
Join us on Discord Přidejte se k nám na Discordu Join us on Discord Přidejte se k nám na Discordu
Justify all Do bloku vše Justify all Do bloku vše
Roman Hanging Punctuation Římská převislá interpunkce
Justify last centered Do bloku, poslední řádek na střed Justify last centered Do bloku, poslední řádek na střed
Justify last left Do bloku, poslední řádek vlevo Justify last left Do bloku, poslední řádek vlevo
Justify last right Do bloku, poslední řádek vpravo Justify last right Do bloku, poslední řádek vpravo
@@ -1960,9 +1959,6 @@
Slice Select Tool Nástroj výběr řezu Slice Select Tool Nástroj výběr řezu
Slice Tool Nástroj řez Slice Tool Nástroj řez
Smart Filters Chytré filtry Smart Filters Chytré filtry
Move Up Přesunout nahoru
Move Down Přesunout dolů
Delete Smart Filter Odstranit chytrý filtr
Smudge Tool Nástroj rozmazání Smudge Tool Nástroj rozmazání
Soft Měkké Soft Měkké
Soften Změkčení Soften Změkčení
@@ -2101,7 +2097,6 @@
Image previews Náhledy obrázků Image previews Náhledy obrázků
Integrations Integrace Integrations Integrace
Interface Rozhraní Interface Rozhraní
Use PhotoCraft-native gradient icons Používat nativní ikony přechodů PhotoCraft
Jpeg quality Kvalita JPEG Jpeg quality Kvalita JPEG
Jpg JPG Jpg JPG
Language Jazyk Language Jazyk
@@ -2601,7 +2596,6 @@ fontWeight Italic Kurzíva
Out of gamut for printing: click for the nearest printable colour Mimo gamut tisku: klepnutím zvolíte nejbližší tisknutelnou barvu Out of gamut for printing: click for the nearest printable colour Mimo gamut tisku: klepnutím zvolíte nejbližší tisknutelnou barvu
Not a web colour: click for the nearest web colour Není webová barva: klepnutím zvolíte nejbližší webovou barvu Not a web colour: click for the nearest web colour Není webová barva: klepnutím zvolíte nejbližší webovou barvu
Color to Alpha… Barva na alfu… Color to Alpha… Barva na alfu…
Frequency Separation… Frekvenční separace…
Transparency Threshold Práh průhlednosti Transparency Threshold Práh průhlednosti
Opacity Threshold Práh krytí Opacity Threshold Práh krytí
PhotoCraft — Screen Color Picker PhotoCraft — Výběr barvy obrazovky PhotoCraft — Screen Color Picker PhotoCraft — Výběr barvy obrazovky
@@ -3880,8 +3874,6 @@ navigator Zoom Zvětšení
Reset All Locked Settings Obnovit všechna zamknutá nastavení Reset All Locked Settings Obnovit všechna zamknutá nastavení
Copy Texture to Other Tools Kopírovat texturu do jiných nástrojů Copy Texture to Other Tools Kopírovat texturu do jiných nástrojů
Load Actions… Načíst akce… Load Actions… Načíst akce…
Load Default Actions Načíst výchozí akce
Blur Background Rozostřit pozadí
Save Actions… Uložit akce… Save Actions… Uložit akce…
New Action… Nová akce… New Action… Nová akce…
Start Recording Zahájit nahrávání Start Recording Zahájit nahrávání
@@ -4250,10 +4242,6 @@ cameraRaw Dehaze Odstranění oparu
Font size presets Předvolby velikosti písma Font size presets Předvolby velikosti písma
Leading presets Předvolby prokladu Leading presets Předvolby prokladu
Tracking presets Předvolby prostrkání Tracking presets Předvolby prostrkání
Selection / Layer Bounds Výběr / Hranice vrstvy
No selection or layer Žádný výběr ani vrstva
{label}: empty {label}: prázdné
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Color Aware Podle barev Color Aware Podle barev
Decontaminate Colors Dekontaminovat barvy Decontaminate Colors Dekontaminovat barvy
Edge Detection Detekce okraje Edge Detection Detekce okraje
@@ -4270,9 +4258,4 @@ cameraRaw Dehaze Odstranění oparu
Refine Hair needs hair detection, which isn't available yet Upřesnit vlasy vyžaduje detekci vlasů, která zatím není k dispozici Refine Hair needs hair detection, which isn't available yet Upřesnit vlasy vyžaduje detekci vlasů, která zatím není k dispozici
Select and Mask was closed: its document is no longer the active one Vybrat a maskovat bylo zavřeno: jeho dokument již není aktivní Select and Mask was closed: its document is no longer the active one Vybrat a maskovat bylo zavřeno: jeho dokument již není aktivní
Select and Mask was closed: another layer was selected Vybrat a maskovat bylo zavřeno: byla vybrána jiná vrstva Select and Mask was closed: another layer was selected Vybrat a maskovat bylo zavřeno: byla vybrána jiná vrstva
Import Shapes… Importovat tvary…
Choose a custom shape file (.csh). Vyberte soubor vlastních tvarů (.csh).
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Počet tvarů importovaných z {file}: {n}|Počet tvarů importovaných z {file}: {n}|Počet tvarů importovaných z {file}: {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Počet tvarů importovaných z {group}: {n}|Počet tvarů importovaných z {group}: {n}|Počet tvarů importovaných z {group}: {n}
Scrollbar Visibility Viditelnost posuvníků
Drag to move the selected path Tažením přesunete vybranou cestu Drag to move the selected path Tažením přesunete vybranou cestu
Can't render this file because it contains an unexpected character in line 6 and column 47.
-20
View File
@@ -1320,7 +1320,6 @@
Italic Kursiv Italic Kursiv
Join us on Discord Tritt unserem Discord bei Join us on Discord Tritt unserem Discord bei
Justify all Alles im Blocksatz Justify all Alles im Blocksatz
Roman Hanging Punctuation Römisch überhängende Zeichensetzung
Justify last centered Letzte Zeile zentriert Justify last centered Letzte Zeile zentriert
Justify last left Letzte Zeile links Justify last left Letzte Zeile links
Justify last right Letzte Zeile rechts Justify last right Letzte Zeile rechts
@@ -1919,11 +1918,6 @@
Slice Select Tool Slice-Auswahl-Werkzeug Slice Select Tool Slice-Auswahl-Werkzeug
Slice Tool Slice-Werkzeug Slice Tool Slice-Werkzeug
Smart Filters Smart-Filter Smart Filters Smart-Filter
# Smart Filter row actions (#1265)
Move Up Nach oben
Move Down Nach unten
Delete Smart Filter Smartfilter löschen
Smudge Tool Wischfinger-Werkzeug Smudge Tool Wischfinger-Werkzeug
Soft Weich Soft Weich
Soften Erweichen Soften Erweichen
@@ -2062,7 +2056,6 @@
Image previews Bildvorschauen Image previews Bildvorschauen
Integrations Integrationen Integrations Integrationen
Interface Benutzeroberfläche Interface Benutzeroberfläche
Use PhotoCraft-native gradient icons PhotoCraft-native Symbole für Verläufe verwenden
Jpeg quality JPEG-Qualität Jpeg quality JPEG-Qualität
Jpg JPG Jpg JPG
Language Sprache Language Sprache
@@ -2640,7 +2633,6 @@ fontWeight Italic Kursiv
Out of gamut for printing: click for the nearest printable colour Außerhalb des Druckfarbumfangs: Klicken für die nächstliegende druckbare Farbe Out of gamut for printing: click for the nearest printable colour Außerhalb des Druckfarbumfangs: Klicken für die nächstliegende druckbare Farbe
Not a web colour: click for the nearest web colour Keine Webfarbe: Klicken für die nächstliegende Webfarbe Not a web colour: click for the nearest web colour Keine Webfarbe: Klicken für die nächstliegende Webfarbe
Color to Alpha… Farbe zu Alpha… Color to Alpha… Farbe zu Alpha…
Frequency Separation… Frequenztrennung…
Transparency Threshold Transparenz-Schwellenwert Transparency Threshold Transparenz-Schwellenwert
Opacity Threshold Deckkraft-Schwellenwert Opacity Threshold Deckkraft-Schwellenwert
PhotoCraft — Screen Color Picker PhotoCraft — Bildschirmfarbenauswahl PhotoCraft — Screen Color Picker PhotoCraft — Bildschirmfarbenauswahl
@@ -3280,8 +3272,6 @@ curves Smooth Glätten
Reset All Locked Settings Alle gesperrten Einstellungen zurücksetzen Reset All Locked Settings Alle gesperrten Einstellungen zurücksetzen
Copy Texture to Other Tools Struktur auf andere Werkzeuge kopieren Copy Texture to Other Tools Struktur auf andere Werkzeuge kopieren
Load Actions… Aktionen laden… Load Actions… Aktionen laden…
Load Default Actions Standardaktionen laden
Blur Background Hintergrund weichzeichnen
Save Actions… Aktionen speichern… Save Actions… Aktionen speichern…
New Action… Neue Aktion… New Action… Neue Aktion…
Start Recording Aufnahme starten Start Recording Aufnahme starten
@@ -3525,10 +3515,6 @@ curves Smooth Glätten
Font size presets Schriftgrößen-Vorgaben Font size presets Schriftgrößen-Vorgaben
Leading presets Zeilenabstand-Vorgaben Leading presets Zeilenabstand-Vorgaben
Tracking presets Laufweiten-Vorgaben Tracking presets Laufweiten-Vorgaben
Selection / Layer Bounds Auswahl / Ebenengrenzen
No selection or layer Keine Auswahl oder Ebene
{label}: empty {label}: leer
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Clear Selection Auswahl löschen Clear Selection Auswahl löschen
Color Aware Farbbezogen Color Aware Farbbezogen
Decontaminate Colors Farben dekontaminieren Decontaminate Colors Farben dekontaminieren
@@ -3546,9 +3532,3 @@ curves Smooth Glätten
Refine Hair needs hair detection, which isn't available yet Haare verbessern benötigt eine Haarerkennung, die noch nicht verfügbar ist Refine Hair needs hair detection, which isn't available yet Haare verbessern benötigt eine Haarerkennung, die noch nicht verfügbar ist
Select and Mask was closed: its document is no longer the active one Auswählen und maskieren wurde geschlossen: Das zugehörige Dokument ist nicht mehr aktiv Select and Mask was closed: its document is no longer the active one Auswählen und maskieren wurde geschlossen: Das zugehörige Dokument ist nicht mehr aktiv
Select and Mask was closed: another layer was selected Auswählen und maskieren wurde geschlossen: Es wurde eine andere Ebene ausgewählt Select and Mask was closed: another layer was selected Auswählen und maskieren wurde geschlossen: Es wurde eine andere Ebene ausgewählt
Import Shapes… Formen importieren…
Choose a custom shape file (.csh). Wählen Sie eine Datei mit eigenen Formen (.csh).
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Anzahl der aus {file} importierten Formen: {n}|Anzahl der aus {file} importierten Formen: {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Anzahl der aus {group} importierten Formen: {n}|Anzahl der aus {group} importierten Formen: {n}
Scrollbar Visibility Sichtbarkeit der Scrollleisten
Hidden Ausgeblendet
Can't render this file because it contains an unexpected character in line 6 and column 47.
-18
View File
@@ -233,7 +233,6 @@
Settings Ρυθμίσεις Settings Ρυθμίσεις
General Γενικά General Γενικά
Interface Περιβάλλον Interface Περιβάλλον
Use PhotoCraft-native gradient icons Χρήση εγγενών εικονιδίων διαβάθμισης PhotoCraft
Language Γλώσσα Language Γλώσσα
Auto Αυτόματα Auto Αυτόματα
Cancel Ακύρωση Cancel Ακύρωση
@@ -554,11 +553,6 @@
Smart Object Έξυπνο αντικείμενο Smart Object Έξυπνο αντικείμενο
Smart Objects Έξυπνα αντικείμενα Smart Objects Έξυπνα αντικείμενα
Smart Filters Έξυπνα φίλτρα Smart Filters Έξυπνα φίλτρα
# Smart Filter row actions (#1265)
Move Up Μετακίνηση επάνω
Move Down Μετακίνηση κάτω
Delete Smart Filter Διαγραφή έξυπνου φίλτρου
Adjustment Layer Επίπεδο προσαρμογής Adjustment Layer Επίπεδο προσαρμογής
Adjustment Layers Επίπεδα προσαρμογής Adjustment Layers Επίπεδα προσαρμογής
Pixel Layer Επίπεδο εικονοστοιχείων Pixel Layer Επίπεδο εικονοστοιχείων
@@ -3695,7 +3689,6 @@ fontWeight Thin Πολύ λεπτά
Out of gamut for printing: click for the nearest printable colour Εκτός γκάμας εκτύπωσης: κάντε κλικ για το πλησιέστερο εκτυπώσιμο χρώμα Out of gamut for printing: click for the nearest printable colour Εκτός γκάμας εκτύπωσης: κάντε κλικ για το πλησιέστερο εκτυπώσιμο χρώμα
Not a web colour: click for the nearest web colour Δεν είναι χρώμα web: κάντε κλικ για το πλησιέστερο χρώμα web Not a web colour: click for the nearest web colour Δεν είναι χρώμα web: κάντε κλικ για το πλησιέστερο χρώμα web
Color to Alpha… Χρώμα σε άλφα… Color to Alpha… Χρώμα σε άλφα…
Frequency Separation… Διαχωρισμός συχνοτήτων…
Transparency Threshold Κατώφλι διαφάνειας Transparency Threshold Κατώφλι διαφάνειας
Opacity Threshold Κατώφλι αδιαφάνειας Opacity Threshold Κατώφλι αδιαφάνειας
PhotoCraft — Screen Color Picker PhotoCraft — Επιλογή χρώματος οθόνης PhotoCraft — Screen Color Picker PhotoCraft — Επιλογή χρώματος οθόνης
@@ -3964,8 +3957,6 @@ curves Smooth Εξομάλυνση
Reset All Locked Settings Επαναφορά όλων των κλειδωμένων ρυθμίσεων Reset All Locked Settings Επαναφορά όλων των κλειδωμένων ρυθμίσεων
Copy Texture to Other Tools Αντιγραφή υφής σε άλλα εργαλεία Copy Texture to Other Tools Αντιγραφή υφής σε άλλα εργαλεία
Load Actions… Φόρτωση ενεργειών… Load Actions… Φόρτωση ενεργειών…
Load Default Actions Φόρτωση προεπιλεγμένων ενεργειών
Blur Background Θόλωση φόντου
Save Actions… Αποθήκευση ενεργειών… Save Actions… Αποθήκευση ενεργειών…
New Action… Νέα ενέργεια… New Action… Νέα ενέργεια…
Start Recording Έναρξη εγγραφής Start Recording Έναρξη εγγραφής
@@ -4205,10 +4196,6 @@ curves Smooth Εξομάλυνση
Font size presets Προεπιλογές μεγέθους γραμματοσειράς Font size presets Προεπιλογές μεγέθους γραμματοσειράς
Leading presets Προεπιλογές διάστιχου Leading presets Προεπιλογές διάστιχου
Tracking presets Προεπιλογές διαστήματος χαρακτήρων Tracking presets Προεπιλογές διαστήματος χαρακτήρων
Selection / Layer Bounds Όρια επιλογής / επιπέδου
No selection or layer Δεν υπάρχει επιλογή ή επίπεδο
{label}: empty {label}: κενό
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Color Aware Με βάση το χρώμα Color Aware Με βάση το χρώμα
Decontaminate Colors Απολύμανση χρωμάτων Decontaminate Colors Απολύμανση χρωμάτων
Edge Detection Ανίχνευση άκρης Edge Detection Ανίχνευση άκρης
@@ -4226,8 +4213,3 @@ curves Smooth Εξομάλυνση
Select and Mask was closed: its document is no longer the active one Η Επιλογή και μάσκα έκλεισε: το έγγραφό της δεν είναι πλέον το ενεργό Select and Mask was closed: its document is no longer the active one Η Επιλογή και μάσκα έκλεισε: το έγγραφό της δεν είναι πλέον το ενεργό
Select and Mask was closed: another layer was selected Η Επιλογή και μάσκα έκλεισε: επιλέχθηκε άλλο επίπεδο Select and Mask was closed: another layer was selected Η Επιλογή και μάσκα έκλεισε: επιλέχθηκε άλλο επίπεδο
Select Layer Under Cursor Επιλογή επιπέδου κάτω από τον δρομέα Select Layer Under Cursor Επιλογή επιπέδου κάτω από τον δρομέα
Import Shapes… Εισαγωγή σχημάτων…
Choose a custom shape file (.csh). Επιλέξτε ένα αρχείο προσαρμοσμένων σχημάτων (.csh).
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Αριθμός σχημάτων που εισήχθησαν από {file}: {n}|Αριθμός σχημάτων που εισήχθησαν από {file}: {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Αριθμός σχημάτων που εισήχθησαν από {group}: {n}|Αριθμός σχημάτων που εισήχθησαν από {group}: {n}
Scrollbar Visibility Ορατότητα γραμμών κύλισης
Can't render this file because it contains an unexpected character in line 47 and column 53.
-16
View File
@@ -1891,9 +1891,6 @@
Slice Select Tool Herramienta Selección de sector Slice Select Tool Herramienta Selección de sector
Slice Tool Herramienta Sector Slice Tool Herramienta Sector
Smart Filters Filtros inteligentes Smart Filters Filtros inteligentes
Move Up Mover arriba
Move Down Mover abajo
Delete Smart Filter Eliminar filtro inteligente
Smudge Tool Herramienta Dedo Smudge Tool Herramienta Dedo
Soft Suave Soft Suave
Soften Suavizar Soften Suavizar
@@ -2032,7 +2029,6 @@
Image previews Vistas previas de imagen Image previews Vistas previas de imagen
Integrations Integraciones Integrations Integraciones
Interface Interfaz Interface Interfaz
Use PhotoCraft-native gradient icons Usar iconos nativos de degradado de PhotoCraft
Jpeg quality Calidad JPEG Jpeg quality Calidad JPEG
Jpg JPG Jpg JPG
Language Idioma Language Idioma
@@ -2612,7 +2608,6 @@ fontWeight Italic Cursiva
Out of gamut for printing: click for the nearest printable colour Fuera de la gama de impresión: haga clic para el color imprimible más cercano Out of gamut for printing: click for the nearest printable colour Fuera de la gama de impresión: haga clic para el color imprimible más cercano
Not a web colour: click for the nearest web colour No es un color web: haga clic para el color web más cercano Not a web colour: click for the nearest web colour No es un color web: haga clic para el color web más cercano
Color to Alpha… Color a alfa… Color to Alpha… Color a alfa…
Frequency Separation… Separación de frecuencias…
Transparency Threshold Umbral de transparencia Transparency Threshold Umbral de transparencia
Opacity Threshold Umbral de opacidad Opacity Threshold Umbral de opacidad
PhotoCraft — Screen Color Picker PhotoCraft — Selector de color de pantalla PhotoCraft — Screen Color Picker PhotoCraft — Selector de color de pantalla
@@ -4176,8 +4171,6 @@ navigator Zoom Zoom
Reset All Locked Settings Restaurar todos los ajustes bloqueados Reset All Locked Settings Restaurar todos los ajustes bloqueados
Copy Texture to Other Tools Copiar textura en otras herramientas Copy Texture to Other Tools Copiar textura en otras herramientas
Load Actions… Cargar acciones… Load Actions… Cargar acciones…
Load Default Actions Cargar acciones predeterminadas
Blur Background Desenfocar fondo
Save Actions… Guardar acciones… Save Actions… Guardar acciones…
New Action… Nueva acción… New Action… Nueva acción…
Start Recording Iniciar grabación Start Recording Iniciar grabación
@@ -4414,10 +4407,6 @@ navigator Zoom Zoom
Font size presets Valores predefinidos de tamaño de fuente Font size presets Valores predefinidos de tamaño de fuente
Leading presets Valores predefinidos de interlineado Leading presets Valores predefinidos de interlineado
Tracking presets Valores predefinidos de tracking Tracking presets Valores predefinidos de tracking
Selection / Layer Bounds Límites de selección / capa
No selection or layer Sin selección ni capa
{label}: empty {label}: vacío
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Color Aware Según color Color Aware Según color
Decontaminate Colors Descontaminar colores Decontaminate Colors Descontaminar colores
Edge Detection Detección de bordes Edge Detection Detección de bordes
@@ -4435,9 +4424,4 @@ navigator Zoom Zoom
Select and Mask was closed: its document is no longer the active one Se cerró Seleccionar y aplicar máscara: su documento ya no es el activo Select and Mask was closed: its document is no longer the active one Se cerró Seleccionar y aplicar máscara: su documento ya no es el activo
Select and Mask was closed: another layer was selected Se cerró Seleccionar y aplicar máscara: se seleccionó otra capa Select and Mask was closed: another layer was selected Se cerró Seleccionar y aplicar máscara: se seleccionó otra capa
Select Layer Under Cursor Seleccionar capa bajo el cursor Select Layer Under Cursor Seleccionar capa bajo el cursor
Import Shapes… Importar formas…
Choose a custom shape file (.csh). Selecciona un archivo de formas personalizadas (.csh).
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Cantidad de formas importadas de {file}: {n}|Cantidad de formas importadas de {file}: {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Cantidad de formas importadas de {group}: {n}|Cantidad de formas importadas de {group}: {n}
Scrollbar Visibility Visibilidad de las barras de desplazamiento
Drag to move the selected path Arrastre para mover el trazado seleccionado Drag to move the selected path Arrastre para mover el trazado seleccionado
Can't render this file because it contains an unexpected character in line 4 and column 47.
-23
View File
@@ -1899,11 +1899,6 @@
Slice Select Tool Outil Sélection de tranche Slice Select Tool Outil Sélection de tranche
Slice Tool Outil Tranche Slice Tool Outil Tranche
Smart Filters Filtres intelligents Smart Filters Filtres intelligents
# Smart Filter row actions (#1265)
Move Up Déplacer vers le haut
Move Down Déplacer vers le bas
Delete Smart Filter Supprimer le filtre intelligent
Smudge Tool Outil Doigt Smudge Tool Outil Doigt
Soft Doux Soft Doux
Soften Adoucir Soften Adoucir
@@ -2040,7 +2035,6 @@
Image previews Aperçus d'image Image previews Aperçus d'image
Integrations Intégrations Integrations Intégrations
Interface Interface Interface Interface
Use PhotoCraft-native gradient icons Utiliser les icônes natives de dégradé PhotoCraft
Jpeg quality Qualité JPEG Jpeg quality Qualité JPEG
Jpg JPG Jpg JPG
Language Langue Language Langue
@@ -3006,7 +3000,6 @@ fontWeight Italic Italique
Out of gamut for printing: click for the nearest printable colour Hors de la gamme d’impression : cliquez pour la couleur imprimable la plus proche Out of gamut for printing: click for the nearest printable colour Hors de la gamme d’impression : cliquez pour la couleur imprimable la plus proche
Not a web colour: click for the nearest web colour Pas une couleur web : cliquez pour la couleur web la plus proche Not a web colour: click for the nearest web colour Pas une couleur web : cliquez pour la couleur web la plus proche
Color to Alpha… Couleur vers alpha… Color to Alpha… Couleur vers alpha…
Frequency Separation… Séparation de fréquences…
Transparency Threshold Seuil de transparence Transparency Threshold Seuil de transparence
Opacity Threshold Seuil d'opacité Opacity Threshold Seuil d'opacité
PhotoCraft — Screen Color Picker PhotoCraft — Sélecteur de couleur d'écran PhotoCraft — Screen Color Picker PhotoCraft — Sélecteur de couleur d'écran
@@ -3270,8 +3263,6 @@ curves Smooth Lisser
Reset All Locked Settings Réinitialiser tous les paramètres verrouillés Reset All Locked Settings Réinitialiser tous les paramètres verrouillés
Copy Texture to Other Tools Copier la texture dans d'autres outils Copy Texture to Other Tools Copier la texture dans d'autres outils
Load Actions… Charger des actions… Load Actions… Charger des actions…
Load Default Actions Charger les actions par défaut
Blur Background Flouter l’arrière-plan
Save Actions… Enregistrer les actions… Save Actions… Enregistrer les actions…
New Action… Nouvelle action… New Action… Nouvelle action…
Start Recording Démarrer l'enregistrement Start Recording Démarrer l'enregistrement
@@ -3302,8 +3293,6 @@ curves Smooth Lisser
Remember to turn off the printer's own colour management in Print Settings… Pensez à désactiver la gestion des couleurs de l’imprimante dans les paramètres d’impression… Remember to turn off the printer's own colour management in Print Settings… Pensez à désactiver la gestion des couleurs de l’imprimante dans les paramètres d’impression…
Sent to {printer} Envoyé à {printer} Sent to {printer} Envoyé à {printer}
set in Print Settings… défini dans les paramètres d’impression… set in Print Settings… défini dans les paramètres d’impression…
Scrollbar Visibility Visibilité des barres de défilement
Hidden Masqué
ICO is limited to 256 × 256 pixels Le format ICO est limité à 256 × 256 pixels ICO is limited to 256 × 256 pixels Le format ICO est limité à 256 × 256 pixels
Show Favorite Fonts Afficher les polices favorites Show Favorite Fonts Afficher les polices favorites
No favorite fonts yet: click the star beside a font to add it. Aucune police favorite pour l’instant : cliquez sur l’étoile à côté d’une police pour l’ajouter. No favorite fonts yet: click the star beside a font to add it. Aucune police favorite pour l’instant : cliquez sur l’étoile à côté d’une police pour l’ajouter.
@@ -3343,14 +3332,6 @@ curves Smooth Lisser
Could not write {name}: {error} Impossible d’écrire {name} : {error} Could not write {name}: {error} Impossible d’écrire {name} : {error}
Kept {name}: written by another application {name} conservé : écrit par une autre application Kept {name}: written by another application {name} conservé : écrit par une autre application
Shadow Tint Teinte des ombres Shadow Tint Teinte des ombres
# Custom shape set import
Import Shapes… Importer des formes…
Choose a custom shape file (.csh). Choisissez un fichier de formes personnalisées (.csh).
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Nombre de formes importées depuis {file} : {n}|Nombre de formes importées depuis {file} : {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Nombre de formes importées depuis {group} : {n}|Nombre de formes importées depuis {group} : {n}
# PhotoCraft release checks.
Check for Updates… Rechercher des mises à jour… Check for Updates… Rechercher des mises à jour…
PhotoCraft Updates Mises à jour de PhotoCraft PhotoCraft Updates Mises à jour de PhotoCraft
Checking for updates… Recherche de mises à jour… Checking for updates… Recherche de mises à jour…
@@ -4552,10 +4533,6 @@ shape Appearance Apparence
Font size presets Tailles de police prédéfinies Font size presets Tailles de police prédéfinies
Leading presets Interlignages prédéfinis Leading presets Interlignages prédéfinis
Tracking presets Approches prédéfinies Tracking presets Approches prédéfinies
Selection / Layer Bounds Limites de la sélection / du calque
No selection or layer Aucune sélection ni aucun calque
{label}: empty {label} : vide
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Color Aware Selon la couleur Color Aware Selon la couleur
Decontaminate Colors Décontaminer les couleurs Decontaminate Colors Décontaminer les couleurs
Edge Detection Détection des contours Edge Detection Détection des contours
Can't render this file because it contains an unexpected character in line 6 and column 47.
-3
View File
@@ -539,9 +539,6 @@ cameraRaw Yellow पीला
Delete {count} layers? {names} {count} लेयर हटाएँ? {names} Delete {count} layers? {names} {count} लेयर हटाएँ? {names}
Delete layer? लेयर हटाएँ? Delete layer? लेयर हटाएँ?
Don't show again दोबारा न दिखाएँ Don't show again दोबारा न दिखाएँ
# Paragraph panel
Roman Hanging Punctuation रोमन हैंगिंग विराम चिह्न
The document is closed दस्तावेज़ बंद है The document is closed दस्तावेज़ बंद है
The document or selected layers changed. Please request deletion again. दस्तावेज़ या चुनी हुई लेयर बदल गई हैं। कृपया हटाने का अनुरोध फिर से करें। The document or selected layers changed. Please request deletion again. दस्तावेज़ या चुनी हुई लेयर बदल गई हैं। कृपया हटाने का अनुरोध फिर से करें।
Missing delete command हटाने का कमांड नहीं मिला Missing delete command हटाने का कमांड नहीं मिला
Can't render this file because it contains an unexpected character in line 6 and column 47.
-16
View File
@@ -1466,7 +1466,6 @@
Use esc to commit Véglegesítés az Esc billentyűvel Use esc to commit Véglegesítés az Esc billentyűvel
Use finite lengths and a positive pattern total Véges hosszakat adj meg, és a mintázat teljes hossza legyen pozitív Use finite lengths and a positive pattern total Véges hosszakat adj meg, és a mintázat teljes hossza legyen pozitív
Use legacy free transform Régi szabad alakítás használata Use legacy free transform Régi szabad alakítás használata
Use PhotoCraft-native gradient icons PhotoCraft saját színátmenet-ikonjainak használata
Vary round brush hardness on hud Kerek ecset keménységének állítása a HUD-on Vary round brush hardness on hud Kerek ecset keménységének állítása a HUD-on
Version Verzió Version Verzió
Vertical Függőleges Vertical Függőleges
@@ -1734,9 +1733,6 @@ fontWeight Thin Hajszálvékony
Show the layer's effects ({key}-click: all layers) Réteg effektusainak megjelenítése ({key}+kattintás: minden réteg) Show the layer's effects ({key}-click: all layers) Réteg effektusainak megjelenítése ({key}+kattintás: minden réteg)
Skip Kihagy Skip Kihagy
Smart Filters Intelligens szűrők Smart Filters Intelligens szűrők
Move Up Mozgatás felfelé
Move Down Mozgatás lefelé
Delete Smart Filter Intelligens szűrő törlése
Soft Light Lágy fény Soft Light Lágy fény
Soften Lágyítás Soften Lágyítás
Source Forrás Source Forrás
@@ -4134,13 +4130,10 @@ cameraRaw Yellow Sárga
Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattints a négy sarokra Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattints a négy sarokra
Response curve Válaszgörbe Response curve Válaszgörbe
Show home recent files Legutóbbi fájlok megjelenítése a kezdőképernyőn Show home recent files Legutóbbi fájlok megjelenítése a kezdőképernyőn
Frequency Separation… Frekvenciaszétválasztás…
Clear Brush Controls Ecsetvezérlők törlése Clear Brush Controls Ecsetvezérlők törlése
Reset All Locked Settings Összes zárolt beállítás visszaállítása Reset All Locked Settings Összes zárolt beállítás visszaállítása
Copy Texture to Other Tools Textúra másolása más eszközökre Copy Texture to Other Tools Textúra másolása más eszközökre
Load Actions… Műveletek betöltése… Load Actions… Műveletek betöltése…
Load Default Actions Alapértelmezett műveletek betöltése
Blur Background Háttér elmosása
Save Actions… Műveletek mentése… Save Actions… Műveletek mentése…
New Action… Új művelet… New Action… Új művelet…
Start Recording Felvétel indítása Start Recording Felvétel indítása
@@ -4414,10 +4407,6 @@ cameraRaw Yellow Sárga
Font size presets Betűméret-előbeállítások Font size presets Betűméret-előbeállítások
Leading presets Sorköz-előbeállítások Leading presets Sorköz-előbeállítások
Tracking presets Betűköz-előbeállítások Tracking presets Betűköz-előbeállítások
Selection / Layer Bounds Kijelölés / Réteghatárok
No selection or layer Nincs kijelölés vagy réteg
{label}: empty {label}: üres
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Color Aware Színalapú Color Aware Színalapú
Decontaminate Colors Színek fertőtlenítése Decontaminate Colors Színek fertőtlenítése
Edge Detection Élészlelés Edge Detection Élészlelés
@@ -4434,9 +4423,4 @@ cameraRaw Yellow Sárga
Refine Hair needs hair detection, which isn't available yet A Haj finomításához hajfelismerés kell, amely még nem érhető el Refine Hair needs hair detection, which isn't available yet A Haj finomításához hajfelismerés kell, amely még nem érhető el
Select and Mask was closed: its document is no longer the active one A Kijelölés és maszk bezárult: a dokumentuma már nem az aktív Select and Mask was closed: its document is no longer the active one A Kijelölés és maszk bezárult: a dokumentuma már nem az aktív
Select and Mask was closed: another layer was selected A Kijelölés és maszk bezárult: másik réteg lett kijelölve Select and Mask was closed: another layer was selected A Kijelölés és maszk bezárult: másik réteg lett kijelölve
Import Shapes… Alakzatok importálása…
Choose a custom shape file (.csh). Válasszon egy egyéni alakzatfájlt (.csh).
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} A(z) {file} forrásból importált alakzatok száma: {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} A(z) {group} forrásból importált alakzatok száma: {n}
Scrollbar Visibility Görgetősávok láthatósága
Drag to move the selected path Húzza a kijelölt útvonal mozgatásához Drag to move the selected path Húzza a kijelölt útvonal mozgatásához
Can't render this file because it contains an unexpected character in line 6 and column 47.
-19
View File
@@ -1907,11 +1907,6 @@
Slice Select Tool Alat Slice Select Slice Select Tool Alat Slice Select
Slice Tool Alat Slice Slice Tool Alat Slice
Smart Filters Smart Filter Smart Filters Smart Filter
# Smart Filter row actions (#1265)
Move Up Pindah ke atas
Move Down Pindah ke bawah
Delete Smart Filter Hapus Smart Filter
Smudge Tool Alat Smudge Smudge Tool Alat Smudge
Soft Lembut Soft Lembut
Soften Lembutkan Soften Lembutkan
@@ -2048,7 +2043,6 @@
Image previews Pratinjau gambar Image previews Pratinjau gambar
Integrations Integrasi Integrations Integrasi
Interface Antarmuka Interface Antarmuka
Use PhotoCraft-native gradient icons Gunakan ikon gradien bawaan PhotoCraft
Jpeg quality Kualitas JPEG Jpeg quality Kualitas JPEG
Jpg JPG Jpg JPG
Language Bahasa Language Bahasa
@@ -3011,7 +3005,6 @@ fontWeight Italic Miring
Out of gamut for printing: click for the nearest printable colour Di luar gamut cetak: klik untuk warna cetak terdekat Out of gamut for printing: click for the nearest printable colour Di luar gamut cetak: klik untuk warna cetak terdekat
Not a web colour: click for the nearest web colour Bukan warna web: klik untuk warna web terdekat Not a web colour: click for the nearest web colour Bukan warna web: klik untuk warna web terdekat
Color to Alpha… Warna ke Alpha… Color to Alpha… Warna ke Alpha…
Frequency Separation… Pemisahan Frekuensi…
Transparency Threshold Ambang transparansi Transparency Threshold Ambang transparansi
Opacity Threshold Ambang opasitas Opacity Threshold Ambang opasitas
PhotoCraft — Screen Color Picker PhotoCraft — Pemilih Warna Layar PhotoCraft — Screen Color Picker PhotoCraft — Pemilih Warna Layar
@@ -3275,8 +3268,6 @@ curves Smooth Haluskan
Reset All Locked Settings Atur Ulang Semua Pengaturan Terkunci Reset All Locked Settings Atur Ulang Semua Pengaturan Terkunci
Copy Texture to Other Tools Salin Tekstur ke Alat Lain Copy Texture to Other Tools Salin Tekstur ke Alat Lain
Load Actions… Muat Tindakan… Load Actions… Muat Tindakan…
Load Default Actions Muat Aksi Default
Blur Background Buramkan Latar Belakang
Save Actions… Simpan Tindakan… Save Actions… Simpan Tindakan…
New Action… Tindakan Baru… New Action… Tindakan Baru…
Start Recording Mulai Rekaman Start Recording Mulai Rekaman
@@ -3524,10 +3515,6 @@ curves Smooth Haluskan
Font size presets Preset ukuran font Font size presets Preset ukuran font
Leading presets Preset leading Leading presets Preset leading
Tracking presets Preset tracking Tracking presets Preset tracking
Selection / Layer Bounds Batas Seleksi / Layer
No selection or layer Tidak ada seleksi atau layer
{label}: empty {label}: kosong
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Clear Selection Hapus Seleksi Clear Selection Hapus Seleksi
Color Aware Berbasis Warna Color Aware Berbasis Warna
Decontaminate Colors Dekontaminasi Warna Decontaminate Colors Dekontaminasi Warna
@@ -3546,9 +3533,3 @@ curves Smooth Haluskan
Select and Mask was closed: its document is no longer the active one Seleksi dan Mask ditutup: dokumennya tidak lagi aktif Select and Mask was closed: its document is no longer the active one Seleksi dan Mask ditutup: dokumennya tidak lagi aktif
Select and Mask was closed: another layer was selected Seleksi dan Mask ditutup: layer lain dipilih Select and Mask was closed: another layer was selected Seleksi dan Mask ditutup: layer lain dipilih
Select Layer Under Cursor Pilih Lapisan di Bawah Kursor Select Layer Under Cursor Pilih Lapisan di Bawah Kursor
Import Shapes… Impor Bentuk…
Choose a custom shape file (.csh). Pilih berkas bentuk khusus (.csh).
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Jumlah bentuk yang diimpor dari {file}: {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Jumlah bentuk yang diimpor dari {group}: {n}
Scrollbar Visibility Visibilitas bilah gulir
Hidden Tersembunyi
Can't render this file because it contains an unexpected character in line 4 and column 47.
-18
View File
@@ -1936,11 +1936,6 @@
Slice Select Tool Strumento Selezione sezioni Slice Select Tool Strumento Selezione sezioni
Slice Tool Strumento Sezione Slice Tool Strumento Sezione
Smart Filters Filtri avanzati Smart Filters Filtri avanzati
# Smart Filter row actions (#1265)
Move Up Sposta su
Move Down Sposta giù
Delete Smart Filter Elimina filtro avanzato
Smudge Tool Strumento Sfumino Smudge Tool Strumento Sfumino
Soft Morbido Soft Morbido
Soften Ammorbidisci Soften Ammorbidisci
@@ -2079,7 +2074,6 @@
Image previews Anteprime immagine Image previews Anteprime immagine
Integrations Integrazioni Integrations Integrazioni
Interface Interfaccia Interface Interfaccia
Use PhotoCraft-native gradient icons Usa le icone native delle sfumature di PhotoCraft
Jpeg quality Qualità JPEG Jpeg quality Qualità JPEG
Jpg JPG Jpg JPG
Language Lingua Language Lingua
@@ -2660,7 +2654,6 @@ fontWeight Italic Corsivo
Out of gamut for printing: click for the nearest printable colour Fuori gamma di stampa: fai clic per il colore stampabile più vicino Out of gamut for printing: click for the nearest printable colour Fuori gamma di stampa: fai clic per il colore stampabile più vicino
Not a web colour: click for the nearest web colour Non è un colore web: fai clic per il colore web più vicino Not a web colour: click for the nearest web colour Non è un colore web: fai clic per il colore web più vicino
Color to Alpha… Colore ad alfa… Color to Alpha… Colore ad alfa…
Frequency Separation… Separazione delle frequenze…
Transparency Threshold Soglia di trasparenza Transparency Threshold Soglia di trasparenza
Opacity Threshold Soglia di opacità Opacity Threshold Soglia di opacità
PhotoCraft — Screen Color Picker PhotoCraft — Selettore colore schermo PhotoCraft — Screen Color Picker PhotoCraft — Selettore colore schermo
@@ -4227,8 +4220,6 @@ navigator Zoom Zoom
Reset All Locked Settings Ripristina tutte le impostazioni bloccate Reset All Locked Settings Ripristina tutte le impostazioni bloccate
Copy Texture to Other Tools Copia texture su altri strumenti Copy Texture to Other Tools Copia texture su altri strumenti
Load Actions… Carica azioni… Load Actions… Carica azioni…
Load Default Actions Carica azioni predefinite
Blur Background Sfoca sfondo
Save Actions… Salva azioni… Save Actions… Salva azioni…
New Action… Nuova azione… New Action… Nuova azione…
Start Recording Avvia registrazione Start Recording Avvia registrazione
@@ -4618,10 +4609,6 @@ shape Appearance Aspetto
Font size presets Predefiniti dimensione font Font size presets Predefiniti dimensione font
Leading presets Predefiniti interlinea Leading presets Predefiniti interlinea
Tracking presets Predefiniti spaziatura Tracking presets Predefiniti spaziatura
Selection / Layer Bounds Limiti selezione / livello
No selection or layer Nessuna selezione o livello
{label}: empty {label}: vuoto
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Color Aware In base al colore Color Aware In base al colore
Decontaminate Colors Decontamina colori Decontaminate Colors Decontamina colori
Edge Detection Rilevamento bordi Edge Detection Rilevamento bordi
@@ -4638,8 +4625,3 @@ shape Appearance Aspetto
Refine Hair needs hair detection, which isn't available yet Migliora capelli richiede il rilevamento dei capelli, non ancora disponibile Refine Hair needs hair detection, which isn't available yet Migliora capelli richiede il rilevamento dei capelli, non ancora disponibile
Select and Mask was closed: its document is no longer the active one Seleziona e maschera è stato chiuso: il suo documento non è più quello attivo Select and Mask was closed: its document is no longer the active one Seleziona e maschera è stato chiuso: il suo documento non è più quello attivo
Select and Mask was closed: another layer was selected Seleziona e maschera è stato chiuso: è stato selezionato un altro livello Select and Mask was closed: another layer was selected Seleziona e maschera è stato chiuso: è stato selezionato un altro livello
Import Shapes… Importa forme…
Choose a custom shape file (.csh). Scegli un file di forme personalizzate (.csh).
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Numero di forme importate da {file}: {n}|Numero di forme importate da {file}: {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Numero di forme importate da {group}: {n}|Numero di forme importate da {group}: {n}
Scrollbar Visibility Visibilità delle barre di scorrimento
Can't render this file because it contains an unexpected character in line 79 and column 53.
-17
View File
@@ -1944,9 +1944,6 @@
Slice Select Tool スライス選択ツール Slice Select Tool スライス選択ツール
Slice Tool スライスツール Slice Tool スライスツール
Smart Filters スマートフィルター Smart Filters スマートフィルター
Move Up 上へ移動
Move Down 下へ移動
Delete Smart Filter スマートフィルターを削除
Smudge Tool 指先ツール Smudge Tool 指先ツール
Soft ソフト Soft ソフト
Soften ぼかし Soften ぼかし
@@ -2085,7 +2082,6 @@
Image previews 画像のプレビュー Image previews 画像のプレビュー
Integrations 連携 Integrations 連携
Interface インターフェイス Interface インターフェイス
Use PhotoCraft-native gradient icons PhotoCraft 標準のグラデーションアイコンを使用
Jpeg quality JPEG 品質 Jpeg quality JPEG 品質
Jpg JPG Jpg JPG
Language 言語 Language 言語
@@ -3612,7 +3608,6 @@ fontWeight Italic 斜体
↑↓ navigate ↵ run esc close ↑↓ 移動 ↵ 実行 esc 閉じる ↑↓ navigate ↵ run esc close ↑↓ 移動 ↵ 実行 esc 閉じる
@id select.all すべてを選択 @id select.all すべてを選択
Color to Alpha… カラーを透明に… Color to Alpha… カラーを透明に…
Frequency Separation… 周波数分離…
Transparency Threshold 透明のしきい値 Transparency Threshold 透明のしきい値
Opacity Threshold 不透明度のしきい値 Opacity Threshold 不透明度のしきい値
PhotoCraft — Screen Color Picker PhotoCraft — スクリーンカラーピッカー PhotoCraft — Screen Color Picker PhotoCraft — スクリーンカラーピッカー
@@ -3880,8 +3875,6 @@ curves Smooth スムーズ
Saved contours to {file} カーブを {file} に保存しました Saved contours to {file} カーブを {file} に保存しました
Show home recent files ホーム画面に最近使ったファイルを表示 Show home recent files ホーム画面に最近使ったファイルを表示
Load Actions… アクションを読み込み… Load Actions… アクションを読み込み…
Load Default Actions 既定のアクションを読み込み
Blur Background 背景をぼかす
Save Actions… アクションを保存… Save Actions… アクションを保存…
New Action… 新規アクション… New Action… 新規アクション…
Start Recording 記録を開始 Start Recording 記録を開始
@@ -3968,8 +3961,6 @@ curves Smooth スムーズ
Clear Brush Controls ブラシのコントロールを消去 Clear Brush Controls ブラシのコントロールを消去
Reset All Locked Settings ロックされた設定をすべて初期化 Reset All Locked Settings ロックされた設定をすべて初期化
Copy Texture to Other Tools テクスチャを他のツールにコピー Copy Texture to Other Tools テクスチャを他のツールにコピー
Scrollbar Visibility スクロールバーの表示
Hidden 非表示
ICO is limited to 256 × 256 pixels ICO は 256 × 256 ピクセルまでです ICO is limited to 256 × 256 pixels ICO は 256 × 256 ピクセルまでです
Show Favorite Fonts お気に入りのフォントを表示 Show Favorite Fonts お気に入りのフォントを表示
No favorite fonts yet: click the star beside a font to add it. お気に入りのフォントはまだありません。フォントの横の星をクリックして追加してください。 No favorite fonts yet: click the star beside a font to add it. お気に入りのフォントはまだありません。フォントの横の星をクリックして追加してください。
@@ -4180,10 +4171,6 @@ curves Smooth スムーズ
Font size presets フォントサイズのプリセット Font size presets フォントサイズのプリセット
Leading presets 行送りのプリセット Leading presets 行送りのプリセット
Tracking presets トラッキングのプリセット Tracking presets トラッキングのプリセット
Selection / Layer Bounds 選択範囲 / レイヤーの境界
No selection or layer 選択範囲またはレイヤーがありません
{label}: empty {label}: 空
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Clear Selection 選択範囲をクリア Clear Selection 選択範囲をクリア
Color Aware カラー認識 Color Aware カラー認識
Decontaminate Colors 不要なカラーの除去 Decontaminate Colors 不要なカラーの除去
@@ -4201,8 +4188,4 @@ curves Smooth スムーズ
Refine Hair needs hair detection, which isn't available yet 髪の毛を調整には髪の毛の検出が必要ですが、まだ利用できません Refine Hair needs hair detection, which isn't available yet 髪の毛を調整には髪の毛の検出が必要ですが、まだ利用できません
Select and Mask was closed: its document is no longer the active one 「選択とマスク」を閉じました: 対象のドキュメントがアクティブではなくなりました Select and Mask was closed: its document is no longer the active one 「選択とマスク」を閉じました: 対象のドキュメントがアクティブではなくなりました
Select and Mask was closed: another layer was selected 「選択とマスク」を閉じました: 別のレイヤーが選択されました Select and Mask was closed: another layer was selected 「選択とマスク」を閉じました: 別のレイヤーが選択されました
Import Shapes… シェイプを読み込み…
Choose a custom shape file (.csh). カスタムシェイプファイル (.csh) を選択してください。
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} {file} から読み込んだシェイプ数: {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} {group} から読み込んだシェイプ数: {n}
Drag to move the selected path ドラッグして選択したパスを移動 Drag to move the selected path ドラッグして選択したパスを移動
Can't render this file because it contains an unexpected character in line 6 and column 47.
-18
View File
@@ -1318,7 +1318,6 @@
Italic 기울임꼴 Italic 기울임꼴
Join us on Discord Discord 참여 Join us on Discord Discord 참여
Justify all 모든 줄 양쪽 정렬 Justify all 모든 줄 양쪽 정렬
Roman Hanging Punctuation 로마자 매달림 문장 부호
Justify last centered 마지막 줄 가운데 정렬 Justify last centered 마지막 줄 가운데 정렬
Justify last left 마지막 줄 왼쪽 정렬 Justify last left 마지막 줄 왼쪽 정렬
Justify last right 마지막 줄 오른쪽 정렬 Justify last right 마지막 줄 오른쪽 정렬
@@ -1920,9 +1919,6 @@
Slice Select Tool 분할 영역 선택 도구 Slice Select Tool 분할 영역 선택 도구
Slice Tool 분할 영역 도구 Slice Tool 분할 영역 도구
Smart Filters 스마트 필터 Smart Filters 스마트 필터
Move Up 위로 이동
Move Down 아래로 이동
Delete Smart Filter 스마트 필터 삭제
Smudge Tool 손가락 도구 Smudge Tool 손가락 도구
Soft 부드럽게 Soft 부드럽게
Soften 부드럽게 하기 Soften 부드럽게 하기
@@ -2059,7 +2055,6 @@
Image previews 이미지 미리 보기 Image previews 이미지 미리 보기
Integrations 연동 Integrations 연동
Interface 인터페이스 Interface 인터페이스
Use PhotoCraft-native gradient icons PhotoCraft 기본 그레이디언트 아이콘 사용
Jpeg quality JPEG 품질 Jpeg quality JPEG 품질
Jpg JPG Jpg JPG
Language 언어 Language 언어
@@ -3191,7 +3186,6 @@ fontWeight Italic 기울임
Out of gamut for printing: click for the nearest printable colour 인쇄 색역 밖: 클릭하면 가장 가까운 인쇄 가능한 색상으로 바뀝니다 Out of gamut for printing: click for the nearest printable colour 인쇄 색역 밖: 클릭하면 가장 가까운 인쇄 가능한 색상으로 바뀝니다
Not a web colour: click for the nearest web colour 웹 색상이 아님: 클릭하면 가장 가까운 웹 색상으로 바뀝니다 Not a web colour: click for the nearest web colour 웹 색상이 아님: 클릭하면 가장 가까운 웹 색상으로 바뀝니다
Color to Alpha… 색상을 알파로… Color to Alpha… 색상을 알파로…
Frequency Separation… 주파수 분리…
Transparency Threshold 투명도 한계값 Transparency Threshold 투명도 한계값
Opacity Threshold 불투명도 한계값 Opacity Threshold 불투명도 한계값
PhotoCraft — Screen Color Picker PhotoCraft — 화면 색상 선택기 PhotoCraft — Screen Color Picker PhotoCraft — 화면 색상 선택기
@@ -3469,8 +3463,6 @@ curves Smooth 매끄럽게
Reset All Locked Settings 모든 잠긴 설정 재설정 Reset All Locked Settings 모든 잠긴 설정 재설정
Copy Texture to Other Tools 다른 도구로 텍스처 복사 Copy Texture to Other Tools 다른 도구로 텍스처 복사
Load Actions… 액션 불러오기… Load Actions… 액션 불러오기…
Load Default Actions 기본 액션 불러오기
Blur Background 배경 흐리게
Save Actions… 액션 저장… Save Actions… 액션 저장…
New Action… 새 액션… New Action… 새 액션…
Start Recording 기록 시작 Start Recording 기록 시작
@@ -3721,10 +3713,6 @@ curves Smooth 매끄럽게
Font size presets 글꼴 크기 사전 설정 Font size presets 글꼴 크기 사전 설정
Leading presets 행간 사전 설정 Leading presets 행간 사전 설정
Tracking presets 자간 사전 설정 Tracking presets 자간 사전 설정
Selection / Layer Bounds 선택 영역 / 레이어 경계
No selection or layer 선택 영역 또는 레이어 없음
{label}: empty {label}: 비어 있음
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Clear Selection 선택 영역 지우기 Clear Selection 선택 영역 지우기
Color Aware 색상 인식 Color Aware 색상 인식
Decontaminate Colors 색상 정화 Decontaminate Colors 색상 정화
@@ -3743,10 +3731,4 @@ curves Smooth 매끄럽게
Select and Mask was closed: its document is no longer the active one 선택 및 마스크가 닫혔습니다: 해당 문서가 더 이상 활성 문서가 아닙니다 Select and Mask was closed: its document is no longer the active one 선택 및 마스크가 닫혔습니다: 해당 문서가 더 이상 활성 문서가 아닙니다
Select and Mask was closed: another layer was selected 선택 및 마스크가 닫혔습니다: 다른 레이어가 선택되었습니다 Select and Mask was closed: another layer was selected 선택 및 마스크가 닫혔습니다: 다른 레이어가 선택되었습니다
Select Layer Under Cursor 커서 아래 레이어 선택 Select Layer Under Cursor 커서 아래 레이어 선택
Import Shapes… 모양 가져오기…
Choose a custom shape file (.csh). 사용자 정의 모양 파일 (.csh)을 선택하세요.
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} {file}에서 가져온 모양 수: {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} {group}에서 가져온 모양 수: {n}
Scrollbar Visibility 스크롤 막대 표시
Hidden 숨김
Drag to move the selected path 드래그하여 선택한 패스 이동 Drag to move the selected path 드래그하여 선택한 패스 이동
Can't render this file because it contains an unexpected character in line 63 and column 53.
-18
View File
@@ -1866,11 +1866,6 @@
Slice Select Tool Segmentselectiegereedschap Slice Select Tool Segmentselectiegereedschap
Slice Tool Segmentgereedschap Slice Tool Segmentgereedschap
Smart Filters Slimme filters Smart Filters Slimme filters
# Smart Filter row actions (#1265)
Move Up Omhoog
Move Down Omlaag
Delete Smart Filter Slim filter verwijderen
Smudge Tool Uitsmeergereedschap Smudge Tool Uitsmeergereedschap
Soft Zacht Soft Zacht
Soften Verzachten Soften Verzachten
@@ -2009,7 +2004,6 @@
Image previews Afbeeldingsvoorvertoningen Image previews Afbeeldingsvoorvertoningen
Integrations Integraties Integrations Integraties
Interface Interface Interface Interface
Use PhotoCraft-native gradient icons Eigen PhotoCraft-verlooppictogrammen gebruiken
Jpeg quality JPEG-kwaliteit Jpeg quality JPEG-kwaliteit
Jpg JPG Jpg JPG
Language Taal Language Taal
@@ -3755,7 +3749,6 @@ cameraRaw Whites Witten
101 by 101 Average Gemiddelde van 101 bij 101 101 by 101 Average Gemiddelde van 101 bij 101
All Layers No Adjustments Alle lagen zonder aanpassingen All Layers No Adjustments Alle lagen zonder aanpassingen
Color to Alpha… Kleur naar alfa… Color to Alpha… Kleur naar alfa…
Frequency Separation… Frequentiescheiding…
Current & Below No Adjustments Huidige en onderliggende zonder aanpassingen Current & Below No Adjustments Huidige en onderliggende zonder aanpassingen
Now drag the selection onto another area Sleep de selectie nu naar een ander gebied Now drag the selection onto another area Sleep de selectie nu naar een ander gebied
Point Sample Puntmonster Point Sample Puntmonster
@@ -4023,8 +4016,6 @@ curves Smooth Vloeiend maken
Reset All Locked Settings Alle vergrendelde instellingen opnieuw instellen Reset All Locked Settings Alle vergrendelde instellingen opnieuw instellen
Copy Texture to Other Tools Structuur kopiëren naar andere gereedschappen Copy Texture to Other Tools Structuur kopiëren naar andere gereedschappen
Load Actions… Handelingen laden… Load Actions… Handelingen laden…
Load Default Actions Standaardacties laden
Blur Background Achtergrond vervagen
Save Actions… Handelingen opslaan… Save Actions… Handelingen opslaan…
New Action… Nieuwe handeling… New Action… Nieuwe handeling…
Start Recording Opname starten Start Recording Opname starten
@@ -4262,10 +4253,6 @@ curves Smooth Vloeiend maken
Font size presets Voorinstellingen lettergrootte Font size presets Voorinstellingen lettergrootte
Leading presets Voorinstellingen interlinie Leading presets Voorinstellingen interlinie
Tracking presets Voorinstellingen spatiëring Tracking presets Voorinstellingen spatiëring
Selection / Layer Bounds Selectie- / laaggrenzen
No selection or layer Geen selectie of laag
{label}: empty {label}: leeg
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Color Aware Kleurbewust Color Aware Kleurbewust
Decontaminate Colors Kleuren zuiveren Decontaminate Colors Kleuren zuiveren
Edge Detection Randdetectie Edge Detection Randdetectie
@@ -4283,8 +4270,3 @@ curves Smooth Vloeiend maken
Select and Mask was closed: its document is no longer the active one Selecteren en maskeren is gesloten: het document is niet meer het actieve document Select and Mask was closed: its document is no longer the active one Selecteren en maskeren is gesloten: het document is niet meer het actieve document
Select and Mask was closed: another layer was selected Selecteren en maskeren is gesloten: er is een andere laag geselecteerd Select and Mask was closed: another layer was selected Selecteren en maskeren is gesloten: er is een andere laag geselecteerd
Select Layer Under Cursor Laag onder cursor selecteren Select Layer Under Cursor Laag onder cursor selecteren
Import Shapes… Vormen importeren…
Choose a custom shape file (.csh). Kies een bestand met aangepaste vormen (.csh).
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Aantal uit {file} geïmporteerde vormen: {n}|Aantal uit {file} geïmporteerde vormen: {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Aantal uit {group} geïmporteerde vormen: {n}|Aantal uit {group} geïmporteerde vormen: {n}
Scrollbar Visibility Zichtbaarheid van schuifbalken
Can't render this file because it contains an unexpected character in line 8 and column 34.
-19
View File
@@ -1930,11 +1930,6 @@
Slice Select Tool Narzędzie Zaznaczanie plasterków Slice Select Tool Narzędzie Zaznaczanie plasterków
Slice Tool Narzędzie Plasterek Slice Tool Narzędzie Plasterek
Smart Filters Filtry inteligentne Smart Filters Filtry inteligentne
# Smart Filter row actions (#1265)
Move Up Przesuń w górę
Move Down Przesuń w dół
Delete Smart Filter Usuń filtr inteligentny
Smudge Tool Narzędzie Palec Smudge Tool Narzędzie Palec
Soft Miękkie Soft Miękkie
Soften Zmiękcz Soften Zmiękcz
@@ -2073,7 +2068,6 @@
Image previews Podglądy obrazów Image previews Podglądy obrazów
Integrations Integracje Integrations Integracje
Interface Interfejs Interface Interfejs
Use PhotoCraft-native gradient icons Używaj natywnych ikon gradientów PhotoCraft
Jpeg quality Jakość JPEG Jpeg quality Jakość JPEG
Jpg JPG Jpg JPG
Language Język Language Język
@@ -3622,7 +3616,6 @@ cameraRaw Roughness Chropowatość
Out of gamut for printing: click for the nearest printable colour Poza gamą druku: kliknij, aby wybrać najbliższy kolor do druku Out of gamut for printing: click for the nearest printable colour Poza gamą druku: kliknij, aby wybrać najbliższy kolor do druku
Not a web colour: click for the nearest web colour To nie jest kolor internetowy: kliknij, aby wybrać najbliższy kolor internetowy Not a web colour: click for the nearest web colour To nie jest kolor internetowy: kliknij, aby wybrać najbliższy kolor internetowy
Color to Alpha… Kolor na alfę… Color to Alpha… Kolor na alfę…
Frequency Separation… Separacja częstotliwości…
Transparency Threshold Próg przezroczystości Transparency Threshold Próg przezroczystości
Opacity Threshold Próg krycia Opacity Threshold Próg krycia
PhotoCraft — Screen Color Picker PhotoCraft — Próbnik kolorów ekranu PhotoCraft — Screen Color Picker PhotoCraft — Próbnik kolorów ekranu
@@ -3880,8 +3873,6 @@ curves Smooth Wygładź
Reset All Locked Settings Resetuj wszystkie zablokowane ustawienia Reset All Locked Settings Resetuj wszystkie zablokowane ustawienia
Copy Texture to Other Tools Kopiuj teksturę do innych narzędzi Copy Texture to Other Tools Kopiuj teksturę do innych narzędzi
Load Actions… Wczytaj operacje… Load Actions… Wczytaj operacje…
Load Default Actions Wczytaj domyślne operacje
Blur Background Rozmyj tło
Save Actions… Zapisz operacje… Save Actions… Zapisz operacje…
New Action… Nowa operacja… New Action… Nowa operacja…
Start Recording Rozpocznij nagrywanie Start Recording Rozpocznij nagrywanie
@@ -4122,10 +4113,6 @@ curves Smooth Wygładź
Font size presets Ustawienia wstępne rozmiaru czcionki Font size presets Ustawienia wstępne rozmiaru czcionki
Leading presets Ustawienia wstępne interlinii Leading presets Ustawienia wstępne interlinii
Tracking presets Ustawienia wstępne światła Tracking presets Ustawienia wstępne światła
Selection / Layer Bounds Granice zaznaczenia / warstwy
No selection or layer Brak zaznaczenia lub warstwy
{label}: empty {label}: puste
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Clear Selection Wyczyść zaznaczenie Clear Selection Wyczyść zaznaczenie
Color Aware Według kolorów Color Aware Według kolorów
Decontaminate Colors Odkażanie kolorów Decontaminate Colors Odkażanie kolorów
@@ -4144,9 +4131,3 @@ curves Smooth Wygładź
Select and Mask was closed: its document is no longer the active one Zamknięto Zaznacz i maskuj: jego dokument nie jest już aktywny Select and Mask was closed: its document is no longer the active one Zamknięto Zaznacz i maskuj: jego dokument nie jest już aktywny
Select and Mask was closed: another layer was selected Zamknięto Zaznacz i maskuj: wybrano inną warstwę Select and Mask was closed: another layer was selected Zamknięto Zaznacz i maskuj: wybrano inną warstwę
Select Layer Under Cursor Zaznacz warstwę pod kursorem Select Layer Under Cursor Zaznacz warstwę pod kursorem
Import Shapes… Importuj kształty…
Choose a custom shape file (.csh). Wybierz plik kształtów własnych (.csh).
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Liczba kształtów zaimportowanych z {file}: {n}|Liczba kształtów zaimportowanych z {file}: {n}|Liczba kształtów zaimportowanych z {file}: {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Liczba kształtów zaimportowanych z {group}: {n}|Liczba kształtów zaimportowanych z {group}: {n}|Liczba kształtów zaimportowanych z {group}: {n}
Scrollbar Visibility Widoczność pasków przewijania
Hidden Ukryte
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -16
View File
@@ -1893,9 +1893,6 @@
Slice Select Tool Ferramenta Seleção de fatia Slice Select Tool Ferramenta Seleção de fatia
Slice Tool Ferramenta Fatia Slice Tool Ferramenta Fatia
Smart Filters Filtros inteligentes Smart Filters Filtros inteligentes
Move Up Mover para cima
Move Down Mover para baixo
Delete Smart Filter Excluir filtro inteligente
Smudge Tool Ferramenta Borrar Smudge Tool Ferramenta Borrar
Soft Suave Soft Suave
Soften Suavizar Soften Suavizar
@@ -2032,7 +2029,6 @@
Image previews Visualizações de imagem Image previews Visualizações de imagem
Integrations Integrações Integrations Integrações
Interface Interface Interface Interface
Use PhotoCraft-native gradient icons Usar ícones nativos de gradiente do PhotoCraft
Jpeg quality Qualidade JPEG Jpeg quality Qualidade JPEG
Jpg JPG Jpg JPG
Language Idioma Language Idioma
@@ -3017,7 +3013,6 @@ fontWeight Italic Itálico
Out of gamut for printing: click for the nearest printable colour Fora da gama de impressão: clique para a cor imprimível mais próxima Out of gamut for printing: click for the nearest printable colour Fora da gama de impressão: clique para a cor imprimível mais próxima
Not a web colour: click for the nearest web colour Não é uma cor da Web: clique para a cor da Web mais próxima Not a web colour: click for the nearest web colour Não é uma cor da Web: clique para a cor da Web mais próxima
Color to Alpha… Cor para alfa… Color to Alpha… Cor para alfa…
Frequency Separation… Separação de frequências…
Transparency Threshold Limiar de transparência Transparency Threshold Limiar de transparência
Opacity Threshold Limiar de opacidade Opacity Threshold Limiar de opacidade
PhotoCraft — Screen Color Picker PhotoCraft — Seletor de cor da tela PhotoCraft — Screen Color Picker PhotoCraft — Seletor de cor da tela
@@ -4061,8 +4056,6 @@ layerLabel No Color Sem cor
Reset All Locked Settings Redefinir todas as configurações bloqueadas Reset All Locked Settings Redefinir todas as configurações bloqueadas
Copy Texture to Other Tools Copiar textura para outras ferramentas Copy Texture to Other Tools Copiar textura para outras ferramentas
Load Actions… Carregar ações… Load Actions… Carregar ações…
Load Default Actions Carregar ações padrão
Blur Background Desfocar plano de fundo
Save Actions… Salvar ações… Save Actions… Salvar ações…
New Action… Nova ação… New Action… Nova ação…
Start Recording Iniciar gravação Start Recording Iniciar gravação
@@ -4279,6 +4272,7 @@ layerLabel No Color Sem cor
Layer Mask Hides Effects A máscara de camada oculta os efeitos Layer Mask Hides Effects A máscara de camada oculta os efeitos
Vector Mask Hides Effects A máscara vetorial oculta os efeitos Vector Mask Hides Effects A máscara vetorial oculta os efeitos
This Layer: Esta camada: This Layer: Esta camada:
Underlying Layer: Camada subjacente:
Dark Gray Cinza-escuro Dark Gray Cinza-escuro
Medium Gray Cinza médio Medium Gray Cinza médio
Select Custom Color… Selecionar cor personalizada… Select Custom Color… Selecionar cor personalizada…
@@ -4430,10 +4424,6 @@ layerLabel No Color Sem cor
Font size presets Predefinições de tamanho da fonte Font size presets Predefinições de tamanho da fonte
Leading presets Predefinições de entrelinha Leading presets Predefinições de entrelinha
Tracking presets Predefinições de tracking Tracking presets Predefinições de tracking
Selection / Layer Bounds Limites da seleção / camada
No selection or layer Nenhuma seleção ou camada
{label}: empty {label}: vazio
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Color Aware Com reconhecimento de cor Color Aware Com reconhecimento de cor
Decontaminate Colors Descontaminar cores Decontaminate Colors Descontaminar cores
Edge Detection Detecção de borda Edge Detection Detecção de borda
@@ -4450,9 +4440,4 @@ layerLabel No Color Sem cor
Refine Hair needs hair detection, which isn't available yet Refinar cabelo requer detecção de cabelo, ainda não disponível Refine Hair needs hair detection, which isn't available yet Refinar cabelo requer detecção de cabelo, ainda não disponível
Select and Mask was closed: its document is no longer the active one Selecionar e mascarar foi fechado: o documento dele não está mais ativo Select and Mask was closed: its document is no longer the active one Selecionar e mascarar foi fechado: o documento dele não está mais ativo
Select and Mask was closed: another layer was selected Selecionar e mascarar foi fechado: outra camada foi selecionada Select and Mask was closed: another layer was selected Selecionar e mascarar foi fechado: outra camada foi selecionada
Import Shapes… Importar formas…
Choose a custom shape file (.csh). Escolha um arquivo de formas personalizadas (.csh).
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Quantidade de formas importadas de {file}: {n}|Quantidade de formas importadas de {file}: {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Quantidade de formas importadas de {group}: {n}|Quantidade de formas importadas de {group}: {n}
Scrollbar Visibility Visibilidade das barras de rolagem
Drag to move the selected path Arraste para mover o demarcador selecionado Drag to move the selected path Arraste para mover o demarcador selecionado
Can't render this file because it contains an unexpected character in line 6 and column 47.
-23
View File
@@ -420,11 +420,6 @@
New Video Layer from File… Новый видеослой из файла… New Video Layer from File… Новый видеослой из файла…
New Blank Video Layer Новый пустой видеослой New Blank Video Layer Новый пустой видеослой
Smart Filters Смарт-фильтры Smart Filters Смарт-фильтры
# Smart Filter row actions (#1265)
Move Up Переместить вверх
Move Down Переместить вниз
Delete Smart Filter Удалить смарт-фильтр
Delete Smart Filters Удалить смарт-фильтры Delete Smart Filters Удалить смарт-фильтры
Enable Smart Filters Включить смарт-фильтры Enable Smart Filters Включить смарт-фильтры
Disable Smart Filters Отключить смарт-фильтры Disable Smart Filters Отключить смарт-фильтры
@@ -2498,7 +2493,6 @@
Image previews Предпросмотр изображений Image previews Предпросмотр изображений
Integrations Интеграции Integrations Интеграции
Interface Интерфейс Interface Интерфейс
Use PhotoCraft-native gradient icons Использовать собственные значки градиента PhotoCraft
Jpeg quality Качество JPEG Jpeg quality Качество JPEG
Jpg JPEG Jpg JPEG
Large tabs Крупные вкладки Large tabs Крупные вкладки
@@ -2867,7 +2861,6 @@ fontWeight Italic Курсив
Out of gamut for printing: click for the nearest printable colour Вне цветового охвата печати: щёлкните, чтобы выбрать ближайший печатаемый цвет Out of gamut for printing: click for the nearest printable colour Вне цветового охвата печати: щёлкните, чтобы выбрать ближайший печатаемый цвет
Not a web colour: click for the nearest web colour Не веб-цвет: щёлкните, чтобы выбрать ближайший веб-цвет Not a web colour: click for the nearest web colour Не веб-цвет: щёлкните, чтобы выбрать ближайший веб-цвет
Color to Alpha… Цвет в альфа-канал… Color to Alpha… Цвет в альфа-канал…
Frequency Separation… Частотное разложение…
Transparency Threshold Порог прозрачности Transparency Threshold Порог прозрачности
Opacity Threshold Порог непрозрачности Opacity Threshold Порог непрозрачности
PhotoCraft — Screen Color Picker PhotoCraft — Пипетка цвета экрана PhotoCraft — Screen Color Picker PhotoCraft — Пипетка цвета экрана
@@ -3227,8 +3220,6 @@ curves Smooth Сгладить
Reset All Locked Settings Сбросить все заблокированные параметры Reset All Locked Settings Сбросить все заблокированные параметры
Copy Texture to Other Tools Скопировать текстуру в другие инструменты Copy Texture to Other Tools Скопировать текстуру в другие инструменты
Load Actions… Загрузить операции… Load Actions… Загрузить операции…
Load Default Actions Загрузить действия по умолчанию
Blur Background Размыть фон
Save Actions… Сохранить операции… Save Actions… Сохранить операции…
New Action… Новая операция… New Action… Новая операция…
Start Recording Начать запись Start Recording Начать запись
@@ -3259,8 +3250,6 @@ curves Smooth Сгладить
Remember to turn off the printer's own colour management in Print Settings… Не забудьте отключить управление цветом самого принтера в параметрах печати… Remember to turn off the printer's own colour management in Print Settings… Не забудьте отключить управление цветом самого принтера в параметрах печати…
Sent to {printer} Отправлено на {printer} Sent to {printer} Отправлено на {printer}
set in Print Settings… задаётся в параметрах печати… set in Print Settings… задаётся в параметрах печати…
Scrollbar Visibility Видимость полос прокрутки
Hidden Скрыто
ICO is limited to 256 × 256 pixels ICO ограничен размером 256 × 256 пикселей ICO is limited to 256 × 256 pixels ICO ограничен размером 256 × 256 пикселей
Calibration Калибровка Calibration Калибровка
Red Primary Красный основной Red Primary Красный основной
@@ -3318,14 +3307,6 @@ curves Smooth Сгладить
Slab Serif С брусковыми засечками Slab Serif С брусковыми засечками
TrueType font Шрифт TrueType TrueType font Шрифт TrueType
Variable font Вариативный шрифт Variable font Вариативный шрифт
# Custom shape set import
Import Shapes… Импортировать фигуры…
Choose a custom shape file (.csh). Выберите файл произвольных фигур (.csh).
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Количество фигур, импортированных из {file}: {n}|Количество фигур, импортированных из {file}: {n}|Количество фигур, импортированных из {file}: {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Количество фигур, импортированных из {group}: {n}|Количество фигур, импортированных из {group}: {n}|Количество фигур, импортированных из {group}: {n}
# Bevel & Emboss shading controls.
Highlight Mode Режим светлых тонов Highlight Mode Режим светлых тонов
Highlight Color Цвет светлых тонов Highlight Color Цвет светлых тонов
Highlight Opacity Непрозрачность светлых тонов Highlight Opacity Непрозрачность светлых тонов
@@ -4713,10 +4694,6 @@ shape Appearance Оформление
Font size presets Наборы размеров шрифта Font size presets Наборы размеров шрифта
Leading presets Наборы интерлиньяжа Leading presets Наборы интерлиньяжа
Tracking presets Наборы трекинга Tracking presets Наборы трекинга
Selection / Layer Bounds Границы выделения / слоя
No selection or layer Нет выделения или слоя
{label}: empty {label}: пусто
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Color Aware С учетом цвета Color Aware С учетом цвета
Decontaminate Colors Очистить цвета Decontaminate Colors Очистить цвета
Edge Detection Обнаружение краев Edge Detection Обнаружение краев
Can't render this file because it contains an unexpected character in line 6 and column 47.
-16
View File
@@ -1908,9 +1908,6 @@
Slice Select Tool Alatka za izbor isečka Slice Select Tool Alatka za izbor isečka
Slice Tool Alatka za isecanje na isečke Slice Tool Alatka za isecanje na isečke
Smart Filters Pametni filteri Smart Filters Pametni filteri
Move Up Pomeri nagore
Move Down Pomeri nadole
Delete Smart Filter Obriši pametni filter
Smudge Tool Alatka za razmazivanje Smudge Tool Alatka za razmazivanje
Soft Meko Soft Meko
Soften Omekšaj Soften Omekšaj
@@ -3015,7 +3012,6 @@ fontWeight Italic Kurziv
Out of gamut for printing: click for the nearest printable colour Van gamuta za štampu: kliknite za najbližu boju koja može da se odštampa Out of gamut for printing: click for the nearest printable colour Van gamuta za štampu: kliknite za najbližu boju koja može da se odštampa
Not a web colour: click for the nearest web colour Nije veb boja: kliknite za najbližu veb boju Not a web colour: click for the nearest web colour Nije veb boja: kliknite za najbližu veb boju
Color to Alpha… Boja u alfu… Color to Alpha… Boja u alfu…
Frequency Separation… Razdvajanje frekvencija…
Transparency Threshold Prag providnosti Transparency Threshold Prag providnosti
Opacity Threshold Prag neprozirnosti Opacity Threshold Prag neprozirnosti
PhotoCraft — Screen Color Picker PhotoCraft — birač boja sa ekrana PhotoCraft — Screen Color Picker PhotoCraft — birač boja sa ekrana
@@ -4286,8 +4282,6 @@ shape Appearance Izgled
Reset All Locked Settings Vrati sva zaključana podešavanja Reset All Locked Settings Vrati sva zaključana podešavanja
Copy Texture to Other Tools Kopiraj teksturu u druge alatke Copy Texture to Other Tools Kopiraj teksturu u druge alatke
Load Actions… Učitaj radnje… Load Actions… Učitaj radnje…
Load Default Actions Učitaj podrazumevane radnje
Blur Background Zamuti pozadinu
Save Actions… Sačuvaj radnje… Save Actions… Sačuvaj radnje…
New Action… Nova radnja… New Action… Nova radnja…
Start Recording Započni snimanje Start Recording Započni snimanje
@@ -4318,7 +4312,6 @@ shape Appearance Izgled
set in Print Settings… podešava se u podešavanjima štampanja… set in Print Settings… podešava se u podešavanjima štampanja…
Paper Width Širina papira Paper Width Širina papira
Paper Height Visina papira Paper Height Visina papira
Scrollbar Visibility Vidljivost traka za pomeranje
ICO is limited to 256 × 256 pixels ICO je ograničen na 256 × 256 piksela ICO is limited to 256 × 256 pixels ICO je ograničen na 256 × 256 piksela
Mask Options… Opcije maske… Mask Options… Opcije maske…
Layer Mask Display Options… Opcije prikaza maske sloja… Layer Mask Display Options… Opcije prikaza maske sloja…
@@ -4572,10 +4565,6 @@ shape Appearance Izgled
Font size presets Unapred podešene veličine fonta Font size presets Unapred podešene veličine fonta
Leading presets Unapred podešeni proredi Leading presets Unapred podešeni proredi
Tracking presets Unapred podešeni razmaci slova Tracking presets Unapred podešeni razmaci slova
Selection / Layer Bounds Границе избора / слоја
No selection or layer Нема избора или слоја
{label}: empty {label}: празно
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Color Aware Prema boji Color Aware Prema boji
Decontaminate Colors Dekontaminiraj boje Decontaminate Colors Dekontaminiraj boje
Edge Detection Detekcija ivice Edge Detection Detekcija ivice
@@ -4593,9 +4582,4 @@ shape Appearance Izgled
Select and Mask was closed: its document is no longer the active one Izaberi i maskiraj je zatvoren: njegov dokument više nije aktivan Select and Mask was closed: its document is no longer the active one Izaberi i maskiraj je zatvoren: njegov dokument više nije aktivan
Select and Mask was closed: another layer was selected Izaberi i maskiraj je zatvoren: izabran je drugi sloj Select and Mask was closed: another layer was selected Izaberi i maskiraj je zatvoren: izabran je drugi sloj
Select Layer Under Cursor Izaberi sloj ispod kursora Select Layer Under Cursor Izaberi sloj ispod kursora
Import Shapes… Uvezi oblike…
Choose a custom shape file (.csh). Izaberite datoteku prilagođenih oblika (.csh).
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Broj oblika uvezenih iz {file}: {n}|Broj oblika uvezenih iz {file}: {n}|Broj oblika uvezenih iz {file}: {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Broj oblika uvezenih iz {group}: {n}|Broj oblika uvezenih iz {group}: {n}|Broj oblika uvezenih iz {group}: {n}
Drag to move the selected path Prevucite da pomerite izabranu putanju Drag to move the selected path Prevucite da pomerite izabranu putanju
Use PhotoCraft-native gradient icons Koristi sopstvene PhotoCraft ikone gradijenta
Can't render this file because it contains an unexpected character in line 6 and column 47.
-18
View File
@@ -1578,7 +1578,6 @@
Update layer comp (visibility, position and appearance) Katman kompozisyonunu güncelleyin (görünürlük, konum ve görünüm) Update layer comp (visibility, position and appearance) Katman kompozisyonunu güncelleyin (görünürlük, konum ve görünüm)
Use Default Varsayılanı Kullan Use Default Varsayılanı Kullan
Use Legacy Eskiyi Kullan Use Legacy Eskiyi Kullan
Use PhotoCraft-native gradient icons PhotoCraft'ın kendi gradyan simgelerini kullan
Use Measurement Scale Ölçüm Ölçeğini Kullan Use Measurement Scale Ölçüm Ölçeğini Kullan
Variables bind a layer's visibility, text or pixels to a named data slot. Değişkenler bir katmanın görünürlüğünü, metnini veya piksellerini adlandırılmış bir veri yuvasına bağlar. Variables bind a layer's visibility, text or pixels to a named data slot. Değişkenler bir katmanın görünürlüğünü, metnini veya piksellerini adlandırılmış bir veri yuvasına bağlar.
Vertical scale Dikey ölçek Vertical scale Dikey ölçek
@@ -1820,11 +1819,6 @@
Slice Select Tool Dilim Seçme Aracı Slice Select Tool Dilim Seçme Aracı
Slice Tool Dilim Aracı Slice Tool Dilim Aracı
Smart Filters Akıllı Filtreler Smart Filters Akıllı Filtreler
# Smart Filter row actions.
Move Up Yukarı taşı
Move Down Aşağı taşı
Delete Smart Filter Akıllı Filtreyi Sil
Smudge Tool Leke Aracı Smudge Tool Leke Aracı
Soft Yumuşak Soft Yumuşak
Soften Yumuşat Soften Yumuşat
@@ -3944,12 +3938,9 @@ cameraData Lens Lens
Response curve Yanıt eğrisi Response curve Yanıt eğrisi
Show home recent files Ana ekranda son dosyaları göster Show home recent files Ana ekranda son dosyaları göster
Clear Brush Controls Fırça Denetimlerini Temizle Clear Brush Controls Fırça Denetimlerini Temizle
Frequency Separation… Frekans Ayırma…
Reset All Locked Settings Tüm Kilitli Ayarları Sıfırla Reset All Locked Settings Tüm Kilitli Ayarları Sıfırla
Copy Texture to Other Tools Dokuyu Diğer Araçlara Kopyala Copy Texture to Other Tools Dokuyu Diğer Araçlara Kopyala
Load Actions… Eylemleri Yükle… Load Actions… Eylemleri Yükle…
Load Default Actions Varsayılan Eylemleri Yükle
Blur Background Arka Planı Bulanıklaştır
Save Actions… Eylemleri Kaydet… Save Actions… Eylemleri Kaydet…
New Action… Yeni Eylem… New Action… Yeni Eylem…
Start Recording Kaydı Başlat Start Recording Kaydı Başlat
@@ -4225,10 +4216,6 @@ cameraData Lens Lens
Font size presets Yazı tipi boyutu hazır ayarları Font size presets Yazı tipi boyutu hazır ayarları
Leading presets Satır aralığı hazır ayarları Leading presets Satır aralığı hazır ayarları
Tracking presets İzleme hazır ayarları Tracking presets İzleme hazır ayarları
Selection / Layer Bounds Seçim / Katman Sınırları
No selection or layer Seçim veya katman yok
{label}: empty {label}: boş
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Color Aware Renge Duyarlı Color Aware Renge Duyarlı
Decontaminate Colors Renkleri Arındır Decontaminate Colors Renkleri Arındır
Edge Detection Kenar Algılama Edge Detection Kenar Algılama
@@ -4250,9 +4237,4 @@ cameraData Lens Lens
Delete effect Efekti sil Delete effect Efekti sil
Current Layer: Geçerli Katman: Current Layer: Geçerli Katman:
Underlying Layer: Alttaki Katman: Underlying Layer: Alttaki Katman:
Import Shapes… Şekilleri İçe Aktar…
Choose a custom shape file (.csh). Özel şekil dosyası (.csh) seçin.
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} {file} kaynağından içe aktarılan şekil sayısı: {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} {group} kaynağından içe aktarılan şekil sayısı: {n}
Scrollbar Visibility Kaydırma çubuklarının görünürlüğü
Drag to move the selected path Seçili yolu taşımak için sürükleyin Drag to move the selected path Seçili yolu taşımak için sürükleyin
Can't render this file because it contains an unexpected character in line 8 and column 34.
-19
View File
@@ -400,11 +400,6 @@
New Video Layer from File… Новий відеошар із файлу… New Video Layer from File… Новий відеошар із файлу…
New Blank Video Layer Новий порожній відеошар New Blank Video Layer Новий порожній відеошар
Smart Filters Смартфільтри Smart Filters Смартфільтри
# Smart Filter row actions (#1265)
Move Up Перемістити вгору
Move Down Перемістити вниз
Delete Smart Filter Видалити смарт-фільтр
Delete Smart Filters Видалити смартфільтри Delete Smart Filters Видалити смартфільтри
Enable Smart Filters Увімкнути смартфільтри Enable Smart Filters Увімкнути смартфільтри
Disable Smart Filters Вимкнути смартфільтри Disable Smart Filters Вимкнути смартфільтри
@@ -2462,7 +2457,6 @@
Image previews Перегляд зображень Image previews Перегляд зображень
Integrations Інтеграції Integrations Інтеграції
Interface Інтерфейс Interface Інтерфейс
Use PhotoCraft-native gradient icons Використовувати власні піктограми градієнта PhotoCraft
Jpeg quality Якість JPEG Jpeg quality Якість JPEG
Jpg JPG Jpg JPG
Large tabs Великі вкладки Large tabs Великі вкладки
@@ -3685,7 +3679,6 @@ cameraRaw Masking Маскування
101 by 101 Average Середнє 101 × 101 101 by 101 Average Середнє 101 × 101
All Layers No Adjustments Усі шари без коригувань All Layers No Adjustments Усі шари без коригувань
Color to Alpha… Колір у прозорість… Color to Alpha… Колір у прозорість…
Frequency Separation… Частотне розкладання…
Current & Below No Adjustments Поточний і нижчі без коригувань Current & Below No Adjustments Поточний і нижчі без коригувань
Not a web colour: click for the nearest web colour Колір поза вебпалітрою: клацніть, щоб вибрати найближчий колір із неї Not a web colour: click for the nearest web colour Колір поза вебпалітрою: клацніть, щоб вибрати найближчий колір із неї
Now drag the selection onto another area Тепер перетягніть виділення на іншу ділянку Now drag the selection onto another area Тепер перетягніть виділення на іншу ділянку
@@ -3966,8 +3959,6 @@ curves Smooth Згладити
Reset All Locked Settings Скинути всі заблоковані параметри Reset All Locked Settings Скинути всі заблоковані параметри
Copy Texture to Other Tools Скопіювати текстуру в інші інструменти Copy Texture to Other Tools Скопіювати текстуру в інші інструменти
Load Actions… Завантажити операції… Load Actions… Завантажити операції…
Load Default Actions Завантажити типові дії
Blur Background Розмити тло
Save Actions… Зберегти операції… Save Actions… Зберегти операції…
New Action… Нова операція… New Action… Нова операція…
Start Recording Почати запис Start Recording Почати запис
@@ -4215,10 +4206,6 @@ curves Smooth Згладити
Font size presets Набори розмірів шрифту Font size presets Набори розмірів шрифту
Leading presets Набори інтерліньяжу Leading presets Набори інтерліньяжу
Tracking presets Набори трекінгу Tracking presets Набори трекінгу
Selection / Layer Bounds Межі виділення / шару
No selection or layer Немає виділення або шару
{label}: empty {label}: порожньо
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Clear Selection Очистити виділення Clear Selection Очистити виділення
Color Aware З урахуванням кольору Color Aware З урахуванням кольору
Decontaminate Colors Очистити кольори Decontaminate Colors Очистити кольори
@@ -4236,9 +4223,3 @@ curves Smooth Згладити
Refine Hair needs hair detection, which isn't available yet Для уточнення волосся потрібне розпізнавання волосся, яке ще недоступне Refine Hair needs hair detection, which isn't available yet Для уточнення волосся потрібне розпізнавання волосся, яке ще недоступне
Select and Mask was closed: its document is no longer the active one Вікно «Виділити та маскувати» закрито: його документ більше не активний Select and Mask was closed: its document is no longer the active one Вікно «Виділити та маскувати» закрито: його документ більше не активний
Select and Mask was closed: another layer was selected Вікно «Виділити та маскувати» закрито: вибрано інший шар Select and Mask was closed: another layer was selected Вікно «Виділити та маскувати» закрито: вибрано інший шар
Import Shapes… Імпортувати фігури…
Choose a custom shape file (.csh). Виберіть файл довільних фігур (.csh).
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Кількість фігур, імпортованих із {file}: {n}|Кількість фігур, імпортованих із {file}: {n}|Кількість фігур, імпортованих із {file}: {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Кількість фігур, імпортованих із {group}: {n}|Кількість фігур, імпортованих із {group}: {n}|Кількість фігур, імпортованих із {group}: {n}
Scrollbar Visibility Видимість смуг прокручування
Hidden Приховано
Can't render this file because it contains an unexpected character in line 152 and column 53.
-23
View File
@@ -158,9 +158,6 @@
# Art History Brush (#213). # Art History Brush (#213).
# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) # Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic)
# Custom shape set import
Mask Options… Tùy chọn mặt nạ… Mask Options… Tùy chọn mặt nạ…
Layer Mask Display Options… Tùy chọn hiển thị mặt nạ lớp… Layer Mask Display Options… Tùy chọn hiển thị mặt nạ lớp…
Channel Color Màu kênh Channel Color Màu kênh
@@ -1202,7 +1199,6 @@
Justify last centered Căn đều, dòng cuối giữa Justify last centered Căn đều, dòng cuối giữa
Justify last right Căn đều, dòng cuối phải Justify last right Căn đều, dòng cuối phải
Justify all Căn đều tất cả Justify all Căn đều tất cả
Roman Hanging Punctuation Dấu câu treo kiểu La Mã
Indent left margin Thụt lề trái Indent left margin Thụt lề trái
Indent right margin Thụt lề phải Indent right margin Thụt lề phải
Indent first line Thụt dòng đầu Indent first line Thụt dòng đầu
@@ -1586,7 +1582,6 @@ cameraRaw Shadows Vùng tối
Inches Inch Inches Inch
Integrations Tích hợp Integrations Tích hợp
Interface Giao diện Interface Giao diện
Use PhotoCraft-native gradient icons Dùng biểu tượng chuyển sắc riêng của PhotoCraft
Jpeg quality Chất lượng JPEG Jpeg quality Chất lượng JPEG
Jpg JPG Jpg JPG
Language Ngôn ngữ Language Ngôn ngữ
@@ -2118,7 +2113,6 @@ layers Fill: Độ tô:
Color Picker (Layer Style Color) Ống hút màu (Màu kiểu lớp) Color Picker (Layer Style Color) Ống hút màu (Màu kiểu lớp)
Color Picker (Text Color) Ống hút màu (Màu chữ) Color Picker (Text Color) Ống hút màu (Màu chữ)
Color to Alpha… Màu sang alpha… Color to Alpha… Màu sang alpha…
Frequency Separation… Tách tần số…
Corner radius Bán kính góc Corner radius Bán kính góc
Create new shape from the current path Tạo hình mới từ đường dẫn hiện tại Create new shape from the current path Tạo hình mới từ đường dẫn hiện tại
Create new style from the selected layer Tạo kiểu mới từ lớp đã chọn Create new style from the selected layer Tạo kiểu mới từ lớp đã chọn
@@ -2730,11 +2724,6 @@ shape Appearance Diện mạo
SemiBold Nửa đậm SemiBold Nửa đậm
Show Extras Options Tuỳ chọn hiện phụ Show Extras Options Tuỳ chọn hiện phụ
Smart Filters Các bộ lọc thông minh Smart Filters Các bộ lọc thông minh
# Smart Filter row actions.
Move Up Di chuyển lên
Move Down Di chuyển xuống
Delete Smart Filter Xóa bộ lọc thông minh
Soft Mềm Soft Mềm
Source: set Nguồn: bộ Source: set Nguồn: bộ
Square, 12 x 12 Vuông, 12 x 12 Square, 12 x 12 Vuông, 12 x 12
@@ -3726,8 +3715,6 @@ curves Smooth Làm mượt
Reset All Locked Settings Đặt lại tất cả cài đặt đã khóa Reset All Locked Settings Đặt lại tất cả cài đặt đã khóa
Copy Texture to Other Tools Sao chép họa tiết sang các công cụ khác Copy Texture to Other Tools Sao chép họa tiết sang các công cụ khác
Load Actions… Nạp hành động… Load Actions… Nạp hành động…
Load Default Actions Tải thao tác mặc định
Blur Background Làm mờ nền
Save Actions… Lưu hành động… Save Actions… Lưu hành động…
New Action… Hành động mới… New Action… Hành động mới…
Start Recording Bắt đầu ghi Start Recording Bắt đầu ghi
@@ -3806,10 +3793,6 @@ curves Smooth Làm mượt
Could not write {name}: {error} Không thể ghi {name}: {error} Could not write {name}: {error} Không thể ghi {name}: {error}
Kept {name}: written by another application Đã giữ {name}: do ứng dụng khác ghi Kept {name}: written by another application Đã giữ {name}: do ứng dụng khác ghi
Shadow Tint Sắc độ bóng tối Shadow Tint Sắc độ bóng tối
Import Shapes… Nhập hình dạng…
Choose a custom shape file (.csh). Chọn tệp hình dạng tùy chỉnh (.csh).
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Số hình dạng đã nhập từ {file}: {n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Số hình dạng đã nhập từ {group}: {n}
Check for Updates… Kiểm tra bản cập nhật… Check for Updates… Kiểm tra bản cập nhật…
PhotoCraft Updates Bản cập nhật PhotoCraft PhotoCraft Updates Bản cập nhật PhotoCraft
Checking for updates… Đang kiểm tra bản cập nhật… Checking for updates… Đang kiểm tra bản cập nhật…
@@ -4009,10 +3992,6 @@ curves Smooth Làm mượt
Font size presets Cỡ chữ đặt sẵn Font size presets Cỡ chữ đặt sẵn
Leading presets Giãn dòng đặt sẵn Leading presets Giãn dòng đặt sẵn
Tracking presets Khoảng cách chữ đặt sẵn Tracking presets Khoảng cách chữ đặt sẵn
Selection / Layer Bounds Giới hạn vùng chọn / lớp
No selection or layer Không có vùng chọn hoặc lớp
{label}: empty {label}: trống
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Clear Selection Xóa vùng chọn Clear Selection Xóa vùng chọn
Color Aware Nhận biết màu Color Aware Nhận biết màu
Decontaminate Colors Khử nhiễm màu Decontaminate Colors Khử nhiễm màu
@@ -4031,5 +4010,3 @@ curves Smooth Làm mượt
Select and Mask was closed: its document is no longer the active one Đã đóng Chọn và Mặt nạ: tài liệu của nó không còn là tài liệu đang hoạt động Select and Mask was closed: its document is no longer the active one Đã đóng Chọn và Mặt nạ: tài liệu của nó không còn là tài liệu đang hoạt động
Select and Mask was closed: another layer was selected Đã đóng Chọn và Mặt nạ: một lớp khác đã được chọn Select and Mask was closed: another layer was selected Đã đóng Chọn và Mặt nạ: một lớp khác đã được chọn
Select Layer Under Cursor Chọn lớp dưới con trỏ Select Layer Under Cursor Chọn lớp dưới con trỏ
Scrollbar Visibility Hiển thị thanh cuộn
Hidden Ẩn
Can't render this file because it contains an unexpected character in line 6 and column 47.
-18
View File
@@ -1353,7 +1353,6 @@
Italic 斜体 Italic 斜体
Join us on Discord 加入我们的 Discord 社区 Join us on Discord 加入我们的 Discord 社区
Justify all 全部两端对齐 Justify all 全部两端对齐
Roman Hanging Punctuation 罗马字悬挂标点
Justify last centered 两端对齐,末行居中 Justify last centered 两端对齐,末行居中
Justify last left 两端对齐,末行左对齐 Justify last left 两端对齐,末行左对齐
Justify last right 两端对齐,末行右对齐 Justify last right 两端对齐,末行右对齐
@@ -1969,9 +1968,6 @@
Slice Select Tool 切片选择工具 Slice Select Tool 切片选择工具
Slice Tool 切片工具 Slice Tool 切片工具
Smart Filters 智能滤镜 Smart Filters 智能滤镜
Move Up 上移
Move Down 下移
Delete Smart Filter 删除智能滤镜
Smudge Tool 涂抹工具 Smudge Tool 涂抹工具
Soft 软 Soft 软
Soften 柔化 Soften 柔化
@@ -2112,7 +2108,6 @@
Image previews 图像预览 Image previews 图像预览
Integrations 集成 Integrations 集成
Interface 界面 Interface 界面
Use PhotoCraft-native gradient icons 使用 PhotoCraft 原生渐变图标
Jpeg quality JPEG 品质 Jpeg quality JPEG 品质
Jpg JPG Jpg JPG
Language 语言 Language 语言
@@ -2631,7 +2626,6 @@ fontWeight Italic 斜体
Out of gamut for printing: click for the nearest printable colour 超出打印色域:单击使用最接近的可打印颜色 Out of gamut for printing: click for the nearest printable colour 超出打印色域:单击使用最接近的可打印颜色
Not a web colour: click for the nearest web colour 不是 Web 颜色:单击使用最接近的 Web 颜色 Not a web colour: click for the nearest web colour 不是 Web 颜色:单击使用最接近的 Web 颜色
Color to Alpha… 颜色转为 Alpha… Color to Alpha… 颜色转为 Alpha…
Frequency Separation… 频率分离…
Transparency Threshold 透明度阈值 Transparency Threshold 透明度阈值
Opacity Threshold 不透明度阈值 Opacity Threshold 不透明度阈值
PhotoCraft — Screen Color Picker PhotoCraft — 屏幕取色器 PhotoCraft — Screen Color Picker PhotoCraft — 屏幕取色器
@@ -2919,8 +2913,6 @@ curves Smooth 平滑
Reset All Locked Settings 复位所有锁定的设置 Reset All Locked Settings 复位所有锁定的设置
Copy Texture to Other Tools 将纹理复制到其他工具 Copy Texture to Other Tools 将纹理复制到其他工具
Load Actions… 载入动作… Load Actions… 载入动作…
Load Default Actions 载入默认动作
Blur Background 模糊背景
Save Actions… 存储动作… Save Actions… 存储动作…
New Action… 新建动作… New Action… 新建动作…
Start Recording 开始录制 Start Recording 开始录制
@@ -3185,10 +3177,6 @@ curves Smooth 平滑
Font size presets 字号预设 Font size presets 字号预设
Leading presets 行距预设 Leading presets 行距预设
Tracking presets 字距调整预设 Tracking presets 字距调整预设
Selection / Layer Bounds 选区 / 图层边界
No selection or layer 无选区或图层
{label}: empty {label}:空
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Clear Selection 清除选区 Clear Selection 清除选区
Color Aware 颜色识别 Color Aware 颜色识别
Decontaminate Colors 净化颜色 Decontaminate Colors 净化颜色
@@ -3206,10 +3194,4 @@ curves Smooth 平滑
Refine Hair needs hair detection, which isn't available yet 调整细线需要毛发检测,目前尚不可用 Refine Hair needs hair detection, which isn't available yet 调整细线需要毛发检测,目前尚不可用
Select and Mask was closed: its document is no longer the active one 已关闭“选择与蒙版”:其文档不再是当前文档 Select and Mask was closed: its document is no longer the active one 已关闭“选择与蒙版”:其文档不再是当前文档
Select and Mask was closed: another layer was selected 已关闭“选择与蒙版”:已选择另一个图层 Select and Mask was closed: another layer was selected 已关闭“选择与蒙版”:已选择另一个图层
Import Shapes… 导入形状…
Choose a custom shape file (.csh). 请选择自定义形状文件 (.csh)。
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} 从 {file} 导入的形状数量:{n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} 从 {group} 导入的形状数量:{n}
Scrollbar Visibility 滚动条可见性
Hidden 隐藏
Drag to move the selected path 拖动以移动选中的路径 Drag to move the selected path 拖动以移动选中的路径
Can't render this file because it contains an unexpected character in line 63 and column 53.
-18
View File
@@ -1334,7 +1334,6 @@
Italic 斜體 Italic 斜體
Join us on Discord 加入我們的 Discord Join us on Discord 加入我們的 Discord
Justify all 全部齊行 Justify all 全部齊行
Roman Hanging Punctuation 羅馬字懸掛標點
Justify last centered 最後一行置中齊行 Justify last centered 最後一行置中齊行
Justify last left 最後一行靠左齊行 Justify last left 最後一行靠左齊行
Justify last right 最後一行靠右齊行 Justify last right 最後一行靠右齊行
@@ -1939,9 +1938,6 @@
Slice Select Tool 切片選取工具 Slice Select Tool 切片選取工具
Slice Tool 切片工具 Slice Tool 切片工具
Smart Filters 智慧型濾鏡 Smart Filters 智慧型濾鏡
Move Up 上移
Move Down 下移
Delete Smart Filter 刪除智慧型濾鏡
Smudge Tool 指尖工具 Smudge Tool 指尖工具
Soft 柔邊 Soft 柔邊
Soften 柔化 Soften 柔化
@@ -2080,7 +2076,6 @@
Image previews 影像預視 Image previews 影像預視
Integrations 整合 Integrations 整合
Interface 介面 Interface 介面
Use PhotoCraft-native gradient icons 使用 PhotoCraft 原生漸層圖示
Jpeg quality JPEG 品質 Jpeg quality JPEG 品質
Jpg JPG Jpg JPG
Language 語言 Language 語言
@@ -2578,7 +2573,6 @@ fontWeight Italic 斜體
Out of gamut for printing: click for the nearest printable colour 超出列印色域:按一下以使用最接近的可列印顏色 Out of gamut for printing: click for the nearest printable colour 超出列印色域:按一下以使用最接近的可列印顏色
Not a web colour: click for the nearest web colour 不是網頁顏色:按一下以使用最接近的網頁顏色 Not a web colour: click for the nearest web colour 不是網頁顏色:按一下以使用最接近的網頁顏色
Color to Alpha… 顏色轉為 Alpha… Color to Alpha… 顏色轉為 Alpha…
Frequency Separation… 頻率分離…
Transparency Threshold 透明度臨界值 Transparency Threshold 透明度臨界值
Opacity Threshold 不透明度臨界值 Opacity Threshold 不透明度臨界值
PhotoCraft — Screen Color Picker PhotoCraft — 螢幕取色器 PhotoCraft — Screen Color Picker PhotoCraft — 螢幕取色器
@@ -2880,8 +2874,6 @@ curves Smooth 平滑
Reset All Locked Settings 重設所有鎖定的設定 Reset All Locked Settings 重設所有鎖定的設定
Copy Texture to Other Tools 複製紋理至其他工具 Copy Texture to Other Tools 複製紋理至其他工具
Load Actions… 載入動作… Load Actions… 載入動作…
Load Default Actions 載入預設動作
Blur Background 模糊背景
Save Actions… 儲存動作… Save Actions… 儲存動作…
New Action… 新增動作… New Action… 新增動作…
Start Recording 開始錄製 Start Recording 開始錄製
@@ -3141,10 +3133,6 @@ curves Smooth 平滑
Font size presets 字體大小預設 Font size presets 字體大小預設
Leading presets 行距預設 Leading presets 行距預設
Tracking presets 字距調整預設 Tracking presets 字距調整預設
Selection / Layer Bounds 選取範圍 / 圖層邊界
No selection or layer 沒有選取範圍或圖層
{label}: empty {label}:空
{label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h}
Clear Selection 清除選取範圍 Clear Selection 清除選取範圍
Color Aware 顏色感知 Color Aware 顏色感知
Decontaminate Colors 淨化顏色 Decontaminate Colors 淨化顏色
@@ -3162,10 +3150,4 @@ curves Smooth 平滑
Refine Hair needs hair detection, which isn't available yet 調整毛髮需要毛髮偵測,目前尚無法使用 Refine Hair needs hair detection, which isn't available yet 調整毛髮需要毛髮偵測,目前尚無法使用
Select and Mask was closed: its document is no longer the active one 已關閉「選取並遮住」:其文件已不是使用中的文件 Select and Mask was closed: its document is no longer the active one 已關閉「選取並遮住」:其文件已不是使用中的文件
Select and Mask was closed: another layer was selected 已關閉「選取並遮住」:已選取另一個圖層 Select and Mask was closed: another layer was selected 已關閉「選取並遮住」:已選取另一個圖層
Import Shapes… 匯入形狀…
Choose a custom shape file (.csh). 請選擇自訂形狀檔案 (.csh)。
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} 從 {file} 匯入的形狀數量:{n}
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} 從 {group} 匯入的形狀數量:{n}
Scrollbar Visibility 捲軸可見性
Hidden 隱藏
Drag to move the selected path 拖曳以移動選取的路徑 Drag to move the selected path 拖曳以移動選取的路徑
Can't render this file because it contains an unexpected character in line 6 and column 47.
-10
View File
@@ -510,16 +510,6 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) {
return; return;
} }
match e.outcome { match e.outcome {
JobOutcome::Done(v) if e.command == "shape.presets.importCsh" => {
let n = v.get("count").and_then(Value::as_u64).unwrap_or(0);
let group = v.get("group").and_then(Value::as_str).unwrap_or_default();
app.ui.status = crate::i18n::fmt(
&crate::i18n::trn(crate::i18n::current(), n, "Imported {n} shape from {group}", "Imported {n} shapes from {group}"),
&[("group", group)],
);
app.ui.status_error = false;
app.ui.presets_ui.shapes = true;
}
JobOutcome::Done(v) if e.command == "brush.presets.importAbr" => { JobOutcome::Done(v) if e.command == "brush.presets.importAbr" => {
let n = v.get("count").and_then(Value::as_u64).unwrap_or(0); let n = v.get("count").and_then(Value::as_u64).unwrap_or(0);
let group = v.get("group").and_then(Value::as_str).unwrap_or_default(); let group = v.get("group").and_then(Value::as_str).unwrap_or_default();

Some files were not shown because too many files have changed in this diff Show More