mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
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:
@@ -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();
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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, ¶ms).is_err(), "{path}");
|
|
||||||
assert!(authorize_desktop_engine_command(id, ¶ms).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, ¶ms).is_ok());
|
|
||||||
assert!(authorize_desktop_engine_command(id, ¶ms).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}");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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]> {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 = ¶ms;
|
let p = ¶ms;
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(¶ms, 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)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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<_>>>()?;
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,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]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 ") {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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("سلام عليكم"));
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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(());
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
Reference in New Issue
Block a user