Integration: merge main 528491f9a, resolve conflicts

- crates/engine/src/edit_cmds.rs: copy_mask keeps #3297's filter-mask target and takes main's
  clipboard `source` (#8945).
- crates/cms/src/lutfile.rs: #9183's shaper doc comment next to main's UTF-16 decode_text (#9129).
- contributors/people.toml, docs: unions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Mann
2026-10-25 16:26:06 +01:00
81 changed files with 2957 additions and 303 deletions
+3 -2
View File
@@ -9,8 +9,8 @@ use photocraft_engine::{
};
pub type Batch = BTreeMap<String, Option<Arc<[u8]>>>;
/// Three sampled images per preset (tip, dual tip, texture), plus group/index/action records.
pub const MAX_RECORDS: usize = MAX_GROUPS + MAX_PRESETS_PER_GROUP * 3 + 2;
/// Three sampled images per preset (tip, dual tip, texture), plus group/index/action/pattern records.
pub const MAX_RECORDS: usize = MAX_GROUPS + MAX_PRESETS_PER_GROUP * 3 + 3;
#[derive(Default)]
struct State {
@@ -45,6 +45,7 @@ pub fn record_limit(name: &str) -> Result<u64, String> {
match name {
INDEX_FILE => Ok(MAX_INDEX_BYTES),
ACTIONS_FILE => Ok(MAX_ACTIONS_BYTES),
PATTERNS_FILE => Ok(MAX_PATTERNS_BYTES),
_ if name.starts_with("tips/") && leaf.ends_with(".pctip") => Ok(MAX_TIP_BYTES),
_ if !name.contains('/') && name.ends_with(".pcbrushes") => Ok(MAX_GROUP_BYTES),
_ => Err("unknown preset record type".into()),
+1
View File
@@ -105,6 +105,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
// Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327).
("DDS", &["dds"]),
("Cineon", &["cin"]),
("Amiga IFF", &["iff"]),
];
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
+11
View File
@@ -267,3 +267,14 @@ real_name = "Kushagra Sharma"
[people.WebWork-rrs]
display_name = "Ratul Paul"
[people.softwaregames1]
sync_github_name = true
[people.VoiceLessQ]
display_name = "VoiceLessQ"
sync_github_name = false
[people.erenova]
display_name = "Erenova"
sync_github_name = false
+45 -4
View File
@@ -948,23 +948,25 @@ fn dehaze(px: &mut [[f32; 4]], w: usize, h: usize, amount: f32) {
*s = s.map(|v| v / c.max(1.0));
}
let r = (sw.max(sh) / 60).max(2);
let min_filter = |v: &[f32]| -> Vec<f32> {
// The minimum (or, with `f32::max`, maximum) over each (2r + 1)² window.
let rank_filter = |v: &[f32], start: f32, pick: fn(f32, f32) -> f32| -> Vec<f32> {
let mut a = vec![0.0f32; sw * sh];
for y in 0..sh {
for x in 0..sw {
let (x0, x1) = (x.saturating_sub(r), (x + r).min(sw - 1));
a[y * sw + x] = (x0..=x1).map(|xx| v[y * sw + xx]).fold(f32::MAX, f32::min);
a[y * sw + x] = (x0..=x1).map(|xx| v[y * sw + xx]).fold(start, pick);
}
}
let mut b = vec![0.0f32; sw * sh];
for y in 0..sh {
for x in 0..sw {
let (y0, y1) = (y.saturating_sub(r), (y + r).min(sh - 1));
b[y * sw + x] = (y0..=y1).map(|yy| a[yy * sw + x]).fold(f32::MAX, f32::min);
b[y * sw + x] = (y0..=y1).map(|yy| a[yy * sw + x]).fold(start, pick);
}
}
b
};
let min_filter = |v: &[f32]| rank_filter(v, f32::MAX, f32::min);
let dark = min_filter(&small.iter().map(|c| c[0].min(c[1]).min(c[2])).collect::<Vec<_>>());
// Atmospheric light: mean colour of the brightest 0.1 % of the dark channel.
let mut idx: Vec<usize> = (0..sw * sh).collect();
@@ -978,7 +980,15 @@ fn dehaze(px: &mut [[f32; 4]], w: usize, h: usize, amount: f32) {
}
let air = air.map(|v| v.max(0.2));
let norm_dark = min_filter(&small.iter().map(|c| (c[0] / air[0]).min(c[1] / air[1]).min(c[2] / air[2])).collect::<Vec<_>>());
let t_raw: Vec<f32> = norm_dark.iter().map(|d| 1.0 - 0.95 * d).collect();
// Opened (a maximum filter of the same radius after the minimum): the minimum grows every
// dark object by `r`, so sky beside a dark building took its dark channel, counted as
// haze-free and kept a bright halo (#9273). Opening shrinks dark regions back to their
// outline and is still a lower bound of the dark channel over the window. It raises the dark
// channel on average, so the 0.95 weight is scaled to keep the slider's overall strength.
let opened = rank_filter(&norm_dark, f32::MIN, f32::max);
let mean = |v: &[f32]| v.iter().sum::<f32>() / v.len().max(1) as f32;
let weight = 0.95 * mean(&norm_dark) / mean(&opened).max(1e-6);
let t_raw: Vec<f32> = opened.iter().map(|d| 1.0 - weight * d).collect();
let guide: Vec<f32> = small.iter().map(|c| luma(*c)).collect();
let t = guided(&guide, &t_raw, sw, sh, r * 2, 1e-3);
let ks = k as f32;
@@ -1098,6 +1108,37 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) {
mod tests {
use super::*;
/// #9273: Dehaze clears the haze evenly over a sky, right up to a dark building, instead of
/// leaving a bright halo of uncorrected haze around it. Synthetic scene: a clear sky and a
/// dark tower, hazed with `I = J·t + A·(1 − t)`.
#[test]
fn dehaze_leaves_no_halo_beside_dark_objects() {
let (w, h) = (600usize, 400usize);
let tower = |x: usize, y: usize| (250..350).contains(&x) && y >= 120;
let (t, air) = (0.55f32, [0.9f32, 0.9, 0.9]);
let mut px: Vec<[f32; 4]> = (0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
let j = if tower(x, y) { [0.06, 0.06, 0.07] } else { [0.35 + 0.1 * y as f32 / h as f32, 0.5, 0.8] };
let c: [f32; 3] = std::array::from_fn(|k| j[k] * t + air[k] * (1.0 - t));
[c[0], c[1], c[2], 1.0]
})
.collect();
dehaze(&mut px, w, h, 1.0);
let sky = |xs: std::ops::Range<usize>| {
let v: Vec<f32> =
(150..390).flat_map(|y| xs.clone().map(move |x| (x, y))).map(|(x, y)| luma([px[y * w + x][0], px[y * w + x][1], px[y * w + x][2]])).collect();
v.iter().sum::<f32>() / v.len() as f32
};
// Sky 1–8 px from the tower against open sky: the haze left beside it, in 8-bit levels.
let halo = (sky(242..250) - sky(20..80)) * 255.0;
// 6.4 levels before the dark channel was opened, under 1 after.
assert!(halo < 3.0, "a {halo:.1}-level halo beside the tower");
// The open sky is still cleared: darker than the hazy input.
let hazy = luma(std::array::from_fn(|k| [0.4, 0.5, 0.8][k] * t + air[k] * (1.0 - t)));
assert!(sky(20..80) < hazy - 0.02, "{} vs hazy {hazy}", sky(20..80));
}
#[test]
fn new_curves_are_validated_but_saved_curves_still_load_and_render() {
for field in ["pointCurve", "pointCurveRed", "pointCurveGreen", "pointCurveBlue"] {
+15
View File
@@ -806,6 +806,21 @@ mod tests {
assert!(authorize_desktop_engine_step("file.saveACopy", &serde_json::json!({})).is_err());
}
// Regression test for #3763: `edit.installedProfiles` scans the OS and user ICC folders but
// takes no path parameter, so automation let it through until it was listed as ambient.
#[test]
fn installed_profiles_listing_is_refused_for_automation() {
for params in [serde_json::json!({}), serde_json::json!({"class": "output", "colorSpace": "cmyk"})] {
assert!(authorize_engine_command("edit.installedProfiles", &params).is_err(), "authorize_engine_command allowed it with {params}");
assert!(authorize_desktop_engine_command("edit.installedProfiles", &params).is_err(), "authorize_desktop_engine_command allowed it with {params}");
assert!(authorize_engine_step("edit.installedProfiles", &params).is_err(), "authorize_engine_step allowed it with {params}");
}
// The sandboxed session refuses it before the engine scans any folder.
let mut headless = crate::Headless::new();
let refused = headless.command_run("edit.installedProfiles", serde_json::json!({})).unwrap_err();
assert!(refused.to_string().contains("ambient filesystem paths"), "{refused}");
}
#[test]
fn apply_data_set_is_judged_by_the_values_it_applies() {
let mut headless = crate::Headless::new();
@@ -1,94 +1,15 @@
//! Stored smart-filter parameters must not grant automation ambient filesystem access.
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
use std::path::Path;
use std::sync::Arc;
use photocraft_automation::{AuthorizedWorkspace, Headless, workspace::authorize_desktop_engine_step};
use photocraft_doc::{Document, LayerContent, LayerId};
#[path = "../support/smart_filter_fixture.rs"]
mod fixture;
use fixture::{Fixture, assert_denied, cube, path_params};
use photocraft_automation::{Headless, workspace::authorize_desktop_engine_step};
use photocraft_doc::LayerContent;
use photocraft_engine::{Session, actions_cmds::Action, jobs::Started, smart_cmds};
use serde_json::{Value, json};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&base).unwrap();
std::fs::create_dir(base.join("workspace")).unwrap();
std::fs::create_dir(base.join("outside")).unwrap();
let f = Self(base);
std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap();
std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap();
std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap();
std::fs::write(f.outside("black.cube"), cube(0)).unwrap();
std::fs::write(f.outside("white.cube"), cube(1)).unwrap();
f
}
fn root(&self) -> PathBuf {
self.0.join("workspace")
}
fn outside(&self, name: &str) -> PathBuf {
self.0.join("outside").join(name)
}
fn headless(&self) -> Headless {
Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap())
}
fn setup(&self, lookup: bool) -> (Headless, LayerId) {
let mut h = self.headless();
h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap();
h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap();
let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap());
let (id, params) = if lookup {
("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"}))
} else {
("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0}))
};
h.session.execute(id, params).unwrap();
(h, layer)
}
fn saved_filter(&self, lookup: bool) -> (Document, LayerId) {
let (h, layer) = self.setup(lookup);
let mut doc = (*h.session.active().unwrap().doc).clone();
let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") };
sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" }));
let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();
std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap();
(doc, layer)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn png(value: impl Fn(u32) -> u8) -> Vec<u8> {
let mut pixels = Vec::new();
for _ in 0..32 {
for x in 0..32 {
pixels.extend([value(x); 3]);
}
}
let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap();
photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()
}
fn cube(value: u8) -> String {
format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8))
}
fn path_params(lookup: bool, path: &Path) -> Value {
if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) }
}
fn assert_denied<T: std::fmt::Debug>(result: Result<T, impl std::fmt::Display>) {
match result {
Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"),
Ok(value) => panic!("ambient file access was accepted: {value:?}"),
}
}
use serde_json::json;
#[test]
fn parameter_edits_reject_outside_images_and_luts_without_changing_the_document() {
@@ -243,21 +164,6 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_
}
}
#[test]
fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() {
// The denial path must fail the render before any stage is cached process-wide.
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
let mut s = Session::new();
s.add_document(doc, None);
s.authorize = Some(authorize_desktop_engine_step);
let bytes = smart_cmds::stack_cache_bytes();
assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage");
}
}
/// Choosing another Displace map through `setParams` embeds it (#7423), so the new map is read
/// before the edit: a path outside the workspace is refused before that read, and the filter
/// keeps the map it had.
@@ -0,0 +1,25 @@
//! One integration-test process keeps the global smart filter stage cache isolated from the
//! parallel tests in `tests/it`, so a denied render can be shown to have cached nothing.
use photocraft_automation::workspace::authorize_desktop_engine_step;
use photocraft_engine::{Session, smart_cmds};
use serde_json::json;
#[path = "support/smart_filter_fixture.rs"]
mod fixture;
use fixture::{Fixture, assert_denied};
#[test]
fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() {
// The denial path must fail the render before any stage is cached process-wide.
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
let mut s = Session::new();
s.add_document(doc, None);
s.authorize = Some(authorize_desktop_engine_step);
let bytes = smart_cmds::stack_cache_bytes();
assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage");
}
}
@@ -0,0 +1,89 @@
//! The smart filter authorization tests' fixture: a workspace with a striped image to place and
//! maps and LUTs outside it (`#[path]`-included by `it/smart_filter_authorization.rs` and
//! `smart_filter_stack_cache.rs`).
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use photocraft_automation::{AuthorizedWorkspace, Headless};
use photocraft_doc::{Document, LayerContent, LayerId};
use serde_json::{Value, json};
pub struct Fixture(PathBuf);
impl Fixture {
pub fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&base).unwrap();
std::fs::create_dir(base.join("workspace")).unwrap();
std::fs::create_dir(base.join("outside")).unwrap();
let f = Self(base);
std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap();
std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap();
std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap();
std::fs::write(f.outside("black.cube"), cube(0)).unwrap();
std::fs::write(f.outside("white.cube"), cube(1)).unwrap();
f
}
pub fn root(&self) -> PathBuf {
self.0.join("workspace")
}
pub fn outside(&self, name: &str) -> PathBuf {
self.0.join("outside").join(name)
}
pub fn headless(&self) -> Headless {
Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap())
}
pub fn setup(&self, lookup: bool) -> (Headless, LayerId) {
let mut h = self.headless();
h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap();
h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap();
let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap());
let (id, params) = if lookup {
("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"}))
} else {
("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0}))
};
h.session.execute(id, params).unwrap();
(h, layer)
}
pub fn saved_filter(&self, lookup: bool) -> (Document, LayerId) {
let (h, layer) = self.setup(lookup);
let mut doc = (*h.session.active().unwrap().doc).clone();
let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") };
sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" }));
let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();
std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap();
(doc, layer)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
pub fn png(value: impl Fn(u32) -> u8) -> Vec<u8> {
let mut pixels = Vec::new();
for _ in 0..32 {
for x in 0..32 {
pixels.extend([value(x); 3]);
}
}
let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap();
photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()
}
pub fn cube(value: u8) -> String {
format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8))
}
pub fn path_params(lookup: bool, path: &Path) -> Value {
if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) }
}
pub fn assert_denied<T: std::fmt::Debug>(result: Result<T, impl std::fmt::Display>) {
match result {
Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"),
Ok(value) => panic!("ambient file access was accepted: {value:?}"),
}
}
+72 -3
View File
@@ -90,7 +90,7 @@ impl LutFile {
/// Parses a LUT, picking the format from `file_name`'s extension (falls back to sniffing).
pub fn parse(file_name: &str, bytes: &[u8]) -> Result<LutFile, LutError> {
let lower = file_name.to_ascii_lowercase();
let text = String::from_utf8_lossy(bytes);
let text = decode_text(bytes);
if lower.ends_with(".3dl") {
parse_3dl(&text)
} else if lower.ends_with(".look") || text.trim_start().starts_with('<') {
@@ -100,6 +100,19 @@ pub fn parse(file_name: &str, bytes: &[u8]) -> Result<LutFile, LutError> {
}
}
/// UTF-16 with a byte-order mark (Photoshop loads such `.look` files, #9118), else UTF-8.
fn decode_text(bytes: &[u8]) -> std::borrow::Cow<'_, str> {
let utf16 = |rest: &[u8], from: fn([u8; 2]) -> u16| {
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|&c| from(c)).collect();
String::from_utf16_lossy(&units).into()
};
match bytes {
[0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes),
[0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes),
_ => String::from_utf8_lossy(bytes),
}
}
/// Adobe / Resolve `.cube`. 1D LUTs (`LUT_1D_SIZE`) are expanded to a 33³ table. A Resolve file
/// with both sizes holds a 1D shaper and then a 3D table; the shaper runs first and its output
/// feeds the table, so the two are baked into one table ([`compose_shaper`]).
@@ -227,12 +240,15 @@ fn compose_shaper(title: String, size1: usize, size3: usize, rows: &[f32], range
}
/// Lustre `.3dl`: an optional shaper line of input values, then integer RGB rows with **blue**
/// varying fastest; the output bit depth is inferred from the largest value.
/// varying fastest; the output bit depth is inferred from the largest value, and is at least 12
/// bits in a file without a header.
pub fn parse_3dl(text: &str) -> Result<LutFile, LutError> {
let mut rows: Vec<[f64; 3]> = Vec::new();
let mut header = false;
for line in text.lines() {
let line = line.split('#').next().unwrap_or("").trim();
if line.is_empty() || line.starts_with(|c: char| c.is_ascii_alphabetic()) {
header |= !line.is_empty();
continue;
}
let v: Vec<f64> = line.split_whitespace().filter_map(|s| s.parse().ok()).collect();
@@ -246,7 +262,9 @@ pub fn parse_3dl(text: &str) -> Result<LutFile, LutError> {
return err(format!("{} rows is not a cube", rows.len()));
}
let max = rows.iter().flatten().fold(0.0f64, |a, &b| a.max(b));
let scale = if max <= 1.0 { 1.0 } else { [1023.0, 4095.0, 16383.0, 65535.0].into_iter().find(|&s| max <= s).unwrap_or(max) };
// Photoshop 27.11 reads a headerless table peaking at 255 or 1023 on the 12-bit scale (#9121).
let depths: &[f64] = if header { &[1023.0, 4095.0, 16383.0, 65535.0] } else { &[4095.0, 16383.0, 65535.0] };
let scale = if max <= 1.0 { 1.0 } else { depths.iter().copied().find(|&s| max <= s).unwrap_or(max) };
let mut data = vec![0.0f32; n * n * n * 3];
for (i, row) in rows.iter().enumerate() {
let (r, g, b) = (i / (n * n), (i / n) % n, i % n);
@@ -261,6 +279,15 @@ pub fn parse_3dl(text: &str) -> Result<LutFile, LutError> {
/// SpeedGrade `.look`: `<size>` and a hex `<data>` string of little-endian float32 RGB triplets
/// (red fastest).
pub fn parse_look(text: &str) -> Result<LutFile, LutError> {
// Comments may hold tag-like text, so drop them before looking for tags (#9109).
let mut clean = String::with_capacity(text.len());
let mut rest = text;
while let Some(i) = rest.find("<!--") {
clean.push_str(&rest[..i]);
rest = rest[i + 4..].find("-->").map_or("", |e| &rest[i + 4 + e + 3..]);
}
clean.push_str(rest);
let text = clean.as_str();
let tag = |name: &str| -> Option<String> {
let open = format!("<{name}>");
let start = text.find(&open)? + open.len();
@@ -536,6 +563,25 @@ DOMAIN_MAX 0.5 1 1
assert!(parse_cube(&text).is_err());
}
#[test]
fn three_dl_without_a_header_reads_low_nodes_as_12_bit() {
let table = |peak: u32| -> String {
let mut text = String::from("0 1023\n");
for i in 0..8 {
let on = |bit: u32| if i & bit != 0 { peak } else { 0 };
text.push_str(&format!("{} {} {}\n", on(4), on(2), on(1)));
}
text
};
for peak in [255, 1023, 4095] {
let white = parse_3dl(&table(peak)).unwrap().data[21..24].to_vec();
assert_eq!(white, [peak as f32 / 4095.0; 3], "peak {peak}");
}
// A header keeps the inferred 10-bit depth.
let white = parse_3dl(&format!("3DMESH\nMesh 1 10\n{}", table(1023))).unwrap().data[21..24].to_vec();
assert_eq!(white, [1.0; 3]);
}
#[test]
fn three_dl_blue_fastest_and_bit_depth() {
let mut text = String::from("0 1023\n");
@@ -559,6 +605,29 @@ DOMAIN_MAX 0.5 1 1
assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data);
}
#[test]
fn look_reads_utf16_with_either_byte_order() {
let id = LutFile::identity(2);
let hex: String = id.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect();
let xml = format!("<?xml version=\"1.0\" encoding=\"UTF-16\"?><look><LUT><size>\"2\"</size><data>\"{hex}\"</data></LUT></look>");
let le: Vec<u8> = [0xFF, 0xFE].into_iter().chain(xml.encode_utf16().flat_map(u16::to_le_bytes)).collect();
let be: Vec<u8> = [0xFE, 0xFF].into_iter().chain(xml.encode_utf16().flat_map(u16::to_be_bytes)).collect();
assert_eq!(parse("a.look", &le).unwrap().data, id.data);
assert_eq!(parse("a.look", &be).unwrap().data, id.data);
}
#[test]
fn look_ignores_tags_inside_xml_comments() {
let hex = |l: &LutFile| -> String { l.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect() };
let inverse = LutFile::from_fn("", 2, |[r, g, b]| [1.0 - r, 1.0 - g, 1.0 - b]);
let xml = format!(
"<look><!-- <size>\"3\"</size><data>\"{}\"</data> --><LUT><size>\"2\"</size><data>\"{}\"</data></LUT><!-- unterminated <data>00",
hex(&LutFile::identity(2)),
hex(&inverse)
);
assert_eq!(parse("commented.look", xml.as_bytes()).unwrap().data, inverse.data);
}
#[test]
fn look_rgb_and_rgba_allow_whitespace_in_hex_data() {
let id = LutFile::identity(2);
+10 -5
View File
@@ -49,7 +49,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in |
| Cineon | yes | yes | U16 (10-bit code values read and written exactly through U16) | Gray, RGB | no | no | no | no | no | no | no | built in |
| DICOM | yes | **no** | U8, U16 | Gray, RGB | no | no | no | no | no | no | n/a | built in |
| Amiga IFF (ILBM/PBM) | yes | **no** | U8 | RGB, RGBA | yes | no | no | no | no | no | n/a | built in |
| Amiga IFF (ILBM/PBM) | yes | yes (24-bit ILBM) | U8 | RGB, RGBA | yes (1-bit mask plane on write) | no | no | no | no | no | no | built in |
| AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` |
| HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` |
@@ -134,12 +134,17 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
interpretations return `Unsupported`/`Malformed`. There is no DICOM writer
(`ASYMMETRIC_EXCEPTIONS`).
* **Amiga IFF (read-only).** `FORM ... ILBM` stores interleaved 1–8 bitplanes;
* **Amiga IFF.** `FORM ... ILBM` stores interleaved 1–8 bitplanes;
`FORM ... PBM ` stores 8-bit chunky palette indices with even-padded rows. Both support
stored and ByteRun1 compression, a `CMAP` palette (grey ramp when absent, black for missing
entries), and transparent-colour masking. ILBM also supports a separate mask plane. HAM/EHB
viewport modes, PBM mask planes, other bit depths and compressions return
`CodecError::Unsupported`. There is no IFF writer; save as PNG.
entries), and transparent-color masking. ILBM also supports a separate mask plane and
24-bitplane RGB ("deep" ILBM: 8 planes each of red, green and blue, least significant bit
first, no palette). HAM/EHB viewport modes, PBM mask planes, a transparent color in a
24-bitplane file, other bit depths and compressions return `CodecError::Unsupported`.
Writing produces a 24-bitplane ILBM, ByteRun1 compressed one plane row at a time, at most
65535 × 65535 pixels. An image with alpha gets a mask plane (masking 1): alpha of 128 or more
is opaque, and `AlphaBinarized` is raised when any pixel is not fully opaque. Gray is
expanded to RGB, and 16-bit and float images are reduced to 8 bits.
* **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
+199 -9
View File
@@ -1,13 +1,18 @@
//! Amiga IFF/ILBM reader (EA "Interchange File Format", "InterLeaved BitMap" and the chunky
//! "PBM " variant). Written from the published IFF/ILBM layout: a `FORM` chunk of four-byte
//! Amiga IFF/ILBM reader and writer (EA "Interchange File Format", "InterLeaved BitMap" and the
//! chunky "PBM " variant). Written from the published IFF/ILBM layout: a `FORM` chunk of four-byte
//! chunks (`BMHD` bitmap header, `CMAP` palette, `CAMG` Amiga viewport mode, `BODY` pixel data).
//! Read-only: modern saves should use PNG.
//!
//! Supported: 1–8 bitplanes with a palette (or a grey ramp when there is none), masking 0/1/2,
//! and both stored (0) and ByteRun1/PackBits (1) compression. Anything else is a clear
//! `Unsupported`/`Malformed` error, never a panic.
//! Read: 1–8 bitplanes with a palette (or a gray ramp when there is none) with masking 0/1/2, and
//! 24-bitplane "deep" ILBMs (8 planes each of red, green and blue, least significant bit first,
//! no palette) with masking 0/1, both stored (0) and ByteRun1/PackBits (1) compressed. Anything
//! else is a clear `Unsupported`/`Malformed` error, never a panic.
//!
//! Write: a 24-bitplane ILBM, ByteRun1 compressed one plane row at a time, with a mask plane
//! (masking 1) when the image has alpha. The mask plane is one bit per pixel: alpha of 128 or
//! more is opaque.
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::format::Format;
use crate::image::{ChannelLayout, Image, SampleType};
use crate::options::Limits;
@@ -37,6 +42,9 @@ pub(crate) fn looks_like_iff(b: &[u8]) -> bool {
b.get(0..4) == Some(b"FORM") && matches!(b.get(8..12), Some(b"ILBM" | b"PBM "))
}
/// Bitplanes of a "deep" ILBM: red, green and blue, 8 each, least significant bit first.
const DEEP_PLANES: u8 = 24;
/// The bitmap header fields decoding needs.
struct Header {
width: u32,
@@ -88,12 +96,15 @@ fn parse_header(b: &[u8], chunky: bool) -> Result<Header, CodecError> {
if width == 0 || height == 0 {
return Err(bad("zero-sized bitmap"));
}
if !(1..=8).contains(&planes) {
return Err(unsupported(format!("{planes} bitplanes (1–8, with a palette)")));
if !(1..=8).contains(&planes) && planes != DEEP_PLANES {
return Err(unsupported(format!("{planes} bitplanes (1–8 with a palette, or 24)")));
}
if masking > 2 {
return Err(unsupported(format!("masking {masking}")));
}
if planes == DEEP_PLANES && masking == 2 {
return Err(unsupported("a transparent color in a 24-bitplane ILBM"));
}
if compression > 1 {
return Err(unsupported(format!("compression {compression} (only stored and ByteRun1)")));
}
@@ -201,7 +212,7 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
} else {
(0..planes).filter(|&p| bit_at(&stored, (y * rows_planes + p) * plane_row, x)).fold(0usize, |acc, p| acc | (1 << p))
};
let c = palette(index);
let c = if h.planes == DEEP_PLANES { [index as u8, (index >> 8) as u8, (index >> 16) as u8] } else { palette(index) };
out.extend_from_slice(&c);
if h.masking != 0 {
let opaque = if h.masking == 1 { bit_at(&stored, (y * rows_planes + planes) * plane_row, x) } else { index != usize::from(h.transparent) };
@@ -217,6 +228,104 @@ fn bit_at(stored: &[u8], base: usize, x: usize) -> bool {
stored.get(base + x / 8).is_some_and(|byte| byte & (0x80 >> (x % 8)) != 0)
}
/// ByteRun1 / PackBits encode of one plane row, appended to `out`: three or more equal bytes
/// become a repeat (at most 128), everything else literal runs (at most 128 bytes).
fn pack_byte_run1(row: &[u8], out: &mut Vec<u8>) {
let mut i = 0usize;
while let Some(&value) = row.get(i) {
let repeat = row[i..].iter().take(128).take_while(|&&b| b == value).count();
if repeat >= 3 {
out.push((1 - repeat as i32) as i8 as u8);
out.push(value);
i += repeat;
continue;
}
let start = i;
while i < row.len() && i - start < 128 {
if row.get(i..i + 3).is_some_and(|r| r[0] == r[1] && r[1] == r[2]) {
break;
}
i += 1;
}
out.push((i - start - 1) as u8);
out.extend_from_slice(&row[start..i]);
}
}
fn push_chunk(out: &mut Vec<u8>, id: &[u8; 4], data: &[u8]) -> Result<(), CodecError> {
let len = u32::try_from(data.len()).map_err(|_| CodecError::encode(F, "chunk too large for an IFF file (4 GiB)"))?;
out.extend_from_slice(id);
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(data);
// Chunks are padded to an even length.
if data.len() % 2 == 1 {
out.push(0);
}
Ok(())
}
/// Writes a 24-bitplane `FORM ILBM`: `BMHD` then a ByteRun1-compressed `BODY`, each row stored as
/// the red, green and blue planes (least significant bit first) and, for RGBA, a mask plane.
pub(crate) fn encode(src: &Image, plan: Plan) -> Result<Vec<u8>, CodecError> {
let enc = |msg: String| CodecError::encode(F, msg);
let img = src.converted(plan.layout, plan.sample);
let masked = match img.layout() {
ChannelLayout::Rgb => false,
ChannelLayout::Rgba => true,
l => return Err(enc(format!("IFF/ILBM cannot store {l:?}"))),
};
if img.sample_type() != SampleType::U8 {
return Err(enc(format!("IFF/ILBM cannot store {:?} samples", img.sample_type())));
}
let (w, h) = img.dimensions();
let (Ok(w16), Ok(h16)) = (u16::try_from(w), u16::try_from(h)) else {
return Err(enc("IFF/ILBM stores at most 65535 × 65535 pixels".into()));
};
let (w, ch) = (w as usize, img.layout().channels());
let data = img.data();
let row_len = w.checked_mul(ch).ok_or_else(|| enc("image too large".into()))?;
if data.len() != row_len.checked_mul(h as usize).ok_or_else(|| enc("image too large".into()))? {
return Err(enc("the image has the wrong number of samples".into()));
}
let planes = usize::from(DEEP_PLANES) + usize::from(masked);
let mut plane_row = vec![0u8; w.div_ceil(16) * 2];
let mut body = Vec::new();
for px in data.chunks_exact(row_len.max(1)) {
for plane in 0..planes {
plane_row.fill(0);
for (x, p) in px.chunks_exact(ch).enumerate() {
let on = if plane == usize::from(DEEP_PLANES) { p[3] >= 128 } else { (p[plane / 8] >> (plane % 8)) & 1 == 1 };
if on {
plane_row[x / 8] |= 0x80 >> (x % 8);
}
}
pack_byte_run1(&plane_row, &mut body);
}
}
let mut bmhd = Vec::with_capacity(20);
bmhd.extend_from_slice(&w16.to_be_bytes());
bmhd.extend_from_slice(&h16.to_be_bytes());
bmhd.extend_from_slice(&[0, 0, 0, 0]); // x/y origin
bmhd.push(DEEP_PLANES);
bmhd.push(u8::from(masked)); // masking: 1 = a mask plane follows the color planes
bmhd.push(1); // ByteRun1
bmhd.push(0); // pad
bmhd.extend_from_slice(&[0, 0]); // transparent color (unused)
bmhd.extend_from_slice(&[1, 1]); // square pixels
bmhd.extend_from_slice(&w16.to_be_bytes()); // page size
bmhd.extend_from_slice(&h16.to_be_bytes());
let mut out = b"FORM".to_vec();
out.extend_from_slice(&[0; 4]); // patched below
out.extend_from_slice(b"ILBM");
push_chunk(&mut out, b"BMHD", &bmhd)?;
push_chunk(&mut out, b"BODY", &body)?;
let form_len = u32::try_from(out.len() - 8).map_err(|_| enc("image too large for an IFF file (4 GiB)".into()))?;
out[4..8].copy_from_slice(&form_len.to_be_bytes());
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -339,6 +448,87 @@ mod tests {
assert_eq!(decode(&b, &Limits::default()).unwrap().data(), &[0, 0, 0]);
}
fn plan(layout: ChannelLayout) -> Plan {
Plan { layout, sample: SampleType::U8 }
}
#[test]
fn reads_a_stored_24_plane_bitmap() {
// 2×1: pixel 0 = (1, 0, 128), pixel 1 = (0, 255, 0). Each plane row is 2 bytes; red bit 0
// has pixel 0 set, green bits 0–7 have pixel 1 set, blue bit 7 has pixel 0 set.
let mut body = Vec::new();
for plane in 0..24 {
let byte = match plane {
0 | 23 => 0x80,
8..=15 => 0x40,
_ => 0,
};
body.extend_from_slice(&[byte, 0]);
}
let b = ilbm(2, 1, 24, &[], 0, &body);
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!((img.width(), img.height(), img.layout()), (2, 1, ChannelLayout::Rgb));
assert_eq!(img.data(), &[1, 0, 128, 0, 255, 0]);
}
#[test]
fn writes_24_plane_byte_run1_that_reads_back_exactly() {
// 37 pixels wide: three plane-row words, the last one partly padding.
let (w, h) = (37u32, 3u32);
let data: Vec<u8> = (0..w * h * 3).map(|i| (i * 7 % 256) as u8).collect();
let img = Image::from_u8(w, h, ChannelLayout::Rgb, data.clone()).unwrap();
let bytes = encode(&img, plan(ChannelLayout::Rgb)).unwrap();
assert!(looks_like_iff(&bytes));
assert_eq!(be32(&bytes, 4).unwrap() as usize, bytes.len() - 8, "FORM length");
let h = parse_header(&bytes, false).unwrap();
assert_eq!((h.width, h.height, h.planes, h.masking, h.compression), (37, 3, 24, 0, 1));
assert!(chunks(&bytes).all(|c| &c.id != b"CMAP"), "a deep ILBM has no palette");
let back = decode(&bytes, &Limits::default()).unwrap();
assert_eq!(back.layout(), ChannelLayout::Rgb);
assert_eq!(back.data(), &data[..]);
}
#[test]
fn alpha_is_written_as_a_mask_plane() {
let data = [10, 20, 30, 0, 40, 50, 60, 127, 70, 80, 90, 128, 1, 2, 3, 255];
let img = Image::from_u8(4, 1, ChannelLayout::Rgba, data.to_vec()).unwrap();
let bytes = encode(&img, plan(ChannelLayout::Rgba)).unwrap();
assert_eq!(parse_header(&bytes, false).unwrap().masking, 1);
let back = decode(&bytes, &Limits::default()).unwrap();
assert_eq!(back.layout(), ChannelLayout::Rgba);
assert_eq!(back.data(), &[10, 20, 30, 0, 40, 50, 60, 0, 70, 80, 90, 255, 1, 2, 3, 255]);
}
#[test]
fn byte_run1_round_trips_runs_literals_and_long_rows() {
let mut rows: Vec<Vec<u8>> = vec![vec![], vec![5], vec![5, 5], vec![5, 5, 5], vec![1, 2, 2, 2, 3], vec![9; 300]];
rows.push((0..300).map(|i| (i % 251) as u8).collect());
rows.push([vec![1, 2], vec![7; 129], vec![3]].concat());
for row in rows {
let mut packed = Vec::new();
pack_byte_run1(&row, &mut packed);
let mut back = vec![0; row.len()];
assert_eq!(unpack_byte_run1(&packed, &mut back).unwrap(), packed.len(), "{row:?}");
assert_eq!(back, row);
}
// A long repeat packs into two-byte runs.
let mut packed = Vec::new();
pack_byte_run1(&[0; 256], &mut packed);
assert_eq!(packed, [0x81, 0, 0x81, 0]);
}
#[test]
fn rejects_what_ilbm_cannot_store() {
let gray = Image::from_u8(1, 1, ChannelLayout::Gray, vec![0]).unwrap();
assert!(encode(&gray, plan(ChannelLayout::Gray)).is_err());
let wide = Image::from_u8(65536, 1, ChannelLayout::Rgb, vec![0; 65536 * 3]).unwrap();
assert!(encode(&wide, plan(ChannelLayout::Rgb)).is_err());
// A transparent color index has no meaning without a palette.
let mut b = ilbm(1, 1, 24, &[], 0, &[0; 48]);
b[29] = 2;
assert!(matches!(decode(&b, &Limits::default()), Err(CodecError::Unsupported { .. })));
}
#[test]
fn hostile_input_errors_not_panics() {
// Truncations of a valid file never panic.
+3 -2
View File
@@ -31,7 +31,7 @@ pub enum FidelityWarning {
RangeClipped,
/// Non-opaque alpha will be discarded.
AlphaDiscarded,
/// Alpha will be reduced to fully transparent / fully opaque (GIF).
/// Alpha will be reduced to fully transparent / fully opaque (GIF, DDS BC1, IFF).
AlphaBinarized,
/// CMYK data will be converted (naively, not colour-managed) to another model.
CmykConverted {
@@ -227,7 +227,8 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
if sl.has_alpha() && !p.layout.has_alpha() && image.has_translucency() {
w.push(W::AlphaDiscarded);
}
let binary_alpha = format == Format::Gif || (format == Format::Dds && opts.dds.compression == DdsCompression::Bc1);
// An IFF/ILBM mask plane holds one bit per pixel.
let binary_alpha = matches!(format, Format::Gif | Format::Iff) || (format == Format::Dds && opts.dds.compression == DdsCompression::Bc1);
if binary_alpha && p.layout.has_alpha() && image.has_translucency() {
w.push(W::AlphaBinarized);
}
+3 -4
View File
@@ -38,7 +38,7 @@ pub enum Format {
Cineon,
/// DICOM single-frame images (uncompressed): read-only.
Dicom,
/// Amiga IFF/ILBM (`FORM ... ILBM`): planar bitplanes with a palette, read-only.
/// Amiga IFF/ILBM (`FORM ... ILBM`): planar bitplanes with a palette or 24-bit RGB.
Iff,
}
@@ -72,7 +72,6 @@ pub struct FormatCaps {
/// The test-suite asserts that every other enabled format is read+write.
pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
(Format::Xpm, "XPM is a legacy Unix icon interchange format. Reading existing XPM icons is supported; writing modern image assets should use PNG instead."),
(Format::Iff, "Amiga IFF/ILBM is supported for opening legacy Amiga artwork; this crate has no IFF writer, so save as PNG instead."),
(
Format::Dicom,
"DICOM single-frame images are imported for medical review; this crate has no DICOM writer, so save an imported frame as TIFF or PNG instead.",
@@ -226,7 +225,7 @@ impl Format {
/// Maximum encodable dimensions, if the format has a hard limit.
pub fn max_dimensions(self) -> Option<(u32, u32)> {
match self {
Format::Jpeg | Format::Gif => Some((65535, 65535)),
Format::Jpeg | Format::Gif | Format::Iff => Some((65535, 65535)),
Format::WebP => Some((16384, 16384)),
Format::Ico => Some((256, 256)),
_ => None,
@@ -279,7 +278,7 @@ pub fn caps(format: Format) -> FormatCaps {
Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base },
Format::Cineon => FormatCaps { depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
Format::Dicom => FormatCaps { read: true, write: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
Format::Iff => FormatCaps { read: true, write: false, depths: &[S::U8], layouts: &[L::Rgb, L::Rgba], alpha: true, ..base },
Format::Iff => FormatCaps { depths: &[S::U8], layouts: &[L::Rgb, L::Rgba], alpha: true, ..base },
}
}
+1 -1
View File
@@ -303,7 +303,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
Format::Dpx => dpx::encode(image, plan, opts),
Format::Cineon => dpx::encode_cineon(image, plan),
Format::Dicom => Err(CodecError::unsupported(format, "DICOM is import-only; save as TIFF or PNG instead")),
Format::Iff => Err(CodecError::unsupported(format, "IFF/ILBM is import-only; save as PNG instead")),
Format::Iff => iff::encode(image, plan),
Format::OpenExr => exr::encode(image, plan, opts),
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts),
+4 -2
View File
@@ -61,6 +61,7 @@ fn default_build_formats_all_symmetric() {
Format::Fits,
Format::Dpx,
Format::Cineon,
Format::Iff,
];
for f in expected {
assert!(caps(f).read && caps(f).write, "{f:?}");
@@ -208,10 +209,11 @@ fn detect_magic_iff() {
assert_eq!(detect(b"FORM\0\0\0\x14PBM "), Some(Format::Iff));
assert_eq!(from_extension("art.LBM"), Some(Format::Iff));
assert_eq!(from_extension("art.ilbm"), Some(Format::Iff));
assert!(caps(Format::Iff).read && !caps(Format::Iff).write, "IFF is read-only");
assert!(caps(Format::Iff).read && caps(Format::Iff).write, "IFF is read and written");
assert!(caps(Format::Iff).alpha);
assert!(caps(Format::Iff).layouts.contains(&photocraft_codecs::ChannelLayout::Rgba));
assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Iff));
assert!(!ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Iff));
assert_eq!(Format::Iff.max_dimensions(), Some((65535, 65535)));
// A bare FORM without a bitmap type is not IFF.
assert_eq!(detect(b"FORM\0\0\0\x14AIFF"), None);
assert!(decode_as(Format::Iff, b"FORM\0\0\0\x14ILBM").is_err());
+7
View File
@@ -156,6 +156,13 @@ fn gif_quantizes_and_binarizes() {
assert!(!w.contains(&W::LossyCompression));
}
#[test]
fn iff_binarizes_alpha_and_reduces_16_bit() {
assert_eq!(warns(&test_image(ChannelLayout::Rgba, SampleType::U8), Format::Iff), vec![W::AlphaBinarized]);
let w = warns(&test_image(ChannelLayout::Rgb, SampleType::U16), Format::Iff);
assert!(w.contains(&W::DepthReduced { from: SampleType::U16, to: SampleType::U8 }));
assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::U8), Format::Iff).is_empty());
}
#[test]
fn hdr_is_lossy() {
assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::F32), Format::Hdr).contains(&W::LossyCompression));
}
+17
View File
@@ -233,6 +233,23 @@ fn next_artboard_name(doc: &Document) -> String {
(1..=names.len() + 1).map(|n| format!("Artboard {n}")).find(|n| !names.contains(&n.as_str())).unwrap_or_else(|| "Artboard".into())
}
/// File › New with Artboards (#9086): one board over the whole canvas, its background the
/// document's (white, black, a colour or transparent), holding an empty Layer 1. As in Photoshop,
/// there is no Background layer.
pub fn new_artboard_document(name: String, size: Size, mode: ColorMode, depth: SampleType, background: Option<Color>) -> Document {
let mut doc = Document::new(name, size, mode, depth);
let background = match background {
None => ArtboardBackground::Transparent,
Some(c) if c == Color::WHITE => ArtboardBackground::White,
Some(c) if c == Color::BLACK => ArtboardBackground::Black,
Some(c) => ArtboardBackground::Custom(c),
};
let mut board = Layer::group("Artboard 1", vec![Layer::raster("Layer 1", doc.pixel_format())]);
set_artboard(&mut board, Artboard { rect: doc.bounds(), background, preset: String::new() });
doc.layers.push(board);
doc
}
fn set_artboard(l: &mut Layer, a: Artboard) {
if let LayerContent::Group(g) = &mut l.content {
g.artboard = Some(a);
+29
View File
@@ -661,3 +661,32 @@ fn add_beside_duplicate_honors_panel_copy_name_preference() {
assert_eq!(doc(&s), &before);
}
}
/// #9086: File › New with Artboards makes one board over the canvas, its background the chosen
/// one, holding an empty Layer 1, and no Background layer.
#[test]
fn new_document_with_an_artboard() {
for (background, want) in [
("white", ArtboardBackground::White),
("black", ArtboardBackground::Black),
("transparent", ArtboardBackground::Transparent),
("#336699", ArtboardBackground::Custom(Color::rgba(0x33 as f32 / 255.0, 0x66 as f32 / 255.0, 0x99 as f32 / 255.0, 1.0))),
] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 120, "height": 90, "background": background, "artboard": true})).unwrap();
let d = doc(&s);
assert_eq!(d.layers.len(), 1, "{background}: only the board");
let boards = d.artboards();
let [(_, _, board)] = boards.as_slice() else { panic!("{background}: one artboard, got {}", boards.len()) };
assert_eq!(board.rect, d.bounds());
assert_eq!(board.background, want, "{background}");
let LayerContent::Group(g) = &d.layers[0].content else { panic!("not a group") };
assert_eq!(g.children.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Layer 1"]);
assert!(!d.layers.iter().any(|l| d.is_background(l.id)), "{background}: no Background layer");
}
// Without the option nothing changes; a non-boolean is refused.
let mut s = Session::new();
s.execute("file.new", json!({"width": 20, "height": 10, "artboard": false})).unwrap();
assert!(doc(&s).artboards().is_empty());
assert!(s.execute("file.new", json!({"artboard": "yes"})).is_err());
}
+3 -3
View File
@@ -3948,9 +3948,9 @@ pub struct Skipped {
fn export_item(id: &str, params: &Value, set: &str) -> Option<AtnItem> {
if id == PHOTOSHOP_STEP {
let event = jstr(params, "event")?;
let event = match jstr(params, "eventKind") {
Some("code") => AtnEvent::Code(event.as_bytes().try_into().ok()?),
Some("name") => AtnEvent::name(event),
let event = match params.get("eventKind") {
Some(Value::String(kind)) if kind == "code" => AtnEvent::Code(event.as_bytes().try_into().ok()?),
Some(Value::String(kind)) if kind == "name" => AtnEvent::name(event),
Some(_) => return None,
// Older PhotoCraft action data did not retain the event kind. Keep its previous
// best-effort export behavior for compatibility.
+6
View File
@@ -986,6 +986,12 @@ fn save_preserves_the_kind_of_unsupported_atn_events() {
round_trip.actions[0].items.iter().map(|item| item.event.clone()).collect::<Vec<_>>(),
[AtnEvent::Name(b"make".to_vec()), AtnEvent::Code(*b"Qwer")]
);
// Legacy steps may omit eventKind, but an explicitly malformed value must not be treated
// as if it were absent.
assert!(export_item(PHOTOSHOP_STEP, &json!({"event": "make", "eventKind": null}), "S").is_none());
assert!(export_item(PHOTOSHOP_STEP, &json!({"event": "make", "eventKind": 4}), "S").is_none());
assert_eq!(export_item(PHOTOSHOP_STEP, &json!({"event": "make"}), "S").unwrap().event, AtnEvent::Code(*b"make"));
}
/// #6745: the events of a Photoshop 2026 retouching set (ID-photo background swap, whitening,
+3
View File
@@ -137,6 +137,9 @@ pub(crate) fn transform_layer_geometry(l: &mut Layer, a: &Affine, content: bool,
if let Some(f) = &mut sh.fill {
map_fill(a, f);
}
if let Some(st) = &mut sh.stroke {
map_fill(a, &mut st.paint);
}
if content {
crate::vector_cmds::transform_shape(sh, a);
}
+32
View File
@@ -221,6 +221,38 @@ fn gradient_fill_offset_stays_a_fraction_of_the_frame() {
assert!((offset.0 - 0.1).abs() < 1e-6 && (offset.1 - 0.25).abs() < 1e-6, "{offset:?}");
}
/// The shape's (fill, stroke) gradient angles after `cmd` on a shape whose fill and stroke both
/// start as a 0° (left to right) gradient.
fn shape_gradient_angles_after(cmd: &str, p: serde_json::Value) -> (f32, f32) {
let mut s = Session::new();
s.execute("file.new", json!({"width": W, "height": H})).unwrap();
let g = json!({"gradient": {"stops": [[0, "#ff0000"], [1, "#0000ff"]], "angle": 0}});
s.execute("shape.create", json!({"kind": "rect", "rect": [10, 8, 30, 20], "fill": g, "stroke": {"width": 6, "fill": g}})).unwrap();
s.execute(cmd, p).unwrap();
let angle = |f: &Fill| if let Fill::Gradient { angle, .. } = f { *angle } else { panic!("not a gradient: {f:?}") };
let sh = doc(&s).walk().into_iter().find_map(|(_, _, l)| if let LayerContent::Shape(sh) = &l.content { Some(sh.clone()) } else { None }).unwrap();
(angle(sh.fill.as_ref().unwrap()), angle(&sh.stroke.as_ref().unwrap().paint))
}
/// A gradient stroke turns and mirrors with the canvas like the fill does (#9239).
#[test]
fn shape_gradient_stroke_turns_with_the_canvas() {
let same = |a: f32, b: f32| {
let d = (a - b).rem_euclid(360.0);
d < 1e-3 || d > 360.0 - 1e-3
};
for (cmd, p, want) in [
("image.imageRotation.90cw", json!({}), -90.0),
("image.imageRotation.flipCanvasHorizontal", json!({}), 180.0),
("image.imageRotation.flipCanvasVertical", json!({}), 0.0),
("image.rotation.arbitrary", json!({"angle": 30, "direction": "cw"}), -30.0),
] {
let (fill, stroke) = shape_gradient_angles_after(cmd, p);
assert!(same(fill, want), "{cmd}: fill {fill}, want {want}");
assert!(same(stroke, want), "{cmd}: stroke {stroke}, want {want}");
}
}
fn type_and_shape_origin(d: &Document) -> ([f64; 6], [f64; 2]) {
let mut t = None;
let mut k = None;
+2 -2
View File
@@ -74,7 +74,7 @@ pub(crate) fn copy(s: &mut Session) -> Result<Option<Value>> {
if bounds.is_empty() {
return Err(EngineError::Other("Could not copy: the selected area is empty".into()));
}
s.clipboard = Some(Clip { surface, bounds, layers: None, resolution: Some(doc.resolution_dpi) });
s.clipboard = Some(Clip { surface, bounds, layers: None, resolution: Some(doc.resolution_dpi), source: Some(doc.id) });
Ok(Some(json!({"bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()], "channel": k})))
}
@@ -86,7 +86,7 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit:
let d = s.active().ok_or(EngineError::NoDocument)?;
let canvas = d.doc.bounds();
let id = d.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
let (dx, dy) = crate::edit_cmds::paste_offset(&clip, canvas, p, in_place);
let (dx, dy) = crate::edit_cmds::paste_offset(&clip, d.doc.id, canvas, p, in_place);
let src = crate::edit_cmds::shifted(&clip.surface, dx, dy);
let area = src.content_bounds().intersect(&canvas);
s.edit(label, |doc, _| {
+16 -10
View File
@@ -547,7 +547,7 @@ fn build() -> Vec<CommandSpec> {
"New…",
["File"],
Some("Cmd+N"),
r##"{"width":1..300000=1920,"height":1..300000=1080 (pixel counts are rounded),"mode":"rgb|gray|grayscale|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","backgroundColor":[r,g,b]? (0..1; defaults to toolbox colour),"resolution":1..30000=72 (ppi),"name":str,"profile":"working"|built-in id|path to an .icc file="working" (the colour profile it is tagged with; it must suit the mode)}"##,
r##"{"width":1..300000=1920,"height":1..300000=1080 (pixel counts are rounded),"mode":"rgb|gray|grayscale|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","backgroundColor":[r,g,b]? (0..1; defaults to toolbox colour),"resolution":1..30000=72 (ppi),"name":str,"artboard":bool=false (one artboard over the canvas, its background the chosen one, holding an empty Layer 1; no Background layer),"profile":"working"|built-in id|path to an .icc file="working" (the colour profile it is tagged with; it must suit the mode)}"##,
always,
|s, p| {
use crate::document_preset_cmds::{MAX_DIMENSION, MAX_RESOLUTION, background_color, color_mode, sample_type};
@@ -566,22 +566,28 @@ fn build() -> Vec<CommandSpec> {
let depth = sample_type(p.get("depth").and_then(Value::as_u64).unwrap_or(8)).unwrap_or(SampleType::U8);
let name = p.get("name").and_then(Value::as_str).unwrap_or("Untitled").to_string();
let res = p.get("resolution").and_then(Value::as_f64).unwrap_or(72.0).clamp(1.0, MAX_RESOLUTION) as f32;
let mut doc = match p.get("background").and_then(Value::as_str).unwrap_or("white") {
// The background colour; `None` for Transparent.
let background = match p.get("background").and_then(Value::as_str).unwrap_or("white") {
"backgroundColor" => {
let bgc = background_color(p, s.tools.background, "file.new")?;
Document::with_background(name, Size::new(w, h), mode, depth, Color::rgba(bgc[0], bgc[1], bgc[2], 1.0))
Some(Color::rgba(bgc[0], bgc[1], bgc[2], 1.0))
}
"transparent" => {
let mut d = Document::new(name, Size::new(w, h), mode, depth);
d.layers.push(Layer::raster("Layer 1", d.pixel_format()));
d
}
"black" => Document::with_background(name, Size::new(w, h), mode, depth, Color::BLACK),
"transparent" => None,
"black" => Some(Color::BLACK),
other => {
let c = parse_hex(other).unwrap_or([1.0; 4]);
Document::with_background(name, Size::new(w, h), mode, depth, Color::rgba(c[0], c[1], c[2], c[3]))
Some(Color::rgba(c[0], c[1], c[2], c[3]))
}
};
let mut doc = if p.get("artboard").and_then(Value::as_bool).unwrap_or(false) {
crate::artboard_cmds::new_artboard_document(name, Size::new(w, h), mode, depth, background)
} else if let Some(c) = background {
Document::with_background(name, Size::new(w, h), mode, depth, c)
} else {
let mut d = Document::new(name, Size::new(w, h), mode, depth);
d.layers.push(Layer::raster("Layer 1", d.pixel_format()));
d
};
doc.resolution_dpi = res;
// Photoshop's New Document › Color Profile: tag the document with another profile
// than the working space (#321). Checked before the document opens.
@@ -78,6 +78,7 @@ pub(crate) fn validate_new_params(s: &Session, p: &Value) -> Result<()> {
continue;
}
"name" => (value.is_string(), "a string".into()),
"artboard" => (value.is_boolean(), "true or false".into()),
"profile" => (value.as_str().is_some_and(|v| !v.is_empty()), "working, a built-in profile id or an .icc path".into()),
_ => return Err(bad(cmd, format!("unknown parameter `{key}`"))),
};
+46 -18
View File
@@ -3,7 +3,7 @@
use photocraft_color::{ColorMode, PixelFormat};
use photocraft_doc::adjust::LevelsChannel;
use photocraft_doc::{Adjustment, Document, Layer, LayerContent, LayerId};
use photocraft_doc::{Adjustment, DocId, Document, Layer, LayerContent, LayerId};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::{Value, json};
@@ -31,6 +31,9 @@ pub struct Clip {
/// The source document's resolution (ppi): File › New from the clipboard takes it, as
/// Photoshop's Clipboard preset does. None for an image from another app.
pub resolution: Option<f32>,
/// The document the pixels were copied from: Paste keeps them in place only there, and
/// centres them in any other (#8945). None for an image from another app.
pub source: Option<DocId>,
}
/// Photoshop's layer-based copy (CC 2018 on): with no selection, Copy takes the active layer
@@ -195,7 +198,7 @@ fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Result<Clip> {
};
let mut out = Surface::new(with_alpha);
if area.is_empty() {
return Ok(Clip { surface: out, bounds: Rect::EMPTY, layers: None, resolution: None });
return Ok(Clip { surface: out, bounds: Rect::EMPTY, layers: None, resolution: None, source: None });
}
// Only the selected area is read: converting the whole layer (a 24 MP Background gaining
// alpha) cost about a second per Layer via Copy (#668).
@@ -217,7 +220,7 @@ fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Result<Clip> {
out.put_tiles(shared);
out.prune();
let bounds = out.content_bounds();
Ok(Clip { surface: out, bounds, layers: None, resolution: None })
Ok(Clip { surface: out, bounds, layers: None, resolution: None, source: None })
}
/// [`lift`] over one rectangle: `src`'s pixels with alpha (`n` channels), the selection
@@ -299,7 +302,7 @@ fn copy(s: &mut Session, merged: bool) -> Result<Value> {
return Err(EngineError::Other("Could not copy: the selected area is empty".into()));
}
let b = clip.bounds;
let clip = Clip { resolution: Some(d.doc.resolution_dpi), ..clip };
let clip = Clip { resolution: Some(d.doc.resolution_dpi), source: Some(d.doc.id), ..clip };
s.clipboard = Some(clip);
Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()]}))
}
@@ -345,8 +348,8 @@ fn copy_mask(s: &mut Session, filter: bool) -> Result<Value> {
return Err(EngineError::Other("Could not copy: the selected area is empty".into()));
}
let b = bounds;
let resolution = Some(d.doc.resolution_dpi);
s.clipboard = Some(Clip { surface, bounds: b, layers: None, resolution });
let (resolution, source) = (Some(d.doc.resolution_dpi), Some(d.doc.id));
s.clipboard = Some(Clip { surface, bounds: b, layers: None, resolution, source });
Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()], "target": if filter { "filterMask" } else { "mask" }}))
}
@@ -390,7 +393,7 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result<Value> {
}
let canvas = d.doc.bounds();
let fmt = d.doc.pixel_format();
let (dx, dy) = paste_offset(&clip, canvas, p, in_place);
let (dx, dy) = paste_offset(&clip, d.doc.id, canvas, p, in_place);
if let Some(layers) = clip.layers.as_deref().filter(|_| crate::layer_copy_cmds::layered(d.doc.mode)) {
return paste_layers(s, layers, dx, dy);
}
@@ -456,8 +459,9 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result<
/// canvas centre, then kept on the canvas as Photoshop keeps it: pixels that fit stay wholly
/// inside, larger ones start at the canvas's top-left corner (#6759). `"clampToCanvas": false`
/// skips that, for Paste Into / Outside, which centre on the selection.
pub(crate) fn paste_offset(clip: &Clip, canvas: Rect, p: &Value, in_place: bool) -> (i32, i32) {
if in_place || (clip.bounds.intersect(&canvas) == clip.bounds && p.get("center").is_none()) {
pub(crate) fn paste_offset(clip: &Clip, doc: DocId, canvas: Rect, p: &Value, in_place: bool) -> (i32, i32) {
let home = clip.source == Some(doc);
if in_place || (home && clip.bounds.intersect(&canvas) == clip.bounds && p.get("center").is_none()) {
return (0, 0);
}
let c = p.get("center").and_then(Value::as_array).filter(|a| a.len() >= 2).map(|a| (a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0)));
@@ -498,7 +502,7 @@ pub(crate) fn paste_to_target(s: &mut Session, p: &Value, in_place: bool, limit:
let d = s.active().ok_or(EngineError::NoDocument)?;
let canvas = d.doc.bounds();
let layer = d.active_layer;
let (dx, dy) = paste_offset(&clip, canvas, p, in_place);
let (dx, dy) = paste_offset(&clip, d.doc.id, canvas, p, in_place);
let src = shifted(&clip.surface, dx, dy);
let area = src.content_bounds().intersect(&canvas);
s.edit(label, |doc, _| {
@@ -1124,7 +1128,7 @@ mod tests {
let mut s = Session::new();
let mut surface = Surface::new(PixelFormat::RGBA8);
surface.write_pixel(i32::MIN, 0, &[1.0, 0.0, 0.0, 1.0]);
s.clipboard = Some(Clip { surface, bounds: Rect::from_xywh(i32::MIN, 0, 1, 1), layers: None, resolution: None });
s.clipboard = Some(Clip { surface, bounds: Rect::from_xywh(i32::MIN, 0, 1, 1), layers: None, resolution: None, source: None });
let result = new_from_clipboard(&mut s);
assert!(result.unwrap_err().to_string().contains("clipboard origin"));
assert!(s.execute("file.newFromClipboard", json!({})).is_err());
@@ -1132,15 +1136,38 @@ mod tests {
assert_eq!(s.clipboard.as_ref().unwrap().surface.rgba(i32::MIN, 0), [1.0, 0.0, 0.0, 1.0]);
}
/// #8945: pixels copied in one document land in place when pasted back into it, and centred
/// in another, even where their old position would fit (Photoshop's Paste).
#[test]
fn paste_centres_pixels_from_another_document() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
s.execute("select.all", json!({})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
let r = s.execute("edit.paste", json!({})).unwrap();
assert_eq!(r["offset"], json!([0, 0]), "back into its own document: in place");
s.execute("file.new", json!({"width": 100, "height": 80, "background": "white"})).unwrap();
let r = s.execute("edit.paste", json!({})).unwrap();
assert_eq!(r["offset"], json!([30, 25]), "into another document: centred");
// An image from another app has no source document: centred too.
s.clipboard = s.clipboard.take().map(|c| Clip { source: None, ..c });
let r = s.execute("edit.paste", json!({})).unwrap();
assert_eq!(r["offset"], json!([30, 25]));
// Paste in Place still keeps the position.
let r = s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
assert_eq!(r["offset"], json!([0, 0]));
}
#[test]
fn paste_centers_clipboard_bounds_near_coordinate_limits() {
for origin in [i32::MAX - 512, i32::MIN + 512] {
let clip = Clip { surface: Surface::new(PixelFormat::RGBA8), bounds: Rect::from_xywh(origin, origin, 2, 2), layers: None, resolution: None };
let (dx, dy) = paste_offset(&clip, Rect::new(0, 0, 20, 20), &json!({}), false);
let clip =
Clip { surface: Surface::new(PixelFormat::RGBA8), bounds: Rect::from_xywh(origin, origin, 2, 2), layers: None, resolution: None, source: None };
let (dx, dy) = paste_offset(&clip, DocId(1), Rect::new(0, 0, 20, 20), &json!({}), false);
assert_eq!(i64::from(origin) + i64::from(dx), 9);
assert_eq!(i64::from(origin) + i64::from(dy), 9);
let clip = Clip { bounds: Rect::new(0, 0, 2, 2), ..clip };
let (dx, dy) = paste_offset(&clip, Rect::from_xywh(origin, origin, 20, 20), &json!({}), false);
let (dx, dy) = paste_offset(&clip, DocId(1), Rect::from_xywh(origin, origin, 20, 20), &json!({}), false);
assert_eq!(dx, origin + 9);
assert_eq!(dy, origin + 9);
}
@@ -1317,7 +1344,7 @@ mod tests {
s.execute("file.new", json!({"width": w, "height": h})).unwrap();
let mut surface = Surface::new(PixelFormat::RGBA8);
surface.fill_rect(Rect::new(0, 0, cw, ch), &[1.0, 0.0, 0.0, 1.0]);
s.clipboard = Some(Clip { surface, bounds: Rect::new(0, 0, cw, ch), layers: None, resolution: None });
s.clipboard = Some(Clip { surface, bounds: Rect::new(0, 0, cw, ch), layers: None, resolution: None, source: None });
s
}
@@ -1378,8 +1405,9 @@ mod tests {
bounds: Rect::new(i32::MAX - 2, i32::MAX - 2, i32::MAX, i32::MAX),
layers: None,
resolution: None,
source: None,
};
let (dx, dy) = paste_offset(&clip, Rect::new(i32::MIN, i32::MIN, i32::MIN + 4, i32::MIN + 4), &json!({}), false);
let (dx, dy) = paste_offset(&clip, DocId(1), Rect::new(i32::MIN, i32::MIN, i32::MIN + 4, i32::MIN + 4), &json!({}), false);
assert_eq!((dx, dy), (i32::MIN, i32::MIN), "an unrepresentable shift saturates instead of wrapping");
}
@@ -2474,13 +2502,13 @@ mod tests {
};
let mut out = Surface::new(with_alpha);
if area.is_empty() {
return Clip { surface: out, bounds: Rect::EMPTY, layers: None, resolution: None };
return Clip { surface: out, bounds: Rect::EMPTY, layers: None, resolution: None, source: None };
}
let px = lift_region(src, sel, area, with_alpha.channels()).unwrap();
out.try_write_region(area, &px).unwrap();
out.prune();
let bounds = out.content_bounds();
Clip { surface: out, bounds, layers: None, resolution: None }
Clip { surface: out, bounds, layers: None, resolution: None, source: None }
}
fn noise(fmt: PixelFormat, r: Rect, seed: u32) -> Surface {
+54 -1
View File
@@ -400,7 +400,38 @@ fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result<Value> {
/// The canvas grown to every layer's pixels: Image › Reveal All's new canvas, and what the Crop
/// tool shows while a frame is edited.
pub fn reveal_all_bounds(doc: &Document) -> Rect {
doc.walk().into_iter().filter_map(|(_, _, l)| l.surface().map(|s| s.content_bounds())).fold(doc.bounds(), |a, b| a.union(&b))
let canvas = doc.bounds();
doc.walk().into_iter().filter_map(|(_, _, l)| l.surface().map(|s| visible_bounds(s, canvas))).fold(canvas, |a, b| a.union(&b))
}
/// Bounds of `s`'s pixels outside `canvas` with alpha > 0, as Photoshop's layer bounds (#9379):
/// the Eraser only clears alpha, so an erased pixel keeps its colour bytes and `content_bounds`
/// counts it. Pixels inside `canvas` can't grow the result and aren't read.
fn visible_bounds(s: &photocraft_raster::Surface, canvas: Rect) -> Rect {
use rayon::prelude::*;
let fmt = s.format();
if !fmt.alpha || s.default_pixel().last().is_none_or(|a| *a > 0.0) {
return s.content_bounds();
}
let (n, bpp, side) = (fmt.channels(), fmt.bytes_per_pixel(), photocraft_geom::TILE_SIZE);
let tiles: Vec<_> = s.tiles().filter(|(c, _)| !canvas.contains_rect(&c.rect())).collect();
let tile_bounds = |o: Rect, bytes: &[u8]| {
let mut out = Rect::EMPTY;
for (ly, row) in bytes.chunks_exact(bpp * side as usize).enumerate() {
let y = o.y0.saturating_add(ly as i32);
let on_canvas = (canvas.y0..canvas.y1).contains(&y);
let visible = |lx: &i32| {
let x = o.x0.saturating_add(*lx);
!(on_canvas && (canvas.x0..canvas.x1).contains(&x))
&& row.get(*lx as usize * bpp..).is_some_and(|px| photocraft_color::read_sample(px, fmt.sample, n - 1) > 0.0)
};
let Some(first) = (0..side).find(visible) else { continue };
let last = (first..side).rfind(visible).unwrap_or(first);
out = out.union(&Rect::new(o.x0.saturating_add(first), y, o.x0.saturating_add(last).saturating_add(1), y.saturating_add(1)));
}
out
};
tiles.par_iter().map(|(c, t)| tile_bounds(c.rect(), t.bytes())).reduce(|| Rect::EMPTY, |a, b| a.union(&b))
}
// ---------- layers ----------
@@ -1760,6 +1791,28 @@ mod tests {
assert_eq!(s.execute("image.revealAll", json!({})).unwrap()["changed"], false);
}
#[test]
fn reveal_all_skips_erased_pixels() {
// #9379: the Eraser only clears alpha, so erased pixels keep their colour bytes; Reveal
// All grew the canvas to them although nothing there is visible.
// No Background: its off-canvas pixels are opaque and would be revealed anyway.
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth, "background": "transparent"})).unwrap();
paint_square(&mut s, Rect::new(0, 0, 40, 30), [1.0, 0.0, 0.0, 1.0]);
let erase = json!({"points": [[32, -10], [32, 40]], "size": 28, "erase": true, "brush": {"hardness": 1.0}});
s.execute("paint.stroke", erase).unwrap();
assert_eq!(pixel(&s, 30, 15)[3], 0.0, "{depth}: erased");
s.execute("image.canvasSize", json!({"width": 20, "height": 30, "anchor": "topLeft"})).unwrap();
assert_eq!(s.execute("image.revealAll", json!({})).unwrap()["changed"], false, "{depth}");
assert_eq!((doc(&s).size.width, doc(&s).size.height), (20, 30), "{depth}");
// A pixel that is only partly erased is still there to reveal.
paint_square(&mut s, Rect::new(25, 5, 26, 6), [0.0, 0.0, 1.0, 0.5]);
assert_eq!(s.execute("image.revealAll", json!({})).unwrap()["changed"], true, "{depth}");
assert_eq!((doc(&s).size.width, doc(&s).size.height), (26, 30), "{depth}");
}
}
#[test]
fn rasterize_fill_and_all_in_one_step() {
let mut s = session(8);
+20
View File
@@ -123,6 +123,8 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
} else {
// Rotating the whole image moves locked layers too, including inside groups.
with_released_movement_locks(l, |l| crate::transform_cmds::transform_layer(None, photocraft_doc::Locks::default(), l, &h, Some(a), interp))?;
// Free Transform moves only linked masks; the whole image turns them all (#9201).
warp_unlinked_masks(l, &h, interp)?;
}
// What Free Transform leaves alone: unlinked vector masks, gradient angles, artboards…
crate::canvas_geom::transform_layer_geometry(l, &a, false, dpi);
@@ -142,6 +144,24 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"width": size.width, "height": size.height}))
}
/// Warps the unlinked pixel masks of `l` and its descendants, which [`transform_layer`] leaves in
/// place.
///
/// [`transform_layer`]: crate::transform_cmds::transform_layer
fn warp_unlinked_masks(l: &mut Layer, h: &Homography, interp: Interp) -> Result<()> {
if let Some(m) = l.mask.as_mut()
&& !m.linked
{
m.surface = crate::transform_cmds::warp_gray(&m.surface, h, interp)?;
}
if let LayerContent::Group(g) = &mut l.content {
for c in &mut g.children {
warp_unlinked_masks(c, h, interp)?;
}
}
Ok(())
}
/// Runs `f` with movement locks (`position` and `all`) temporarily released on `l` and all its
/// descendants, so canvas-wide rotation moves every layer, then restores each lock.
fn with_released_movement_locks<R>(l: &mut Layer, f: impl FnOnce(&mut Layer) -> Result<R>) -> Result<R> {
+53
View File
@@ -144,6 +144,59 @@ fn rotate_arbitrary_rotates_grouped_locked_children_and_preserves_lock() {
assert_eq!(photocraft_compose::flatten(&d_arb).px, photocraft_compose::flatten(d_cw).px);
}
/// Rotating the whole image turns every layer mask, linked or not, as Rotate 180° does (#9201).
#[test]
fn rotate_arbitrary_turns_unlinked_layer_masks() {
for depth in [8, 16] {
for grouped in [false, true] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth, "background": "white"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Paint"})).unwrap();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("layer.layerMask.revealSelection", json!({})).unwrap();
s.execute("layer.layerMask.linked", json!({"linked": false})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
if grouped {
s.execute("layer.groupLayers", json!({})).unwrap();
}
let mask = |s: &Session| doc(s).layer(id).unwrap().mask.clone().unwrap();
let base = s.active().unwrap().doc.clone();
s.execute("image.rotation.arbitrary", json!({"angle": 180, "interpolation": "nearest"})).unwrap();
let m = mask(&s);
assert!(!m.linked);
let at = |x, y| m.surface.sample_channel(x, y, 0);
assert!(at(34, 24) > 0.9, "{depth}-bit, grouped {grouped}: the mask turned with its layer");
assert!(at(4, 4) < 0.1, "{depth}-bit, grouped {grouped}: nothing left at the old place");
// Rotate 180° and a crop turned by 180° over the whole canvas give the same mask.
for (id_, p) in [("image.imageRotation.180", json!({})), ("image.crop", json!({"x": 0, "y": 0, "width": 40, "height": 30, "angle": 180}))] {
s.active_mut().unwrap().doc = base.clone();
s.execute(id_, p).unwrap();
let turned = mask(&s);
for y in 0..30 {
for x in 0..40 {
let (a, b) = (at(x, y), turned.surface.sample_channel(x, y, 0));
assert!((a - b).abs() < 1e-3, "{id_}, {depth}-bit, grouped {grouped}: ({x},{y}) {a} vs {b}");
}
}
}
// 90° pins the direction too (180° is its own inverse).
s.active_mut().unwrap().doc = base.clone();
s.execute("image.rotation.arbitrary", json!({"angle": 90, "interpolation": "nearest"})).unwrap();
let arbitrary = mask(&s);
s.active_mut().unwrap().doc = base.clone();
s.execute("image.imageRotation.90cw", json!({})).unwrap();
let turned = mask(&s);
for y in 0..40 {
for x in 0..30 {
let (a, b) = (arbitrary.surface.sample_channel(x, y, 0), turned.surface.sample_channel(x, y, 0));
assert!((a - b).abs() < 1e-3, "90cw, {depth}-bit, grouped {grouped}: ({x},{y}) {a} vs {b}");
}
}
}
}
}
#[test]
fn flattened_mode_pixels_match_the_allocating_conversion_at_every_channel_count() {
let px = [[0.0, 0.25, 0.75, 0.0], [0.9, 0.3, 0.2, 0.2], [0.1, 0.7, 0.4, 0.7], [1.0; 4]];
+24 -5
View File
@@ -22,14 +22,36 @@ use crate::{EngineError, Result, Session};
#[derive(Clone, Debug)]
pub struct PatternLibrary {
pub items: Vec<Pattern>,
/// Bumped when a pattern's pixels are replaced under the same id (an imported `.pat` whose
/// id is already in the library), which its id, name and size don't show.
pub pixels_rev: u64,
}
impl Default for PatternLibrary {
fn default() -> Self {
PatternLibrary { items: builtin() }
PatternLibrary { items: builtin(), pixels_rev: 0 }
}
}
impl PatternLibrary {
/// Add `pat`, or replace the pattern with its id (marking the pixels changed).
pub fn upsert(&mut self, pat: Pattern) {
match self.items.iter_mut().find(|q| q.id == pat.id) {
Some(q) => {
*q = pat;
self.pixels_rev += 1;
}
None => self.items.push(pat),
}
}
}
/// Whether `p` is a built-in pattern as shipped (same id, name, size and pixels): those are never
/// saved. A built-in replaced by an import, or renamed, is the user's and is.
pub(crate) fn is_pristine_builtin(p: &Pattern, builtins: &[Pattern]) -> bool {
builtins.iter().any(|b| b.id == p.id && b.name == p.name && (b.width, b.height) == (p.width, p.height) && b.surface == p.surface)
}
fn hash01(x: u32, y: u32, seed: u32) -> f32 {
let mut h = x.wrapping_mul(0x8da6_b343) ^ y.wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f);
h ^= h >> 13;
@@ -278,10 +300,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let mut ids = Vec::new();
for pat in pats {
ids.push(pat.id.clone());
match s.patterns.items.iter_mut().find(|q| q.id == pat.id) {
Some(q) => *q = pat,
None => s.patterns.items.push(pat),
}
s.patterns.upsert(pat);
}
Ok(json!({"imported": ids, "count": ids.len()}))
}
+143 -4
View File
@@ -829,12 +829,62 @@ fn merge_to_hdr(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- Crop and Straighten Photos ----------
/// Paints the pixels outside the selection (holes, gaps between regions) in
/// the median colour of the selection's edge, so detection sees them as
/// background and finds no photo there. Returns false when no pixel is at
/// least half selected (a faint feathered selection), so the caller refuses
/// instead of searching the unmasked area.
fn mask_unselected(sel: &Surface, area: Rect, w: usize, h: usize, rgba: &mut [[f32; 4]]) -> bool {
let mut cov = Vec::new();
sel.read_region_into(area, &mut cov);
let ch = sel.channels().max(1);
let inside: Vec<bool> = cov.chunks_exact(ch).map(|p| p[0] >= 0.5).collect();
if inside.len() != w * h || rgba.len() != w * h {
return true;
}
if !inside.iter().any(|&b| b) {
return false;
}
if inside.iter().all(|&b| b) {
return true;
}
let is_in = |x: usize, y: usize| inside.get(y * w + x).copied().unwrap_or(false);
let mut edge = Vec::new();
for y in 0..h {
for x in 0..w {
let on_edge = x == 0 || y == 0 || x + 1 == w || y + 1 == h || !is_in(x - 1, y) || !is_in(x + 1, y) || !is_in(x, y - 1) || !is_in(x, y + 1);
if is_in(x, y)
&& on_edge
&& let Some(q) = rgba.get(y * w + x)
{
edge.push(*q);
}
}
}
let bg: [f32; 4] = std::array::from_fn(|c| {
let mut v: Vec<f32> = edge.iter().map(|p| p[c]).collect();
v.sort_by(f32::total_cmp);
v.get(v.len() / 2).copied().unwrap_or(1.0)
});
for (q, &keep) in rgba.iter_mut().zip(&inside) {
if !keep {
*q = bg;
}
}
true
}
fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let doc = st.doc.clone();
let fmt = doc.pixel_format();
let flat = flattened(&doc, fmt);
let area = doc.bounds();
let canvas = doc.bounds();
// Like Photoshop, only the selected area is searched when there is a selection.
let area = doc.selection.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas));
if area.is_empty() {
return Err(EngineError::Other("the selection is empty".into()));
}
let (w, h) = (area.width() as usize, area.height() as usize);
let mut rgba = vec![[0.0f32; 4]; w * h];
flat.read_rgba_into(area, &mut rgba);
@@ -847,7 +897,15 @@ fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result<Value> {
}
}
}
let found = scancrop::find_photos(w, h, &rgba);
if let Some(sel) = &doc.selection
&& !mask_unselected(sel, area, w, h, &mut rgba)
{
return Err(EngineError::Other("no pixels are more than 50% selected".into()));
}
let mut found = scancrop::find_photos(w, h, &rgba);
for f in &mut found {
f.center = [f.center[0] + f64::from(area.x0), f.center[1] + f64::from(area.y0)];
}
if found.is_empty() {
return Err(EngineError::Other("no photos found on a uniform background".into()));
}
@@ -857,7 +915,7 @@ fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result<Value> {
let corners = f.corners();
let hq = Homography::rect_to_quad([0.0, 0.0, pw as f64, ph as f64], corners).and_then(|h| h.inverse());
let Some(hm) = hq else { continue };
let warped = warp_surface(&flat, area, &hm, Interp::Bicubic)?;
let warped = warp_surface(&flat, canvas, &hm, Interp::Bicubic)?;
let keep = Rect::new(0, 0, pw as i32, ph as i32);
let mut px = Surface::new(fmt);
px.write_region(keep, &warped.read_region(keep));
@@ -924,7 +982,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Crop and Straighten Photos",
menu: &["File", "Automate"],
shortcut: None,
params: "{} → one new document per photo found on the scan",
params: "{} → one new document per photo found on the scan (inside the selection when there is one)",
enabled: not_indexed,
run: crop_and_straighten,
journal: true,
@@ -1173,6 +1231,87 @@ mod tests {
assert_eq!(s.documents().len(), 1);
}
#[test]
fn crop_and_straighten_only_processes_the_selection() {
// Photoshop: with a selection, only the photos inside it are split off.
let (w, h) = (480usize, 340usize);
let mut px = vec![[0.98f32, 0.98, 0.98, 1.0]; w * h];
// Upright photos: red 160x110 at (40, 60), blue 150x120 at (290, 180).
for (x0, y0, pw, ph, c) in [(40, 60, 160, 110, [0.6f32, 0.1, 0.1, 1.0]), (290, 180, 150, 120, [0.1, 0.2, 0.6, 1.0])] {
for y in y0..y0 + ph {
px[y * w + x0..y * w + x0 + pw].fill(c);
}
}
let mut s = Session::new();
s.add_document(doc_from("scan", &px, w, h, SampleType::U8), None);
s.execute("select.rect", json!({"x": 270, "y": 160, "width": 190, "height": 160})).unwrap();
let r = s.execute("file.automate.cropAndStraightenPhotos", json!({})).unwrap();
let found = r["photos"].as_array().unwrap();
assert_eq!(found.len(), 1, "{r}");
assert_eq!(s.documents().len(), 2);
// The reported centre is in the scan's coordinates.
let c = &found[0]["center"];
assert!((c[0].as_f64().unwrap() - 365.0).abs() < 3.0 && (c[1].as_f64().unwrap() - 240.0).abs() < 3.0, "{c}");
let d = &s.documents()[1].doc;
assert!((d.size.width as i64 - 150).abs() <= 4 && (d.size.height as i64 - 120).abs() <= 4, "{:?}", d.size);
let q = d.layers[0].surface().unwrap().rgba(d.size.width as i32 / 2, d.size.height as i32 / 2);
assert!(q[2] > 0.5 && q[0] < 0.2, "{q:?}");
// Without a selection both photos are found.
s.set_active(0);
s.execute("select.deselect", json!({})).unwrap();
let r = s.execute("file.automate.cropAndStraightenPhotos", json!({})).unwrap();
assert_eq!(r["photos"].as_array().unwrap().len(), 2, "{r}");
}
#[test]
fn crop_and_straighten_skips_photos_between_selected_regions() {
// Two selected rectangles: a photo in the gap between them lies inside
// their bounding box but outside the selection, so it is not split off.
let (w, h) = (480usize, 340usize);
let mut px = vec![[0.75f32, 0.75, 0.75, 1.0]; w * h];
// Red 160x110 at (40, 60) and blue 150x120 at (290, 180) are selected; green 130x80 at (280, 50) is not.
for (x0, y0, pw, ph, c) in
[(40, 60, 160, 110, [0.6f32, 0.1, 0.1, 1.0]), (290, 180, 150, 120, [0.1, 0.2, 0.6, 1.0]), (280, 50, 130, 80, [0.1, 0.6, 0.1, 1.0])]
{
for y in y0..y0 + ph {
px[y * w + x0..y * w + x0 + pw].fill(c);
}
}
let mut s = Session::new();
s.add_document(doc_from("scan", &px, w, h, SampleType::U8), None);
s.execute("select.rect", json!({"x": 20, "y": 40, "width": 200, "height": 150})).unwrap();
s.execute("select.rect", json!({"x": 270, "y": 160, "width": 190, "height": 160, "mode": "add"})).unwrap();
let r = s.execute("file.automate.cropAndStraightenPhotos", json!({})).unwrap();
let found = r["photos"].as_array().unwrap();
assert_eq!(found.len(), 2, "{r}");
for f in found {
let c = &f["center"];
let (cx, cy) = (c[0].as_f64().unwrap(), c[1].as_f64().unwrap());
let near = |x: f64, y: f64| (cx - x).abs() < 3.0 && (cy - y).abs() < 3.0;
assert!(near(120.0, 115.0) || near(365.0, 240.0), "{c}");
}
}
#[test]
fn crop_and_straighten_refuses_a_selection_under_half_coverage() {
// A faint (strongly feathered) selection has no pixel at or above 50%:
// like Photoshop, nothing counts as selected, so no photo is split off
// from the unmasked bounding box.
let (w, h) = (480usize, 340usize);
let mut px = vec![[0.98f32, 0.98, 0.98, 1.0]; w * h];
for y in 180..300 {
px[y * w + 290..y * w + 440].fill([0.1, 0.2, 0.6, 1.0]);
}
let mut s = Session::new();
s.add_document(doc_from("scan", &px, w, h, SampleType::U8), None);
let mut m = Surface::new(photocraft_color::PixelFormat::GRAY8);
m.fill_rect(Rect::new(270, 160, 460, 320), &[0.3]);
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).selection = Some(m);
let e = s.execute("file.automate.cropAndStraightenPhotos", json!({})).unwrap_err();
assert!(e.to_string().contains("50%"), "{e}");
assert_eq!(s.documents().len(), 1);
}
#[test]
fn crop_and_straighten_makes_documents() {
let (w, h) = (500usize, 360usize);
+118 -3
View File
@@ -38,6 +38,11 @@
//! The Actions list (`actions.json`, see [`crate::actions_cmds`]) lives in the same directory.
//! A missing file is an empty list. A corrupt or oversized file is skipped with a warning.
//! Headless sessions have no store, so their actions stay in memory.
//!
//! So do the patterns the user defined or imported (`patterns.pat`, Photoshop's own pattern file
//! format, #9317): the whole user library in its order, rewritten when its ids, names or order
//! change. The built-in patterns are regenerated on start and never written. Like `actions.json`,
//! a file that fails to load is warned about and never overwritten.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::Read;
@@ -67,6 +72,10 @@ pub const MAX_INDEX_BYTES: u64 = 4 << 20;
pub const ACTIONS_FILE: &str = "actions.json";
/// Largest Actions file read or written.
pub const MAX_ACTIONS_BYTES: u64 = 16 << 20;
/// The user's pattern library (`.pat`).
pub const PATTERNS_FILE: &str = "patterns.pat";
/// Largest pattern library read or written.
pub const MAX_PATTERNS_BYTES: u64 = 256 << 20;
/// The whole store never grows beyond this; further groups are skipped with a warning.
pub const MAX_STORE_BYTES: u64 = 2 << 30;
/// Largest tip side accepted from disk.
@@ -550,6 +559,10 @@ pub struct PresetStore {
actions_rev: u64,
/// `actions.json` exists but failed to load: left on disk untouched, so it is never written.
actions_skipped: bool,
/// [`patterns_key`] of the user patterns last written (or loaded).
patterns_key: u64,
/// `patterns.pat` exists but failed to load: left on disk untouched, so it is never written.
patterns_skipped: bool,
warnings: Vec<String>,
/// Full tips loaded for brushes in use (behind a lock so `&Session` paths can load).
cache: Mutex<TipCache>,
@@ -568,6 +581,8 @@ pub struct Opened {
pub warnings: Vec<String>,
/// Recorded actions (`actions.json`). Empty when the file is missing or unreadable.
pub actions: Vec<crate::actions_cmds::Action>,
/// User patterns (`patterns.pat`), in library order. Empty when the file is missing or unreadable.
pub patterns: Vec<photocraft_doc::Pattern>,
}
/// Load a store (any thread; the desktop app does this in the background at start).
@@ -644,6 +659,9 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
let loaded_actions = load_actions(backend.as_ref(), &mut warnings);
let actions_skipped = loaded_actions.is_none();
let actions = loaded_actions.unwrap_or_default();
let loaded_patterns = load_patterns(backend.as_ref(), &mut warnings);
let patterns_skipped = loaded_patterns.is_none();
let patterns = loaded_patterns.unwrap_or_default();
let mut store = PresetStore {
backend,
synced_rev: None,
@@ -653,6 +671,8 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
index: None,
actions_rev: 0,
actions_skipped,
patterns_key: patterns_key(&patterns, 0),
patterns_skipped,
warnings: Vec::new(),
cache: Mutex::new(TipCache { limit: TIP_CACHE_BYTES, ..Default::default() }),
};
@@ -710,7 +730,41 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
let hidden_builtins = index.as_ref().map(|i| i.hidden_builtins.clone()).unwrap_or_default();
let order = index.as_ref().map(|i| i.order.clone()).unwrap_or_default();
store.index = index;
Opened { store, presets, hidden_builtins, order, warnings, actions }
Opened { store, presets, hidden_builtins, order, warnings, actions, patterns }
}
/// `patterns.pat`: a missing file is no patterns. Anything else unreadable is a warning.
/// The saved patterns; `None` when `patterns.pat` exists but could not be loaded.
fn load_patterns(backend: &dyn PresetBackend, warnings: &mut Vec<String>) -> Option<Vec<photocraft_doc::Pattern>> {
let bytes = match backend.read(PATTERNS_FILE, MAX_PATTERNS_BYTES) {
Ok(b) => b,
Err(e) if missing_file(&e) => return Some(Vec::new()),
Err(e) => {
warnings.push(format!("patterns: {PATTERNS_FILE} skipped: {e}"));
return None;
}
};
match photocraft_io::pattern_map::read_pat(&bytes) {
Ok(p) => Some(p),
Err(e) => {
warnings.push(format!("patterns: {PATTERNS_FILE} skipped: {e}"));
None
}
}
}
/// What decides whether the user patterns need rewriting: their ids, names, sizes and order, and
/// the library's [`pixels_rev`](crate::pattern_cmds::PatternLibrary::pixels_rev) (pixels replaced
/// under the same id).
fn patterns_key(patterns: &[photocraft_doc::Pattern], pixels_rev: u64) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
for p in patterns {
(&p.id, &p.name, p.width, p.height).hash(&mut h);
}
patterns.len().hash(&mut h);
pixels_rev.hash(&mut h);
h.finish()
}
/// `actions.json`: a missing file is an empty list. Anything else unreadable is a warning.
@@ -1075,6 +1129,49 @@ impl PresetStore {
}
}
impl PresetStore {
/// Write the user patterns (the library minus the built-ins) when they changed since the last
/// load or write. An oversized library is warned once per change and not written. An I/O
/// error is warned and retried on the next command.
pub fn sync_patterns(&mut self, library: &[photocraft_doc::Pattern], pixels_rev: u64) {
let builtins = crate::pattern_cmds::builtin();
let user: Vec<photocraft_doc::Pattern> = library.iter().filter(|p| !crate::pattern_cmds::is_pristine_builtin(p, &builtins)).cloned().collect();
let key = patterns_key(&user, pixels_rev);
if self.patterns_key == key {
return;
}
self.patterns_key = key;
if self.patterns_skipped {
self.warnings.push(format!("Couldn't save patterns: {PATTERNS_FILE} could not be read, so it was left unchanged"));
return;
}
if user.is_empty() {
if let Err(e) = self.backend.remove(PATTERNS_FILE) {
self.warnings.push(format!("Couldn't save patterns: {e}"));
// Try again after the next command.
self.patterns_key = key.wrapping_add(1);
}
return;
}
let bytes = match photocraft_io::pattern_map::write_pat(&user) {
Ok(b) => b,
Err(e) => {
self.warnings.push(format!("Couldn't save patterns: {e}"));
return;
}
};
if bytes.len() as u64 > MAX_PATTERNS_BYTES {
self.warnings.push(format!("patterns: the pattern library is too large to save ({} MB limit)", MAX_PATTERNS_BYTES >> 20));
return;
}
if let Err(e) = self.backend.write(PATTERNS_FILE, &bytes) {
self.warnings.push(format!("Couldn't save patterns: {e}"));
// Try again after the next command.
self.patterns_key = key.wrapping_add(1);
}
}
}
fn builtin_names() -> &'static [String] {
static NAMES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
NAMES.get_or_init(|| photocraft_paint::presets::builtin().into_iter().map(|p| p.name).collect())
@@ -1091,7 +1188,24 @@ impl Session {
/// built-in of the same name; presets created before the store finished loading win), hide
/// deleted built-ins, then sync. Returns the load warnings.
pub fn attach_preset_store(&mut self, opened: Opened) -> Vec<String> {
let Opened { store, presets, hidden_builtins, order, mut warnings, actions: disk } = opened;
let Opened { store, presets, hidden_builtins, order, mut warnings, actions: disk, patterns } = opened;
// Saved patterns join the library after the built-ins, and a saved built-in (replaced by
// an import or renamed) takes the shipped one's place; a pattern defined or imported
// before the store finished loading keeps its place (and the same id isn't added twice).
let builtins = crate::pattern_cmds::builtin();
for p in patterns {
match self.patterns.items.iter_mut().find(|q| q.id == p.id) {
Some(q) if crate::pattern_cmds::is_pristine_builtin(q, &builtins) => *q = p,
// The earlier copy wins; other pixels than the saved ones must still be written,
// which its id, name and size don't show.
Some(q) => {
if q.surface != p.surface {
self.patterns.pixels_rev += 1;
}
}
None => self.patterns.items.push(p),
}
}
let lib = &mut self.tools.presets;
lib.retain(|p| !(p.builtin && hidden_builtins.iter().any(|h| h.eq_ignore_ascii_case(&p.name))));
for p in presets {
@@ -1136,7 +1250,7 @@ impl Session {
warnings
}
/// Write pending brush preset and Actions changes to the attached store (no-op without one).
/// Write pending brush preset, Actions and pattern changes to the attached store (no-op without one).
pub fn sync_preset_store(&mut self) {
let rev = self.tools.presets_rev;
let actions_rev = self.actions.rev;
@@ -1151,6 +1265,7 @@ impl Session {
}
}
st.sync_actions(&self.actions.list, actions_rev);
st.sync_patterns(&self.patterns.items, self.patterns.pixels_rev);
}
}
+167
View File
@@ -703,3 +703,170 @@ fn an_actions_file_that_failed_to_load_is_not_overwritten() {
assert_eq!(mem.files.lock().unwrap().get(ACTIONS_FILE), Some(&newer), "an actions file the store could not read was overwritten");
assert!(!s.preset_store.as_mut().unwrap().take_warnings().is_empty());
}
/// #9317: a defined or imported pattern survives a restart, as in Photoshop; the built-in
/// patterns are never written, and deleting the last user pattern removes the file.
#[test]
fn user_patterns_survive_a_restart() {
let dir = TempDir::new("patterns");
let builtin = Session::new().patterns.items.len();
let (mut s, w) = session(&dir);
assert!(w.is_empty(), "{w:?}");
s.execute("file.new", json!({"width": 12, "height": 10})).unwrap();
s.execute("edit.fill", json!({"color": "#1ea05a"})).unwrap();
let id = s.execute("edit.definePattern", json!({"name": "Green Tile"})).unwrap()["pattern"].as_str().unwrap().to_string();
s.sync_preset_store();
assert!(dir.files().contains(&PATTERNS_FILE.to_string()), "{:?}", dir.files());
// Only the user's pattern is in the file.
let bytes = std::fs::read(dir.0.join(PATTERNS_FILE)).unwrap();
let saved = photocraft_io::pattern_map::read_pat(&bytes).unwrap();
assert_eq!(saved.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Green Tile"]);
drop(s);
let (mut t, w) = session(&dir);
assert!(w.is_empty(), "{w:?}");
assert_eq!(t.patterns.items.len(), builtin + 1);
let back = t.patterns.items.iter().find(|p| p.id == id).expect("the defined pattern is back after a restart");
assert_eq!((back.name.as_str(), back.width, back.height), ("Green Tile", 12, 10));
assert_eq!(back.surface, saved[0].surface, "with its pixels");
// Renames are kept; deleting the last user pattern removes the file.
t.execute("pattern.rename", json!({"pattern": id, "name": "Moss"})).unwrap();
t.sync_preset_store();
let (u, _) = session(&dir);
assert!(u.patterns.items.iter().any(|p| p.id == id && p.name == "Moss"));
t.execute("pattern.delete", json!({"pattern": id})).unwrap();
t.sync_preset_store();
assert!(!dir.files().contains(&PATTERNS_FILE.to_string()), "{:?}", dir.files());
}
#[test]
fn a_patterns_file_that_failed_to_load_is_not_overwritten() {
let mem = MemBackend::default();
let unreadable = b"not a pattern file".to_vec();
mem.files.lock().unwrap().insert(PATTERNS_FILE.into(), unreadable.clone());
let mut s = Session::new();
let w = s.attach_preset_store(open(Box::new(mem.clone())));
assert!(w.iter().any(|w| w.contains(PATTERNS_FILE)), "{w:?}");
s.execute("file.new", json!({"width": 4, "height": 4})).unwrap();
s.execute("edit.definePattern", json!({"name": "New"})).unwrap();
s.sync_preset_store();
assert_eq!(mem.files.lock().unwrap().get(PATTERNS_FILE), Some(&unreadable), "a patterns file the store could not read was overwritten");
assert!(!s.preset_store.as_mut().unwrap().take_warnings().is_empty());
}
/// A `.pat` of one 4×4 pattern with id `id`, filled with `rgba`.
fn pat_bytes(id: &str, rgba: [f32; 4]) -> String {
use photocraft_geom::Rect;
use photocraft_raster::Surface;
let mut surface = Surface::new(photocraft_color::PixelFormat::RGBA8);
surface.fill_rect(Rect::new(0, 0, 4, 4), &rgba);
let mut p = photocraft_doc::Pattern::new("Same", surface, 4, 4);
p.id = id.into();
photocraft_paint::tile::b64_encode(&photocraft_io::pattern_map::write_pat(&[p]).unwrap())
}
/// #9318 review: re-importing a pattern under the same id, name and size but with other pixels
/// (an edited `.pat`) is written, so a restart doesn't bring the old pixels back.
#[test]
fn a_same_id_pattern_replacement_survives_a_restart() {
let mem = MemBackend::default();
let mut s = Session::new();
s.attach_preset_store(open(Box::new(mem.clone())));
let id = "6f1c2c1e-0000-4000-8000-000000009318";
s.execute("pattern.import", json!({"data": pat_bytes(id, [1.0, 0.0, 0.0, 1.0])})).unwrap();
s.sync_preset_store();
s.execute("pattern.import", json!({"data": pat_bytes(id, [0.0, 0.0, 1.0, 1.0])})).unwrap();
s.sync_preset_store();
let mut t = Session::new();
t.attach_preset_store(open(Box::new(mem.clone())));
let back = t.patterns.items.iter().find(|p| p.id == id).expect("the pattern is back");
let want = s.patterns.items.iter().find(|p| p.id == id).unwrap();
assert_eq!(back.surface, want.surface, "with the replacement's pixels, not the first import's");
}
/// A [`MemBackend`] whose removals fail while `fail` is set.
#[derive(Clone, Default)]
struct FlakyRemove {
mem: MemBackend,
fail: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
impl PresetBackend for FlakyRemove {
fn list(&self) -> Result<Vec<(String, u64)>, String> {
self.mem.list()
}
fn read(&self, name: &str, max: u64) -> Result<Vec<u8>, String> {
self.mem.read(name, max)
}
fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
self.mem.write(name, bytes)
}
fn remove(&self, name: &str) -> Result<(), String> {
if self.fail.load(std::sync::atomic::Ordering::SeqCst) {
return Err(format!("{name}: busy"));
}
self.mem.remove(name)
}
}
/// #9318 review: deleting the last user pattern while `patterns.pat` can't be removed is
/// warned and retried, so the deleted pattern doesn't come back after a restart.
#[test]
fn a_failed_patterns_removal_is_retried() {
let backend = FlakyRemove::default();
let mut s = Session::new();
s.attach_preset_store(open(Box::new(backend.clone())));
s.execute("file.new", json!({"width": 4, "height": 4})).unwrap();
let id = s.execute("edit.definePattern", json!({"name": "Gone"})).unwrap()["pattern"].as_str().unwrap().to_string();
s.sync_preset_store();
assert!(backend.mem.files.lock().unwrap().contains_key(PATTERNS_FILE));
backend.fail.store(true, std::sync::atomic::Ordering::SeqCst);
s.execute("pattern.delete", json!({"pattern": id})).unwrap();
s.sync_preset_store();
assert!(!s.preset_store.as_mut().unwrap().take_warnings().is_empty(), "the failure is warned");
backend.fail.store(false, std::sync::atomic::Ordering::SeqCst);
s.execute("file.new", json!({"width": 4, "height": 4})).unwrap();
s.sync_preset_store();
assert!(!backend.mem.files.lock().unwrap().contains_key(PATTERNS_FILE), "the removal is retried");
}
/// #9318 review: an imported `.pat` that replaces a built-in pattern's pixels (same id) is saved
/// and replaces the built-in after a restart, once; untouched built-ins are still never written.
#[test]
fn an_imported_builtin_replacement_survives_a_restart() {
let mem = MemBackend::default();
let mut s = Session::new();
s.attach_preset_store(open(Box::new(mem.clone())));
let builtin = s.patterns.items[0].id.clone();
s.execute("pattern.import", json!({"data": pat_bytes(&builtin, [0.9, 0.1, 0.4, 1.0])})).unwrap();
s.sync_preset_store();
let saved = photocraft_io::pattern_map::read_pat(mem.files.lock().unwrap().get(PATTERNS_FILE).expect("written")).unwrap();
assert_eq!(saved.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(), [builtin.as_str()], "only the replaced built-in");
let mut t = Session::new();
t.attach_preset_store(open(Box::new(mem.clone())));
let back: Vec<_> = t.patterns.items.iter().filter(|p| p.id == builtin).collect();
assert_eq!(back.len(), 1, "once");
let want = s.patterns.items.iter().find(|p| p.id == builtin).unwrap();
assert_eq!(back[0].surface, want.surface, "with the imported pixels");
}
/// #9318 review: a pattern imported before the store finished loading, with the id, name and
/// size of a saved one but other pixels, wins the merge and is written, so a restart keeps it.
#[test]
fn an_early_same_id_import_wins_and_is_saved() {
let mem = MemBackend::default();
let id = "6f1c2c1e-0000-4000-8000-00000000e4a1";
let mut s = Session::new();
s.attach_preset_store(open(Box::new(mem.clone())));
s.execute("pattern.import", json!({"data": pat_bytes(id, [1.0, 0.0, 0.0, 1.0])})).unwrap();
s.sync_preset_store();
// Next start: the same pattern with other pixels arrives before the store is attached.
let mut t = Session::new();
t.execute("pattern.import", json!({"data": pat_bytes(id, [0.0, 1.0, 0.0, 1.0])})).unwrap();
let early = t.patterns.items.iter().find(|p| p.id == id).unwrap().surface.clone();
t.attach_preset_store(open(Box::new(mem.clone())));
t.sync_preset_store();
let mut u = Session::new();
u.attach_preset_store(open(Box::new(mem.clone())));
assert_eq!(u.patterns.items.iter().find(|p| p.id == id).unwrap().surface, early, "the early import's pixels were saved");
}
+22 -3
View File
@@ -1143,12 +1143,31 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
for (_, _, l) in doc.walk() {
let LayerContent::Smart(so) = &l.content else { continue };
let SmartSource::Linked { path } = &so.source else { continue };
if missing.contains(path) {
continue;
}
let target = match copied.iter().find(|(src, _)| src == path) {
Some((_, t)) => t.clone(),
None => {
let mut fname = file_name(path);
if copied.iter().any(|(_, t)| file_name(t) == fname) {
fname = format!("{}-{}", copied.len(), fname);
// A name no earlier copy has, ignoring case (macOS and Windows folders do): a
// renamed copy can't take the name of another link either. A file already in
// Links (an earlier package into the same folder) is never overwritten.
let base = file_name(path);
let taken = |n: &str| {
copied.iter().any(|(_, t)| file_name(t).to_lowercase() == n.to_lowercase())
|| std::path::Path::new(&join(&links_dir, n)).exists()
|| std::fs::read_dir(&links_dir)
.ok()
.into_iter()
.flatten()
.flatten()
.any(|entry| entry.file_name().to_string_lossy().to_lowercase() == n.to_lowercase())
};
let mut fname = base.clone();
let mut k = copied.len();
while taken(&fname) {
fname = format!("{k}-{base}");
k += 1;
}
let t = join(&links_dir, &fname);
// A path that is not followed is packaged as missing, like a file that is gone.
+66
View File
@@ -463,6 +463,72 @@ fn package_copies_links_and_relinks() {
);
}
/// Links whose names collide get distinct copies, even when a renamed copy's name is already
/// another link's name (`b/2-x.png` copied before the third `x.png` would be renamed to it).
#[test]
fn package_never_overwrites_one_link_copy_with_another() {
let dir = tmp("package-collide");
let mut s = session("rgb", 8);
let mut links = Vec::new();
for (sub, name, colour) in [("a", "x.png", "#ff0000"), ("b", "2-x.png", "#00ff00"), ("c", "x.png", "#0000ff")] {
std::fs::create_dir_all(format!("{dir}/{sub}")).unwrap();
let link = format!("{dir}/{sub}/{name}");
let mut src = Session::new();
src.execute("file.new", json!({"width": 4, "height": 4, "background": colour})).unwrap();
crate::file_cmds::save_doc(&src.active().unwrap().doc, &link, None).unwrap();
s.execute("file.placeLinked", json!({"path": link})).unwrap();
links.push(std::fs::read(&link).unwrap());
}
let r = s.execute("file.package", json!({"dir": format!("{dir}/pkg")})).unwrap();
let copies: Vec<&str> = r["links"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
assert_eq!(copies.len(), 3);
let unique: std::collections::HashSet<&&str> = copies.iter().collect();
assert_eq!(unique.len(), 3, "{copies:?}");
for (copy, bytes) in copies.iter().zip(&links) {
assert_eq!(&std::fs::read(copy).unwrap(), bytes, "{copy}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A second package into the same folder copies its links beside the first one's, never over them.
#[test]
fn package_keeps_files_already_in_links() {
let dir = tmp("package-again");
let link = format!("{dir}/art.png");
let mut src = Session::new();
src.execute("file.new", json!({"width": 4, "height": 4, "background": "#ff0000"})).unwrap();
crate::file_cmds::save_doc(&src.active().unwrap().doc, &link, None).unwrap();
let mut s = session("rgb", 8);
s.execute("file.placeLinked", json!({"path": link})).unwrap();
let first = s.execute("file.package", json!({"dir": format!("{dir}/pkg")})).unwrap();
let first = first["links"][0].as_str().unwrap().to_string();
let kept = std::fs::read(&first).unwrap();
let second = s.execute("file.package", json!({"dir": format!("{dir}/pkg")})).unwrap();
let second = second["links"][0].as_str().unwrap();
assert_ne!(second, first);
assert!(second.ends_with("Links/0-art.png"), "{second}");
assert_eq!(std::fs::read(&first).unwrap(), kept);
assert_eq!(std::fs::read(second).unwrap(), kept);
let _ = std::fs::remove_dir_all(&dir);
}
/// A missing link placed twice is reported once.
#[test]
fn package_lists_a_missing_link_once() {
let dir = tmp("package-missing-once");
let link = format!("{dir}/gone.png");
let mut src = Session::new();
src.execute("file.new", json!({"width": 4, "height": 4})).unwrap();
crate::file_cmds::save_doc(&src.active().unwrap().doc, &link, None).unwrap();
let mut s = session("rgb", 8);
s.execute("file.placeLinked", json!({"path": link})).unwrap();
s.execute("file.placeLinked", json!({"path": link})).unwrap();
std::fs::remove_file(&link).unwrap();
let r = s.execute("file.package", json!({"dir": format!("{dir}/pkg")})).unwrap();
assert_eq!(r["missing"], json!([link]));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn paths_export_as_illustrator_postscript() {
let mut s = session("rgb", 8);
+19 -4
View File
@@ -55,10 +55,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let mut pattern_ids = Vec::new();
for pat in imp.patterns {
pattern_ids.push(pat.id.clone());
match s.patterns.items.iter_mut().find(|q| q.id == pat.id) {
Some(q) => *q = pat,
None => s.patterns.items.push(pat),
}
s.patterns.upsert(pat);
}
let styles = &mut s.presets.styles;
if replace {
@@ -241,6 +238,24 @@ mod tests {
assert!((f.opacity - l.opacity).abs() < 1e-4);
}
/// #9318 review: an ASL that replaces a library pattern by id marks its pixels changed, so the
/// preset store writes the replacement (see `PatternLibrary::pixels_rev`).
#[test]
fn an_asl_pattern_replacement_bumps_pixels_rev() {
let mut s = session();
let mut pat = s.patterns.items[1].clone();
pat.id = "asl-replaced-pattern".into();
let asl = |p: &photocraft_doc::Pattern| {
let style = AslStyle { name: "Pat".into(), effects: vec![], blend: None, opacity: None, fill_opacity: None };
b64_encode(&asl_map::write_asl(&[style], std::slice::from_ref(p)).unwrap())
};
s.execute(IMPORT, json!({"data": asl(&pat), "group": "A"})).unwrap();
let rev = s.patterns.pixels_rev;
pat.surface = s.patterns.items[2].surface.clone();
s.execute(IMPORT, json!({"data": asl(&pat), "group": "B"})).unwrap();
assert!(s.patterns.pixels_rev > rev, "the same-id replacement is marked");
}
#[test]
fn embedded_patterns_reach_the_library_and_the_document() {
let mut s = session();
+19 -2
View File
@@ -421,7 +421,16 @@ pub fn export_data_sets_with(s: &Session, p: &Value, mut write: impl FnMut(&str,
}
// Filename template: `{name}` (sanitised data-set name), `{index}` (1-based). Default `{name}`.
let template = p.get("naming").and_then(Value::as_str).unwrap_or("{name}");
let doc_stem = original.name.rsplit_once('.').map_or(original.name.as_str(), |(a, _)| a).to_string();
// A document name is not a path: a separator in it, or a stem of `.` / `..` before a `/` in
// the template, must not leave `dir`.
let doc_stem: String = original
.name
.rsplit_once('.')
.map_or(original.name.as_str(), |(a, _)| a)
.chars()
.map(|c| if c.is_control() || "/\\<>:\"|?*".contains(c) { '_' } else { c })
.collect();
let doc_stem = if matches!(doc_stem.as_str(), "" | "." | "..") { "_".to_string() } else { doc_stem };
// Plan the whole batch before writing: sanitised names or a constant template may collide.
// Match the case-insensitive comparison used by the other batch exporters.
let mut claimed = std::collections::HashMap::new();
@@ -429,7 +438,15 @@ pub fn export_data_sets_with(s: &Session, p: &Value, mut write: impl FnMut(&str,
for (n, set) in sets.iter().enumerate() {
let safe: String = set.name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
let stem = template.replace("{name}", &safe).replace("{index}", &format!("{:03}", n + 1)).replace("{document}", &doc_stem);
let name = format!("{stem}.{format}");
// Each folder the template makes is checked too: `{document}/{name}` of `nul.psd` would
// otherwise write into the `nul` device.
let name: String = format!("{stem}.{format}")
.split_inclusive(['/', '\\'])
.map(|part| {
let sep = ["/", "\\"].into_iter().find(|s| part.ends_with(s)).unwrap_or("");
crate::file_cmds::portable_device_name(part.strip_suffix(sep).unwrap_or(part).to_string()) + sep
})
.collect();
if let Some(first) = claimed.insert(name.to_lowercase(), &set.name) {
return Err(bad(
"file.export.dataSetsAsFiles",
+33
View File
@@ -421,6 +421,39 @@ fn export_data_sets_rejects_collisions_before_writing() {
}
}
#[test]
fn export_data_sets_keeps_document_derived_names_inside_the_folder() {
// A document name is not a path: `{document}` must not climb out of `dir` or into a subfolder,
// and a data set named after a Windows device gets the `_` every other exporter gives it.
let root = tempfile::tempdir().unwrap();
let root = root.path().to_string_lossy().into_owned();
let dir = format!("{root}/out");
let mut s = visibility_export_session(&["nul", "b"]);
s.execute("image.duplicate", json!({"name": "../esc/aped.psd"})).unwrap();
let r = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "naming": "{document}_{index}"})).unwrap();
assert_eq!(r["files"], json!([format!("{dir}/.._esc_aped_001.png"), format!("{dir}/.._esc_aped_002.png")]));
let r = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "dataSets": ["nul"]})).unwrap();
assert_eq!(r["files"], json!([format!("{dir}/nul_.png")]));
// `...` has the stem `..`: before a folder in the caller's template it would climb out too.
s.execute("image.duplicate", json!({"name": "..."})).unwrap();
let r = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "dataSets": ["b"], "naming": "{document}/{name}"})).unwrap();
assert_eq!(r["files"], json!([format!("{dir}/_/b.png")]));
// `.` has an empty stem: `{document}` alone would be a nameless `.png`.
s.execute("image.duplicate", json!({"name": "."})).unwrap();
let r = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "dataSets": ["b"], "naming": "{document}"})).unwrap();
assert_eq!(r["files"], json!([format!("{dir}/_.png")]));
// A folder the template makes from a device-named document gets the `_` as well.
s.execute("image.duplicate", json!({"name": "nul.psd"})).unwrap();
let r = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "dataSets": ["b"], "naming": "{document}/{name}"})).unwrap();
assert_eq!(r["files"], json!([format!("{dir}/nul_/b.png")]));
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
written.sort();
assert_eq!(written, [".._esc_aped_001.png", ".._esc_aped_002.png", "_", "_.png", "nul_", "nul_.png"]);
assert!(std::path::Path::new(&format!("{dir}/nul_/b.png")).is_file());
assert!(!std::path::Path::new(&format!("{root}/esc")).exists());
assert!(!std::path::Path::new(&format!("{root}/b.png")).exists());
}
#[test]
fn export_data_sets_unique_paths_preserve_variations_and_document() {
for (label, names, naming, selected, expected) in [
+1 -1
View File
@@ -1491,12 +1491,12 @@ pub fn generate_assets(doc: &Document, dir: &str) -> (Vec<String>, Vec<Value>) {
(files, errors)
}
/// `<document folder>/<document name>-assets`.
/// Whether any pixel of `doc` is not fully transparent.
fn has_pixels(doc: &Document) -> bool {
photocraft_compose::render(doc, doc.bounds()).px.iter().any(|q| q[3] > 0.0)
}
/// `<document folder>/<document name>-assets`.
pub fn assets_dir(doc_path: &str) -> String {
let p = std::path::Path::new(doc_path);
let dir = p.parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default();
+16 -3
View File
@@ -126,10 +126,17 @@ pub fn read_entry(file: &mut impl std::io::Read, len: u64, max: usize, path: &st
/// The saver checks the same bound, so every bundle it writes loads again.
const MAX_MANIFEST_DEPTH: usize = 3 * (convert::MAX_GROUP_DEPTH + 1) + 64;
/// Rejects JSON nested deeper than [`MAX_MANIFEST_DEPTH`] with a linear scan (no recursion), so
/// parsing with serde_json's recursion limit disabled can't exhaust the stack on hostile input.
/// Most JSON items (arrays and objects opened, plus elements and members after a comma) a
/// manifest may hold. Parsing builds a `serde_json::Value` tree of tens of bytes per item, so
/// `[0,0,…]` costs about 16× its size in memory; a typical manifest spends 20 bytes or more per
/// item, so this still admits manifests of about 80 MiB.
const MAX_MANIFEST_ITEMS: usize = 4 << 20;
/// Rejects JSON nested deeper than [`MAX_MANIFEST_DEPTH`] or holding more than
/// [`MAX_MANIFEST_ITEMS`] items with a linear scan (no recursion), so parsing with serde_json's
/// recursion limit disabled can't exhaust the stack, nor tiny values the memory, on hostile input.
fn check_manifest_depth(json: &[u8]) -> Result<()> {
let (mut depth, mut in_str, mut escaped) = (0usize, false, false);
let (mut depth, mut items, mut in_str, mut escaped) = (0usize, 0usize, false, false);
for &b in json {
if in_str {
match b {
@@ -151,6 +158,12 @@ fn check_manifest_depth(json: &[u8]) -> Result<()> {
b']' | b'}' => depth = depth.saturating_sub(1),
_ => {}
}
if matches!(b, b'[' | b'{' | b',') {
items += 1;
if items > MAX_MANIFEST_ITEMS {
return Err(FormatError::LimitExceeded(format!("manifest JSON holds more than {MAX_MANIFEST_ITEMS} values")));
}
}
}
Ok(())
}
+18
View File
@@ -128,6 +128,24 @@ fn over_deep_manifest_fails_cleanly() {
assert!(matches!(e, FormatError::Corrupt(_)), "{e}");
}
/// A manifest of millions of tiny values (`[0,0,…]` deflates about 1000:1) is refused before
/// it is parsed into a JSON tree, which would cost about 16 bytes of memory per input byte.
#[test]
fn manifest_with_too_many_values_is_refused() {
let n = (4 << 20) + 16;
let mut m = Vec::with_capacity(2 * n + 1);
m.push(b'[');
for i in 0..n {
m.extend_from_slice(if i == 0 { b"0" } else { b",0" });
}
m.push(b']');
let b = rebuild(&sample(), |name, d| Some(if name == "manifest.json" { m.clone() } else { d }));
let e = load_from_bytes(&b).unwrap_err();
assert!(matches!(e, FormatError::LimitExceeded(_)), "{e}");
let e = read_manifest(&b).unwrap_err();
assert!(matches!(e, FormatError::LimitExceeded(_)), "{e}");
}
#[test]
fn too_new_version_rejected() {
let b = rebuild(&sample(), |n, d| {
+17 -4
View File
@@ -211,16 +211,29 @@ impl DeviceHealth {
/// wait).
#[cfg(not(target_arch = "wasm32"))]
pub fn wait(&self, device: &wgpu::Device, index: Option<wgpu::SubmissionIndex>) -> bool {
self.wait_idle(device, index);
self.is_ok()
}
/// Like [`Self::wait`], but returns whether the wait completed: `false` also when the poll
/// itself failed, so the work may still be running.
#[cfg(not(target_arch = "wasm32"))]
pub fn wait_idle(&self, device: &wgpu::Device, index: Option<wgpu::SubmissionIndex>) -> bool {
if !self.is_ok() {
return false;
}
let poll = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| device.poll(wgpu::PollType::Wait { submission_index: index, timeout: None })));
match poll {
Ok(Ok(_)) => {}
Ok(Err(e)) => log::warn!("wgpu poll: {e}"),
Err(_) => self.lost_by_poll(),
Ok(Ok(_)) => self.is_ok(),
Ok(Err(e)) => {
log::warn!("wgpu poll: {e}");
false
}
Err(_) => {
self.lost_by_poll();
false
}
}
self.is_ok()
}
/// Non-blocking poll (reclaims staging memory), skipped once the device is unusable.
+25 -7
View File
@@ -493,6 +493,9 @@ pub struct Compositor {
layer_alloc: Option<String>,
/// Bytes of pages dropped to admit new ones, not yet counted towards an intermediate submit.
admit_freed: u64,
/// Test hook ([`Compositor::fail_layer_allocs`]): how many more layer page textures fail as
/// out of memory.
fail_allocs: u32,
acc_format: wgpu::TextureFormat,
/// Whether the effect-map pipelines could be built (else documents with layer effects are
/// [`Unsupported`] and use the CPU compositor).
@@ -807,6 +810,7 @@ impl Compositor {
recorded: 0,
layer_alloc: None,
admit_freed: 0,
fail_allocs: 0,
acc_format,
effect_maps: map_error.is_none(),
cmyk: 0,
@@ -847,6 +851,13 @@ impl Compositor {
self.page
}
/// Test hook: the next `n` layer page textures fail as if the device were out of memory
/// (a real one can't be forced), so the fallback paths of #6563 can be tested.
#[doc(hidden)]
pub fn fail_layer_allocs(&mut self, n: u32) {
self.fail_allocs = n;
}
/// Set the GPU memory resident layer pages may hold (default [`RESIDENT_BUDGET`]), exactly:
/// nothing is lent to it ([`Self::fit_layers`]) until the next [`Self::set_memory_budget`].
pub fn set_resident_budget(&mut self, bytes: u64) {
@@ -1358,12 +1369,15 @@ impl Compositor {
self.layer_alloc = None;
submit_batch(device, queue, encoder, self.health.as_ref(), self.timer.as_mut(), &mut last_submit);
#[cfg(not(target_arch = "wasm32"))]
match self.health.as_ref() {
Some(h) => {
h.wait(device, last_submit.clone());
}
None => {
let _ = device.poll(wgpu::PollType::Wait { submission_index: last_submit.clone(), timeout: None });
{
let idle = match self.health.as_ref() {
Some(h) => h.wait_idle(device, last_submit.clone()),
None => device.poll(wgpu::PollType::Wait { submission_index: last_submit.clone(), timeout: None }).is_ok(),
};
// The GPU has run every timestamp submitted so far, so a retry that fails too
// can drop this measurement without switching timing off for good.
if idle && let Some(t) = self.timer.as_mut() {
t.drained();
}
}
self.staged = 0;
@@ -1506,7 +1520,11 @@ impl Compositor {
return Some(device.create_texture(desc));
}
for retry in [false, true] {
let (texture, error) = first_error(device, || device.create_texture(desc));
let (texture, mut error) = first_error(device, || device.create_texture(desc));
if error.is_none() && self.fail_allocs > 0 {
self.fail_allocs -= 1;
error = Some(wgpu::Error::OutOfMemory { source: "out of memory (test hook)".into() });
}
match error {
None => return Some(texture),
Some(wgpu::Error::OutOfMemory { .. }) if !retry => {
+9 -2
View File
@@ -95,8 +95,9 @@ pub struct GpuTimer {
/// A recording was abandoned after it had already submitted timestamps. Writing a query
/// again resets that slot (wgpu-core resets per write), and resetting one an in-flight
/// submission is still writing is undefined, with no way to learn when it has drained.
/// That only happens when a refresh fails after a flush, which on this path means the
/// device faulted and the canvas is about to drop to the CPU compositor anyway, so
/// That only happens when a refresh fails after a flush that nothing waited for (a refresh
/// whose page retry waited for the GPU calls [`Self::drained`] first), which is rare: usually
/// the device faulted and the canvas is about to drop to the CPU compositor anyway. So
/// measuring simply stops: no number, which is the contract everywhere else here.
stopped: bool,
/// Timestamps and pixels of the measurement being read back, and its readback flag.
@@ -236,6 +237,12 @@ impl GpuTimer {
self.flushed = false;
}
/// The caller waited until the GPU finished everything submitted so far: no timestamp of
/// this recording is still in flight, so [`Self::cancel`] can reuse its slots safely.
pub fn drained(&mut self) {
self.flushed = false;
}
/// Whether a measurement is being read back (so this refresh won't be measured).
pub fn busy(&self) -> bool {
self.in_flight.is_some()
+53
View File
@@ -2915,6 +2915,59 @@ fn layer_pages_are_admitted_under_a_zero_budget_and_stay_on_the_gpu() {
check(&mut g, &d, "after zero-budget renders");
}
/// A layer page texture the device has no memory for (#6563): the compositor drops the pages
/// this cell doesn't use and tries again, then submits, waits and runs the cell once more, and
/// only then ends this refresh on the CPU. The device stays healthy and the next refresh is back
/// on the GPU. The test hook makes the next `n` page textures fail as out of memory.
#[test]
fn a_layer_page_out_of_memory_retries_then_ends_only_that_refresh_on_the_cpu() {
let Some(mut g) = gpu() else { return };
let mut d = base_doc(600, 500);
d.layers.push(noise_layer("mid", PixelFormat::RGBA8, Rect::new(-20, 10, 590, 480), 71, 0.0));
let mut top = noise_layer("top", PixelFormat::RGBA8, Rect::new(30, -15, 620, 510), 72, 0.2);
top.blend = BlendMode::Multiply;
d.layers.push(top);
g.paged.set_composite_cache_budget(0);
let cpu = photocraft_compose::flatten(&d);
let encode = |g: &mut Gpu, d: &Document| {
let mut encoder = g.device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
g.paged.encode(&g.device, &g.queue, &mut encoder, d, d.bounds(), &mut |_, _| {})
};
let render = |g: &mut Gpu, d: &Document| photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, d, d.bounds());
let matches = |out: &[[f32; 4]], what: &str| {
let worst = worst_diff(&cpu.px, out);
assert!(worst.0 <= TOL, "{what}: max diff {:.2}/255 at {}", worst.0 * 255.0, worst.1);
};
// Every case starts with no pages, so the first page texture is the one that fails.
let fresh = |g: &mut Gpu, d: &Document, n: u32| {
g.paged.forget_doc(d.id);
g.paged.fail_layer_allocs(n);
};
// One failure: the second try right away succeeds (`encode` can't submit, so this is the
// only retry it has).
fresh(&mut g, &d, 1);
encode(&mut g, &d).unwrap_or_else(|e| panic!("one failed page texture is retried at once: {}", e.0));
// Two: both tries fail, and `encode` gives the refresh to the CPU.
fresh(&mut g, &d, 2);
let e = encode(&mut g, &d).expect_err("encode can't submit to free memory");
assert_eq!(e.0, photocraft_gpu::OVER_BUDGET);
assert!(g.paged.fault().is_none(), "out of memory for a page doesn't lose the device");
// `render` submits, waits and runs the cell once more, which then succeeds.
fresh(&mut g, &d, 2);
let (out, s) = render(&mut g, &d).unwrap_or_else(|e| panic!("render retries after submitting: {}", e.0));
assert!(s.flushes > 0, "{s:?}");
matches(&out, "after the submit-and-retry");
// Four: the retry after the submit fails as well, so this refresh goes to the CPU...
fresh(&mut g, &d, 4);
let e = render(&mut g, &d).expect_err("every try failed");
assert_eq!(e.0, photocraft_gpu::OVER_BUDGET);
assert!(g.paged.fault().is_none(), "out of memory for a page doesn't lose the device");
// ...and the next one is back on the GPU.
let (out, _) = render(&mut g, &d).unwrap_or_else(|e| panic!("the refresh after an out-of-memory one is on the GPU: {}", e.0));
matches(&out, "the refresh after the CPU one");
}
#[test]
fn the_focused_view_stays_resident_after_a_full_refresh() {
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
+22
View File
@@ -287,3 +287,25 @@ fn a_failed_refresh_that_recorded_nothing_keeps_the_timer_alive() {
let after = after.expect("the timer still measures after a refresh that recorded nothing");
assert_eq!(after.px, 4096, "the measurement belongs to the refresh that produced it");
}
/// A refresh whose layer pages ran out of GPU memory even after the submit-and-retry ends on the
/// CPU (#6563), but measuring goes on: the retry waited for the GPU, so none of the dropped
/// measurement's timestamps were still in flight.
#[test]
fn an_out_of_memory_refresh_does_not_stop_gpu_timing() {
let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some((device, queue, mut comp)) = gpu() else { return };
let doc = document(Size::new(640, 480));
let full = doc.bounds();
// The first page texture fails twice, and twice more in the retry after the submit.
comp.fail_layer_allocs(4);
assert!(comp.render(&device, &queue, &doc, full, |_, _| {}).is_err(), "every try failed");
let mut timing = None;
for _ in 0..4 {
timing = measure(&device, &queue, &mut comp, &doc, full).2;
if timing.is_some() {
break;
}
}
assert!(timing.is_some(), "GPU timing stopped for good after one out-of-memory refresh");
}
+16
View File
@@ -352,6 +352,22 @@ fn krita_soft_light_ids_map_to_photoshop_soft_light() {
assert!(!all.contains("soft_light_svg"), "{all}");
}
#[test]
fn soft_light_exports_as_svg_soft_light() {
// The W3C id, not krita:soft_light_svg (which import also accepts): other applications read it.
let mut d = doc(SampleType::U8);
let mut soft = block("Soft", PixelFormat::RGBA8, 0, 0, 10, 10, 5);
soft.blend = BlendMode::SoftLight;
d.layers.push(soft);
let out = export(&d).unwrap();
let xml = String::from_utf8(ZipReader::new(&out.bytes).unwrap().read_by_name("stack.xml", 1 << 20).unwrap()).unwrap();
assert!(xml.contains("name=\"Soft\"") && xml.contains("composite-op=\"svg:soft-light\""), "{xml}");
assert!(!xml.contains("soft_light"), "{xml}");
let (back, exported, imported) = round_trip(&d);
assert!(back.layers.iter().any(|l| l.name == "Soft" && l.blend == BlendMode::SoftLight));
assert!(!exported.iter().chain(&imported).any(|w| w.to_lowercase().contains("soft")), "{exported:?} {imported:?}");
}
#[test]
fn preserves_surrounding_whitespace_in_layer_and_group_names() {
let xml = r#"<image w="2" h="2"><stack>
+229
View File
@@ -203,6 +203,86 @@ fn system_generic_family(_generic: &str) -> Option<String> {
None
}
/// Most font file bytes [`in_memory`] keeps; past it a face stays on disk (slow, still right).
#[cfg(not(target_arch = "wasm32"))]
const MAX_CACHED_FONT_BYTES: usize = 256 << 20;
/// `id`, or the same face loaded from memory when the database names a file for it. fontdb
/// re-reads a system font's whole file for every glyph usvg outlines (about a millisecond a
/// character with Times New Roman), so a short entity-expanded text could keep an import busy
/// for minutes. Each file is read once per process; the parse's own database copy then holds
/// every face of that file from memory.
#[cfg(not(target_arch = "wasm32"))]
fn in_memory(id: usvg::fontdb::ID, db: &mut Arc<usvg::fontdb::Database>) -> usvg::fontdb::ID {
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Mutex, PoisonError};
use usvg::fontdb::Source;
type Files = (HashMap<PathBuf, Arc<Vec<u8>>>, usize);
static FILES: OnceLock<Mutex<Files>> = OnceLock::new();
static LOADING: Mutex<()> = Mutex::new(());
let Some((Source::File(path), index)) = db.face_source(id) else { return id };
let files = || FILES.get_or_init(Mutex::default).lock().unwrap_or_else(PoisonError::into_inner);
let fits = |files: &Files, len: usize| files.1.saturating_add(len) <= MAX_CACHED_FONT_BYTES;
let cached = files().0.get(&path).cloned();
// One miss at a time, so imports wanting the same file read it once: a second one waits
// here, then takes the bytes the first one kept.
let loading = cached.is_none().then(|| LOADING.lock().unwrap_or_else(PoisonError::into_inner));
let cached = cached.or_else(|| files().0.get(&path).cloned());
let bytes = match cached {
Some(b) => b,
None => {
// The size first: a file past the budget isn't read at all, on this lookup or the next.
let Ok(len) = std::fs::metadata(&path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)) else { return id };
if !fits(&files(), len) {
return id;
}
let Ok(b) = std::fs::read(&path) else { return id };
let b = Arc::new(b);
// The face has to load from the bytes before they're kept or the file's faces leave
// the database: a file replaced or cut short since fontdb indexed it stays on disk.
let mut probe = usvg::fontdb::Database::new();
let ids = probe.load_font_source(Source::Binary(b.clone()));
if !ids.iter().any(|&n| probe.face_source(n).is_some_and(|(_, i)| i == index)) {
return id;
}
let mut files = files();
if !fits(&files, b.len()) {
return id;
}
files.1 = files.1.saturating_add(b.len());
files.0.insert(path.clone(), b.clone());
b
}
};
drop(loading);
let db = Arc::make_mut(db);
let same_file: Vec<_> = db.faces().filter(|f| matches!(&f.source, Source::File(p) if *p == path)).map(|f| f.id).collect();
// Last first: fontdb's slots are reused last-freed-first, so the file's faces come back in
// their old places and order, and the fallback search (first face with the character)
// still finds the face it finds on disk.
for f in same_file.into_iter().rev() {
db.remove_face(f);
}
let loaded = db.load_font_source(Source::Binary(bytes));
loaded.into_iter().find(|&n| db.face_source(n).is_some_and(|(_, i)| i == index)).unwrap_or(id)
}
/// usvg's font choice, with the chosen faces read from memory ([`in_memory`]).
fn font_resolver() -> usvg::FontResolver<'static> {
#[cfg(not(target_arch = "wasm32"))]
{
let select = usvg::FontResolver::default_font_selector();
let fallback = usvg::FontResolver::default_fallback_selector();
usvg::FontResolver {
select_font: Box::new(move |font, db| select(font, db).map(|id| in_memory(id, db))),
select_fallback: Box::new(move |c, used, db| fallback(c, used, db).map(|id| in_memory(id, db))),
}
}
#[cfg(target_arch = "wasm32")]
usvg::FontResolver::default()
}
/// Parse options for an untrusted drawing. `<image href>` resolves only `data:` URLs: a file path
/// or relative href is never read, so opening an SVG cannot pull local files (`/etc/...`,
/// `~/.ssh/...`) into the document. Embedded SVG images get no `<image>` resolution at all
@@ -239,6 +319,7 @@ fn options(fontdb: Arc<usvg::fontdb::Database>) -> usvg::Options<'static> {
resources_dir: None,
image_href_resolver: usvg::ImageHrefResolver { resolve_data, resolve_string: Box::new(|_, _| None) },
fontdb,
font_resolver: font_resolver(),
..usvg::Options::default()
}
}
@@ -1092,6 +1173,154 @@ mod tests {
}
}
/// #9406: a font installed on disk (how the system fonts load) is read once, not once per
/// glyph: fontdb re-reads a file source's whole font on every glyph it outlines, about a
/// millisecond a character. The face text is set in comes from memory, and the outlines
/// match the same font loaded from memory in the first place.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn text_in_a_font_on_disk_is_set_from_memory() {
fn inter_on_disk() -> Arc<usvg::fontdb::Database> {
let mut db = usvg::fontdb::Database::new();
let _ = db.load_font_file(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/Inter-Regular.ttf"));
Arc::new(db)
}
assert!(inter_on_disk().faces().all(|f| matches!(f.source, usvg::fontdb::Source::File(_))));
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"60\"><text x=\"5\" y=\"45\" font-size=\"48\" font-family=\"Inter\">IHI</text></svg>";
let parsed = parse_with(svg, inter_on_disk).unwrap();
let faces: Vec<_> = parsed.tree.fontdb().faces().map(|f| matches!(f.source, usvg::fontdb::Source::Binary(_))).collect();
assert_eq!(faces, [true], "the face text was set in is held in memory");
let on_disk = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap();
let in_memory = import_parsed("t", parse_with(svg, inter_only).unwrap(), MAX_GROUP_DEPTH).unwrap();
assert_eq!(on_disk.document.layers.first().map(|l| l.name.as_str()), Some("IHI"));
let outlines = |r: &ImportResult| {
let groups = r.document.layers.iter().filter_map(|l| if let LayerContent::Group(g) = &l.content { Some(g) } else { None });
groups
.flat_map(|g| &g.children)
.filter_map(|l| if let LayerContent::Shape(s) = &l.content { Some(s.path.clone()) } else { None })
.collect::<Vec<_>>()
};
assert!(outlines(&on_disk).iter().any(|p| !p.subpaths.is_empty()), "the text has outlines");
assert_eq!(outlines(&on_disk), outlines(&in_memory));
}
/// A font file that no longer loads (replaced or cut short since fontdb indexed it) keeps its
/// faces in the database and isn't remembered: once the file is whole again, text is set
/// from it.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_font_file_gone_bad_since_indexing_is_not_cached() {
use std::sync::atomic::{AtomicBool, Ordering};
static BAD: AtomicBool = AtomicBool::new(true);
fn indexed_then_overwritten() -> Arc<usvg::fontdb::Database> {
let path = std::env::temp_dir().join(format!("photocraft-svg-test-bad-{}.ttf", std::process::id()));
std::fs::write(&path, include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap();
let mut db = usvg::fontdb::Database::new();
db.load_font_file(&path).unwrap();
if BAD.load(Ordering::SeqCst) {
std::fs::write(&path, b"not a font any more").unwrap();
}
Arc::new(db)
}
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"60\"><text x=\"5\" y=\"45\" font-family=\"Inter\">IHI</text></svg>";
let parsed = parse_with(svg, indexed_then_overwritten).unwrap();
let sources: Vec<_> = parsed.tree.fontdb().faces().map(|f| matches!(f.source, usvg::fontdb::Source::File(_))).collect();
assert_eq!(sources, [true], "the face stays in the database, on disk");
BAD.store(false, Ordering::SeqCst);
let parsed = parse_with(svg, indexed_then_overwritten).unwrap();
let sources: Vec<_> = parsed.tree.fontdb().faces().map(|f| matches!(f.source, usvg::fontdb::Source::Binary(_))).collect();
assert_eq!(sources, [true], "the whole file is read into memory now");
let layers = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap().document.layers;
assert_eq!(layers.first().map(|l| l.name.as_str()), Some("IHI"));
assert!(
layers.iter().any(|l| matches!(&l.content, LayerContent::Group(g) if g.children.iter().any(|c| matches!(&c.content, LayerContent::Shape(s) if !s.path.subpaths.is_empty())))),
"the text has outlines"
);
}
/// Imports that miss the cache for the same file at once read it once: every one sets its
/// text from the same bytes, not a copy of its own next to the kept one.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn imports_missing_the_same_font_at_once_share_one_read() {
use std::sync::atomic::{AtomicUsize, Ordering};
static ROUND: AtomicUsize = AtomicUsize::new(0);
fn fresh_file() -> Arc<usvg::fontdb::Database> {
let round = ROUND.load(Ordering::SeqCst);
let path = std::env::temp_dir().join(format!("photocraft-svg-test-shared-{}-{round}.ttf", std::process::id()));
let mut db = usvg::fontdb::Database::new();
db.load_font_file(&path).unwrap();
Arc::new(db)
}
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"60\"><text x=\"5\" y=\"45\" font-family=\"Inter\">IHI</text></svg>";
for round in 0..5 {
ROUND.store(round, Ordering::SeqCst);
let path = std::env::temp_dir().join(format!("photocraft-svg-test-shared-{}-{round}.ttf", std::process::id()));
std::fs::write(&path, include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap();
let start = Arc::new(std::sync::Barrier::new(8));
let threads: Vec<_> = (0..8)
.map(|_| {
let start = start.clone();
std::thread::spawn(move || {
start.wait();
let parsed = parse_with(svg, fresh_file).unwrap();
let bytes: Vec<_> = parsed
.tree
.fontdb()
.faces()
.filter_map(|f| if let usvg::fontdb::Source::Binary(b) = &f.source { Some(Arc::as_ptr(b).cast::<u8>() as usize) } else { None })
.collect();
bytes
})
})
.collect();
let bytes: Vec<_> = threads.into_iter().flat_map(|t| t.join().unwrap()).collect();
assert_eq!(bytes.len(), 8, "every import set its text from memory");
assert!(bytes.iter().all(|&b| b == bytes[0]), "round {round}: {bytes:x?}");
let _ = std::fs::remove_file(&path);
}
}
/// #9406: moving a font collection into memory keeps its faces in their order, so the
/// fallback search for a later text still finds the face it found on disk first.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_collection_in_memory_keeps_its_face_order() {
fn collection_on_disk() -> Arc<usvg::fontdb::Database> {
// A two-face .ttc of the bundled Inter Regular and SemiBold: the `ttcf` header, then
// each font with its table offsets moved by where it starts.
let fonts: [&[u8]; 2] = [include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")];
let mut ttc = b"ttcf".to_vec();
let mut start = 20;
let mut body = Vec::new();
for f in fonts {
ttc.extend_from_slice(&u32::try_from(start).unwrap().to_be_bytes());
let mut font = f.to_vec();
for t in 0..usize::from(u16::from_be_bytes([font[4], font[5]])) {
let at = 12 + t * 16 + 8;
let off = u32::from_be_bytes(font[at..at + 4].try_into().unwrap()) + u32::try_from(start).unwrap();
font[at..at + 4].copy_from_slice(&off.to_be_bytes());
}
font.resize(font.len().next_multiple_of(4), 0);
start += font.len();
body.extend(font);
}
ttc.extend(body);
let path = std::env::temp_dir().join(format!("photocraft-svg-test-{}.ttc", std::process::id()));
std::fs::write(&path, ttc).unwrap();
let mut db = usvg::fontdb::Database::new();
db.load_font_file(&path).unwrap();
Arc::new(db)
}
let order = |db: &usvg::fontdb::Database| db.faces().map(|f| (f.index, f.weight.0)).collect::<Vec<_>>();
assert_eq!(order(&collection_on_disk()), [(0, 400), (1, 600)]);
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"60\"><text x=\"5\" y=\"45\" font-family=\"Inter\">IHI</text></svg>";
let parsed = parse_with(svg, collection_on_disk).unwrap();
let db = parsed.tree.fontdb();
assert!(db.faces().all(|f| matches!(f.source, usvg::fontdb::Source::Binary(_))), "both faces of the file are in memory");
assert_eq!(order(db), [(0, 400), (1, 600)]);
}
#[test]
fn generic_families_point_at_installed_fonts() {
use usvg::fontdb::Family;
+1
View File
@@ -59,6 +59,7 @@ codec_rt!(dpx_rgba16, "dpx", ColorMode::Rgb, SampleType::U16, true, 0.0);
// Cineon stores 10-bit code values: within half a 10-bit step.
codec_rt!(cin_gray16, "cin", ColorMode::Grayscale, SampleType::U16, false, 0.5 / 1023.0);
codec_rt!(cin_rgb16, "cin", ColorMode::Rgb, SampleType::U16, false, 0.5 / 1023.0);
codec_rt!(iff_rgb8_opaque, "iff", ColorMode::Rgb, SampleType::U8, false, 0.0);
fn indexed_bmp_8() -> Vec<u8> {
const FILE_HEADER_SIZE: usize = 14;
+26 -1
View File
@@ -316,10 +316,16 @@ fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> std::result::Result<Self::Value, A::Error> {
let mut out = Vec::new();
while let Some((k, v)) = map.next_entry::<String, ParamSpec>()? {
while let Some(k) = map.next_key::<String>()? {
// A repeated key would give one value two dialog controls, the last spec winning.
// Named before its spec is read or counted, so the error is about the key.
if out.iter().any(|(seen, _)| *seen == k) {
return Err(serde::de::Error::custom(format!("parameter {k:?} is declared twice")));
}
if out.len() >= MAX_PARAMS {
return Err(serde::de::Error::custom(format!("too many parameters (limit {MAX_PARAMS})")));
}
let v = map.next_value::<ParamSpec>()?;
out.push((k, v));
}
Ok(out)
@@ -486,6 +492,25 @@ mod tests {
assert!(Manifest::parse(&vec![b' '; MAX_MANIFEST_BYTES + 1]).is_err());
}
#[test]
fn rejects_a_parameter_declared_twice() {
// Both entries were kept: two dialog controls for one value, and the last spec won.
let twice = br#"{"id":"a","name":"x","kind":"filter",
"params":{"n":{"type":"bool"},"n":{"type":"number","min":0,"max":1,"default":0}}}"#;
assert!(matches!(Manifest::parse(twice), Err(Error::Manifest(_))));
// The error names the key, also when the second spec is broken or past the limit.
let broken = br#"{"id":"a","name":"x","kind":"filter","params":{"n":{"type":"bool"},"n":{"type":"nope"}}}"#;
let many: Vec<String> = (0..MAX_PARAMS).map(|i| format!(r#""p{i}":{{"type":"bool"}}"#)).collect();
let past = format!(r#"{{"id":"a","name":"x","kind":"filter","params":{{{},"p0":{{"type":"bool"}}}}}}"#, many.join(","));
for (json, key) in [(&broken[..], "n"), (past.as_bytes(), "p0")] {
let e = Manifest::parse(json).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains(&format!("parameter {key:?} is declared twice")), "{e}");
}
// Keys differing in case are different parameters.
let m = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","params":{"n":{"type":"bool"},"N":{"type":"bool"}}}"#).unwrap();
assert_eq!(m.params_notation(), r#"{"n":bool=false,"N":bool=false}"#);
}
/// Chromatic Aberration-like: a pixel shift, a pixel int, a signed pixel offset and a unitless
/// `size` (a name the built-in filters treat as pixels).
const PIXELS: &str = r#"{"id":"org.example.fringe","name":"Fringe","kind":"filter",
+37 -2
View File
@@ -391,6 +391,10 @@ fn write_pattern(p: &PsdPattern, out: &mut Vec<u8>) -> Result<()> {
out.extend_from_slice(&(p.height as u16).to_be_bytes());
out.extend_from_slice(&(p.width as u16).to_be_bytes());
let units: Vec<u16> = p.name.encode_utf16().chain(std::iter::once(0)).collect();
// The reader refuses longer names (see `read_pattern`).
if units.len() > 65_536 {
return Err(PsdError::LimitExceeded("pattern name length"));
}
out.extend_from_slice(&(units.len() as u32).to_be_bytes());
for u in units {
out.extend_from_slice(&u.to_be_bytes());
@@ -533,6 +537,9 @@ pub fn block_key(depth: u16) -> [u8; 4] {
}
}
/// The most patterns a `.pat` file may hold: more are refused on read and on write.
pub const MAX_PAT_PATTERNS: usize = 100_000;
/// Parses a `.pat` pattern file.
pub fn parse_pat_file(data: &[u8]) -> Result<Vec<PsdPattern>> {
let mut r = Rd { b: data, p: 0 };
@@ -541,10 +548,13 @@ pub fn parse_pat_file(data: &[u8]) -> Result<Vec<PsdPattern>> {
return Err(PsdError::InvalidSignature { expected: "8BPT", found: [sig[0], sig[1], sig[2], sig[3]] });
}
let _version = r.u16()?;
let count = r.u32()?;
let count = r.u32()? as usize;
if count > MAX_PAT_PATTERNS {
return Err(PsdError::LimitExceeded("pattern count"));
}
let mut out = Vec::new();
let mut budget = PatternBudget::default();
for _ in 0..count.min(100_000) {
for _ in 0..count {
out.push(read_pattern(&mut r, &mut budget)?);
}
Ok(out)
@@ -552,6 +562,9 @@ pub fn parse_pat_file(data: &[u8]) -> Result<Vec<PsdPattern>> {
/// Writes a `.pat` pattern file.
pub fn write_pat_file(patterns: &[PsdPattern]) -> Result<Vec<u8>> {
if patterns.len() > MAX_PAT_PATTERNS {
return Err(PsdError::LimitExceeded("pattern count"));
}
let mut out = b"8BPT".to_vec();
out.extend_from_slice(&1u16.to_be_bytes());
out.extend_from_slice(&(patterns.len() as u32).to_be_bytes());
@@ -582,6 +595,28 @@ mod tests {
}
}
/// #9318 review: a name the reader refuses (more than 65,536 UTF-16 units with its NUL) is
/// refused on write, so a saved library can't become a file that loads nothing.
#[test]
fn a_name_the_reader_refuses_is_refused_on_write() {
let long = PsdPattern { name: "a".repeat(65_536), ..pat(8, false) };
assert!(matches!(write_pat_file(&[long]), Err(PsdError::LimitExceeded(_))));
let longest = PsdPattern { name: "a".repeat(65_535), ..pat(8, false) };
assert_eq!(parse_pat_file(&write_pat_file(std::slice::from_ref(&longest)).unwrap()).unwrap(), [longest]);
}
/// #9318 review: more patterns than a `.pat` holds are refused on write, and a file claiming
/// more is refused on read rather than silently cut to the first 100,000.
#[test]
fn more_patterns_than_a_pat_file_holds_are_refused() {
let many = vec![pat(8, false); MAX_PAT_PATTERNS + 1];
assert!(matches!(write_pat_file(&many), Err(PsdError::LimitExceeded(_))));
let mut header = b"8BPT".to_vec();
header.extend_from_slice(&1u16.to_be_bytes());
header.extend_from_slice(&(MAX_PAT_PATTERNS as u32 + 1).to_be_bytes());
assert!(matches!(parse_pat_file(&header), Err(PsdError::LimitExceeded(_))));
}
#[test]
fn block_and_pat_file_round_trip_at_every_depth() {
for depth in [8, 16, 32] {
+59 -6
View File
@@ -144,6 +144,25 @@ fn point_on(h: &H, id: LayerId) -> Option<[f64; 2]> {
None
}
/// Preserve current Smart Objects and restore only legacy pixel fallbacks in the fixture.
fn restore_layout_smart_objects(s: &mut photocraft_engine::Session) -> Result<(), photocraft_engine::EngineError> {
let st = s.active().ok_or(photocraft_engine::EngineError::NoDocument)?;
let ids: Vec<_> = st
.doc
.walk()
.iter()
.filter(|(_, _, l)| {
(l.name == "Logo" || (l.name.starts_with("Product ") && !l.name.contains('—') && !l.is_group())) && !matches!(&l.content, LayerContent::Smart(_))
})
.map(|(_, _, l)| l.id.0)
.collect();
for id in ids {
s.execute("layer.select", json!({"layer": id}))?;
s.execute("layer.smartObjects.convertToSmartObject", json!({"layer": id}))?;
}
Ok(())
}
fn main() {
reexec_with_gpu_sync();
let args: Vec<String> = std::env::args().collect();
@@ -165,14 +184,13 @@ fn main() {
let t = Instant::now();
let r = photocraft_io::import("layout.psd", &psd).expect("psd import");
println!("PSD: {:.1} MB, save {t_save:.0} ms, open {:.0} ms ({} warnings)", psd.len() as f64 / 1e6, ms(t), r.warnings.len());
// PSD export writes engine-made smart objects as pixels (no SoLd yet): convert them back,
// as a Photoshop-made file would have them.
// Current PSDs preserve Smart Objects. Restore only raster fallbacks from older
// writers, selecting each target before the command checks its enabled state.
let mut s = photocraft_engine::Session::new();
s.open_document(r.document, None);
let smart = |l: &photocraft_doc::Layer| l.name == "Logo" || (l.name.starts_with("Product ") && !l.name.contains('—') && !l.is_group());
let ids: Vec<u64> = s.active().map(|st| st.doc.walk().iter().filter(|(_, _, l)| smart(l)).map(|(_, _, l)| l.id.0).collect()).unwrap_or_default();
for id in ids {
s.execute("layer.smartObjects.convertToSmartObject", json!({"layer": id})).expect("convert");
if let Err(error) = restore_layout_smart_objects(&mut s) {
eprintln!("cannot prepare layout benchmark: {error}");
std::process::exit(1);
}
(*s.active().expect("doc").doc).clone()
};
@@ -470,3 +488,38 @@ fn reexec_with_gpu_sync() {
let status = std::process::Command::new(exe).args(std::env::args().skip(1)).env("PHOTOCRAFT_GPU_SYNC", "1").status().expect("re-exec");
std::process::exit(status.code().unwrap_or(1));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn restore_keeps_native_smart_objects_and_converts_legacy_pixels_once() {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 4, "height": 3, "background": "white"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Logo"})).unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#204060"})).unwrap();
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let native = s.active().unwrap().active_layer.unwrap();
let before = s.active().unwrap().doc.layer(native).unwrap().content.clone();
s.execute("layer.new.layer", json!({"name": "Product 1"})).unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#406020"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Tile 1"})).unwrap();
let tile = s.active().unwrap().active_layer.unwrap();
s.execute("layer.select", json!({"layer": native.0})).unwrap();
let history = s.active().unwrap().history.past_len();
restore_layout_smart_objects(&mut s).expect("mixed current and legacy fixture prepares without re-converting a Smart Object");
let st = s.active().unwrap();
assert_eq!(st.doc.layer(native).unwrap().content, before);
assert!(matches!(&st.doc.layer(tile).unwrap().content, LayerContent::Raster(_)));
assert!(st.doc.walk().iter().any(|(_, _, l)| l.name == "Product 1" && matches!(&l.content, LayerContent::Smart(_))));
assert_eq!(st.history.past_len(), history + 1);
restore_layout_smart_objects(&mut s).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), history + 1, "preparation is idempotent");
}
#[test]
fn restore_without_a_document_returns_an_error() {
assert!(matches!(restore_layout_smart_objects(&mut photocraft_engine::Session::new()), Err(photocraft_engine::EngineError::NoDocument)));
}
}
+3 -1
View File
@@ -1000,8 +1000,10 @@ fn hue_saturation(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
}
// Spectrum bars: input hues above, adjusted below; the selected range's sliders between.
ui.add_space(6.0);
let w = ui.available_width().min(300.0);
// As wide as the sliders above, whatever the panel's width (#9242).
let w = ui.available_width();
let (bars, bresp) = ui.allocate_exact_size(vec2(w, 40.0), Sense::click_and_drag());
ui.data_mut(|d| d.insert_temp(cx.mem.with("hs-bars"), bars));
let top = Rect::from_min_size(bars.min, vec2(w, 10.0));
let bottom = Rect::from_min_size(pos2(bars.left(), bars.bottom() - 10.0), vec2(w, 10.0));
let p = ui.painter_at(bars.expand(4.0));
@@ -807,6 +807,37 @@ fn editor_harness(kind: &'static str, params: serde_json::Value) -> Harness<'sta
h
}
/// #9242: the Hue/Saturation spectrum bars stretch with the panel, like the sliders above them.
#[test]
fn hue_saturation_bars_fill_the_panel_width() {
use crate::adjust_editors::{EditorCx, editor};
for width in [300.0, 600.0] {
let mut h = Harness::builder().with_size(vec2(width, 500.0)).build_ui_state(
|ui, values: &mut serde_json::Value| {
let cx = EditorCx {
mem: egui::Id::new("hs"),
hist: None,
gray: false,
swatches: [[0.0; 3], [1.0; 3]],
gradients: Vec::new(),
dialog: false,
dynamic_color_sliders: true,
isolated_curve: None,
};
let avail = ui.available_width();
editor(ui, "hueSaturation", values, &cx);
ui.data_mut(|d| d.insert_temp(egui::Id::new("hs-avail"), avail));
},
json!({}),
);
PhotocraftApp::setup_context(&h.ctx, Default::default());
h.run_steps(4);
let bars = h.ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("hs").with("hs-bars"))).expect("bars drawn");
let avail = h.ctx.data(|d| d.get_temp::<f32>(egui::Id::new("hs-avail"))).unwrap();
assert!((bars.width() - avail).abs() < 0.5, "{width}: bars {} of {avail}", bars.width());
}
}
/// Types `text` into the `index`th number field and commits it with Enter.
fn type_into_field(h: &mut Harness<'static, serde_json::Value>, index: usize, text: &str) {
h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(index).unwrap().click();
+88 -11
View File
@@ -63,6 +63,7 @@ pub(crate) struct Redraw {
/// The state the blocks are drawn from, and what the texels depend on besides it.
doc: Arc<Document>,
display: u64,
text_gamma: u32,
format: eframe::wgpu::TextureFormat,
/// Uploaded blocks of `doc` kept for [`Kept`] (empty once over `cap`), and their bytes.
collected: Vec<Option<Vec<u8>>>,
@@ -72,6 +73,16 @@ pub(crate) struct Redraw {
}
impl Redraw {
/// The text gamma changed while it runs (even if back since): its blocks may mix both, so
/// none of them are kept. Checked every frame for every redraw, shown or not ([`retain`]); a
/// change and back between two frames (one batch of agent commands) isn't seen.
fn forget_if_gamma_changed(&mut self, gamma: u32) {
if self.text_gamma != gamma {
self.collected = Vec::new();
self.collected_bytes = 0;
}
}
/// Share of the document drawn, 0..=1.
pub fn fraction(&self) -> f32 {
if self.blocks.is_empty() { 1.0 } else { 1.0 - self.left as f32 / self.blocks.len() as f32 }
@@ -91,6 +102,7 @@ pub(crate) struct Kept {
doc: Weak<Document>,
id: DocId,
display: u64,
text_gamma: u32,
format: eframe::wgpu::TextureFormat,
blocks: Vec<Rect>,
texels: Vec<Vec<u8>>,
@@ -101,9 +113,11 @@ impl Kept {
/// Whether these are the texels a redraw of `doc` would draw. The key is the whole document
/// (layers and their pixels, visibility, opacity, blending, masks, adjustments and everything
/// else it holds), so a stale composite can't come back; Arc-shared tiles compare without
/// reading their pixels.
fn matches(&self, doc: &Arc<Document>, display: u64, format: eframe::wgpu::TextureFormat, blocks: &[Rect]) -> bool {
/// reading their pixels. Besides the document, the display transform and the text gamma
/// ([`text_gamma`]) decide the texels.
fn matches(&self, doc: &Arc<Document>, display: u64, text_gamma: u32, format: eframe::wgpu::TextureFormat, blocks: &[Rect]) -> bool {
self.display == display
&& self.text_gamma == text_gamma
&& self.format == format
&& self.blocks == blocks
&& self.doc.upgrade().is_some_and(|kept| Arc::ptr_eq(&kept, doc) || *kept == **doc)
@@ -114,12 +128,14 @@ impl Kept {
#[derive(Default)]
pub(crate) struct KeptComposites {
entries: VecDeque<Kept>,
/// The entries' documents and their revisions, and Memory Usage, when the cap was last taken.
capped: (Vec<(DocId, u64)>, u64),
}
impl KeptComposites {
/// The kept redraw of exactly this state, now the newest.
fn find(&mut self, doc: &Arc<Document>, display: u64, format: eframe::wgpu::TextureFormat, blocks: &[Rect]) -> Option<&Kept> {
let i = self.entries.iter().position(|k| k.matches(doc, display, format, blocks))?;
fn find(&mut self, doc: &Arc<Document>, display: u64, text_gamma: u32, format: eframe::wgpu::TextureFormat, blocks: &[Rect]) -> Option<&Kept> {
let i = self.entries.iter().position(|k| k.matches(doc, display, text_gamma, format, blocks))?;
let k = self.entries.remove(i)?;
self.entries.push_front(k);
self.entries.front()
@@ -128,7 +144,9 @@ impl KeptComposites {
/// Keep `k`, the redraw of `doc` (in place of an entry of the same state), within `cap` bytes
/// in all.
fn store(&mut self, k: Kept, doc: &Arc<Document>, cap: u64) {
self.entries.retain(|e| !e.matches(doc, k.display, k.format, &k.blocks));
// Another list: [`retain`] takes the cap over all its documents again.
self.capped = Default::default();
self.entries.retain(|e| !e.matches(doc, k.display, k.text_gamma, k.format, &k.blocks));
self.entries.push_front(k);
self.trim(cap);
}
@@ -151,6 +169,13 @@ impl KeptComposites {
}
}
/// Color Settings › "Blend Text Colors Using Gamma": type layers composite with it, but it is an
/// application setting, not part of the document. Read from the session's settings, which the
/// engine keeps in step with the process-wide value compose reads (`psblend::set_text_gamma`).
fn text_gamma(app: &PhotocraftApp) -> u32 {
app.session.color.settings.blend_text_gamma.to_bits()
}
/// Bytes kept composites of document `doc` may hold: what Memory Usage leaves beside its pixels
/// and History. It shrinks as the document grows, which drops them under memory pressure.
fn keep_cap(app: &PhotocraftApp, doc: DocId) -> u64 {
@@ -208,13 +233,14 @@ pub(crate) fn start(
) {
let all = blocks(doc.bounds());
let display_key = display.as_deref().map_or(0, |d| d.texture_key);
let gamma = text_gamma(app);
// Taken again when a running redraw continues: the edit (or Memory Usage) may have changed it.
let cap = keep_cap(app, doc.id);
app.kept.trim(cap);
let (mut pending, since, fallback, mut keep) = match app.redraws.remove(&doc.id) {
Some(mut old) if old.blocks == all => {
// Blocks drawn from another state aren't part of this one's composite.
let same = old.display == display_key && (Arc::ptr_eq(&old.doc, &doc) || *old.doc == *doc);
let same = old.display == display_key && old.text_gamma == gamma && (Arc::ptr_eq(&old.doc, &doc) || *old.doc == *doc);
let (collected, bytes) =
if same && old.collected_bytes <= cap { (std::mem::take(&mut old.collected), old.collected_bytes) } else { (vec![None; all.len()], 0) };
(std::mem::take(&mut old.pending), (old.started_ms, old.drawn), fallback.or_else(|| old.fallback.take()), (collected, bytes, cap))
@@ -229,7 +255,7 @@ pub(crate) fn start(
}
let Some(format) = gpu.prepare_texture(doc.id.0, &doc) else { return };
// This exact state was drawn a moment ago: upload its blocks rather than composite them.
if let Some(k) = app.kept.find(&doc, display_key, format, &all) {
if let Some(k) = app.kept.find(&doc, display_key, gamma, format, &all) {
let t0 = crate::gpu_canvas::now_ms();
let mut px = 0u64;
for (i, p) in pending.iter_mut().enumerate() {
@@ -246,7 +272,7 @@ pub(crate) fn start(
}
keep = (vec![None; all.len()], 0, keep.2);
}
spawn(app, ctx, Spawn { doc, display, display_key, format, visible, blocks: all, pending, since, fallback, keep });
spawn(app, ctx, Spawn { doc, display, display_key, text_gamma: gamma, format, visible, blocks: all, pending, since, fallback, keep });
}
/// After an edit drew its own damage: continue document `doc.id`'s redraw, if one is running, on
@@ -261,7 +287,9 @@ pub(crate) fn restart(app: &mut PhotocraftApp, ctx: &egui::Context, gpu: &GpuCan
/// lands, the redraw is recorded like the synchronous refresh it replaces (and explained once if
/// it was slow).
pub(crate) fn poll(app: &mut PhotocraftApp, gpu: &GpuCanvas, doc: DocId) {
let gamma = text_gamma(app);
let Some(r) = app.redraws.get_mut(&doc) else { return };
r.forget_if_gamma_changed(gamma);
loop {
match r.rx.try_recv() {
Ok(p) => {
@@ -288,6 +316,7 @@ pub(crate) fn poll(app: &mut PhotocraftApp, gpu: &GpuCanvas, doc: DocId) {
doc: Arc::downgrade(&r.doc),
id: r.doc.id,
display: r.display,
text_gamma: r.text_gamma,
format: r.format,
blocks: r.blocks.clone(),
texels,
@@ -335,6 +364,26 @@ pub(crate) fn retain(app: &mut PhotocraftApp) {
let docs = app.session.documents();
app.redraws.retain(|id, _| docs.iter().any(|st| st.doc.id == *id));
app.kept.retain(|id| docs.iter().any(|st| st.doc.id == id));
let gamma = text_gamma(app);
for r in app.redraws.values_mut() {
r.forget_if_gamma_changed(gamma);
}
// Not only when the next redraw starts: states History dropped (purged or trimmed) go at once,
// and the cap is taken again when a kept document (its pixels and History) or Memory Usage
// changed.
app.kept.entries.retain(|k| k.doc.strong_count() > 0);
if app.kept.entries.is_empty() {
return;
}
let docs = app.session.documents();
let mut ids: Vec<(DocId, u64)> = app.kept.entries.iter().filter_map(|k| docs.iter().find(|st| st.doc.id == k.id).map(|st| (k.id, st.revision))).collect();
ids.dedup();
let capped = (ids, app.session.prefs().performance.history_budget_bytes() as u64);
if capped != app.kept.capped {
let cap = capped.0.iter().map(|&(id, _)| keep_cap(app, id)).min().unwrap_or(0);
app.kept.capped = capped;
app.kept.trim(cap);
}
}
/// What [`spawn`] starts a redraw with.
@@ -342,6 +391,7 @@ struct Spawn {
doc: Arc<Document>,
display: Option<Arc<CanvasDisplay>>,
display_key: u64,
text_gamma: u32,
format: eframe::wgpu::TextureFormat,
visible: Rect,
blocks: Vec<Rect>,
@@ -354,8 +404,19 @@ struct Spawn {
}
fn spawn(app: &mut PhotocraftApp, ctx: &egui::Context, s: Spawn) {
let Spawn { doc, display, display_key, format, visible, blocks, pending, since: (started_ms, drawn), fallback, keep: (collected, collected_bytes, cap) } =
s;
let Spawn {
doc,
display,
display_key,
text_gamma,
format,
visible,
blocks,
pending,
since: (started_ms, drawn),
fallback,
keep: (collected, collected_bytes, cap),
} = s;
let id = doc.id;
let kept_doc = doc.clone();
let todo = order(&blocks, (0..blocks.len()).filter(|&i| pending[i]), visible);
@@ -375,7 +436,23 @@ fn spawn(app: &mut PhotocraftApp, ctx: &egui::Context, s: Spawn) {
}
app.redraws.insert(
id,
Redraw { blocks, pending, left, drawn, rx, stop, started_ms, fallback, doc: kept_doc, display: display_key, format, collected, collected_bytes, cap },
Redraw {
blocks,
pending,
left,
drawn,
rx,
stop,
started_ms,
fallback,
doc: kept_doc,
display: display_key,
text_gamma,
format,
collected,
collected_bytes,
cap,
},
);
}
+185 -10
View File
@@ -39,6 +39,7 @@ fn kept(doc: &Arc<Document>, bytes: usize) -> Kept {
doc: Arc::downgrade(doc),
id: doc.id,
display: 7,
text_gamma: 1,
format: eframe::wgpu::TextureFormat::Rgba8Unorm,
bytes: (bytes * all.len()) as u64,
blocks: all,
@@ -65,8 +66,8 @@ fn kept_composites_come_back_only_for_the_exact_state_and_stay_bounded() {
let mut cache = KeptComposites::default();
cache.store(kept(&a, 4), &a, u64::MAX);
// The same state hits, also as an equal document that isn't the same Arc (a restore after a solo).
assert!(cache.find(&a, 7, Rgba8Unorm, &all).is_some());
assert!(cache.find(&Arc::new((*a).clone()), 7, Rgba8Unorm, &all).is_some());
assert!(cache.find(&a, 7, 1, Rgba8Unorm, &all).is_some());
assert!(cache.find(&Arc::new((*a).clone()), 7, 1, Rgba8Unorm, &all).is_some());
// Any change to what the composite depends on misses.
let hidden = changed(&|d| d.layers[0].visible = false);
let faded = changed(&|d| d.layers[0].opacity = 0.5);
@@ -77,18 +78,23 @@ fn kept_composites_come_back_only_for_the_exact_state_and_stay_bounded() {
}
});
for (what, d) in [("visibility", &hidden), ("opacity", &faded), ("blend mode", &multiplied), ("pixels", &painted)] {
assert!(cache.find(d, 7, Rgba8Unorm, &all).is_none(), "{what} changed");
assert!(cache.find(d, 7, 1, Rgba8Unorm, &all).is_none(), "{what} changed");
}
assert!(cache.find(&a, 8, Rgba8Unorm, &all).is_none(), "another display transform");
assert!(cache.find(&a, 7, Rgba16Float, &all).is_none(), "another texture format");
assert!(cache.find(&a, 7, Rgba8Unorm, &all[1..]).is_none(), "other blocks");
assert!(cache.find(&a, 8, 1, Rgba8Unorm, &all).is_none(), "another display transform");
assert!(cache.find(&a, 7, 2, Rgba8Unorm, &all).is_none(), "another text gamma");
assert!(cache.find(&a, 7, 1, Rgba16Float, &all).is_none(), "another texture format");
assert!(cache.find(&a, 7, 1, Rgba8Unorm, &all[1..]).is_none(), "other blocks");
// At most KEEP entries, the least recently used going first.
cache.store(kept(&hidden, 4), &hidden, u64::MAX);
assert!(cache.find(&a, 7, Rgba8Unorm, &all).is_some());
assert!(cache.find(&a, 7, 1, Rgba8Unorm, &all).is_some());
cache.store(kept(&faded, 4), &faded, u64::MAX);
assert_eq!(cache.entries.len(), KEEP);
assert!(cache.find(&hidden, 7, Rgba8Unorm, &all).is_none(), "the least recently used went");
assert!(cache.find(&a, 7, Rgba8Unorm, &all).is_some() && cache.find(&faded, 7, Rgba8Unorm, &all).is_some());
assert!(cache.find(&hidden, 7, 1, Rgba8Unorm, &all).is_none(), "the least recently used went");
assert!(cache.find(&a, 7, 1, Rgba8Unorm, &all).is_some() && cache.find(&faded, 7, 1, Rgba8Unorm, &all).is_some());
// A stored entry makes the next frame take the cap again, over every kept document.
cache.capped = (vec![(a.id, 1)], 1);
cache.store(kept(&faded, 4), &faded, u64::MAX);
assert_eq!(cache.capped, Default::default());
// Storing a state again replaces its entry.
cache.store(kept(&faded, 4), &faded, u64::MAX);
assert_eq!(cache.entries.len(), 2);
@@ -109,7 +115,7 @@ fn kept_composites_come_back_only_for_the_exact_state_and_stay_bounded() {
cache.store(kept(&gone, 4), &gone, u64::MAX);
let equal = Arc::new((*gone).clone());
drop(gone);
assert!(cache.find(&equal, 7, Rgba8Unorm, &all).is_none(), "no strong reference to the drawn state");
assert!(cache.find(&equal, 7, 1, Rgba8Unorm, &all).is_none(), "no strong reference to the drawn state");
cache.trim(u64::MAX);
assert!(cache.entries.is_empty());
}
@@ -281,6 +287,175 @@ fn a_redraw_finishing_after_memory_usage_drops_keeps_nothing_over_it() {
assert!(g.harness.state().kept.entries.is_empty(), "a composite was kept over the current cap");
}
/// Color Settings › "Blend Text Colors Using Gamma" isn't part of the document, but type layers
/// composite with it: a state drawn before it changed is composited again, not uploaded from
/// blocks drawn with the old gamma. Only the session's setting changes here, not the process-wide
/// gamma compose reads, so tests compositing type layers at the same time aren't affected.
#[test]
fn a_state_drawn_before_the_text_gamma_changed_is_composited_again() {
let _gpu = photocraft_testkit::gpu_lock();
let Some(mut g) = gpu_app() else { return };
let doc = cpu_only_document(g.harness.state_mut(), 2600, 1700);
let layer = g.harness.state().session.active().and_then(|st| st.active_layer).expect("layer");
settle(&mut g);
g.harness.state_mut().run("layer.setProps", json!({"layer": layer.0, "visible": false})).expect("hide");
settle(&mut g);
g.harness.state_mut().session.color.settings.blend_text_gamma = 1.0;
g.harness.state_mut().run("layer.setProps", json!({"layer": layer.0, "visible": true})).expect("show");
g.harness.step();
assert!(g.harness.state().redraws.contains_key(&doc), "served from blocks of the old gamma: {}", g.harness.state().perf.last_refresh);
settle(&mut g);
assert_eq!(g.harness.state().perf.last_refresh, "background");
assert_matches_synchronous(&mut g, doc);
}
/// The text gamma changing while a redraw runs: its blocks were drawn with both, so it isn't kept.
#[test]
fn a_redraw_across_a_text_gamma_change_is_not_kept() {
let _gpu = photocraft_testkit::gpu_lock();
let Some(mut g) = gpu_app() else { return };
let doc = cpu_only_document(g.harness.state_mut(), 4096, 3072);
g.harness.step();
assert!(g.harness.state().redraws.contains_key(&doc));
g.harness.state_mut().session.color.settings.blend_text_gamma = 1.0;
settle(&mut g);
assert!(g.harness.state().kept.entries.is_empty(), "a composite of two gammas was kept");
}
/// Changed and changed back while a redraw runs: blocks drawn in between still mix both.
#[test]
fn a_redraw_across_a_text_gamma_change_and_back_is_not_kept() {
let _gpu = photocraft_testkit::gpu_lock();
let Some(mut g) = gpu_app() else { return };
let doc = cpu_only_document(g.harness.state_mut(), 4096, 3072);
let gamma = g.harness.state().session.color.settings.blend_text_gamma;
g.harness.step();
g.harness.state_mut().session.color.settings.blend_text_gamma = 1.0;
g.harness.step();
g.harness.state_mut().session.color.settings.blend_text_gamma = gamma;
assert!(g.harness.state().redraws.contains_key(&doc), "the redraw finished before the change back");
settle(&mut g);
assert!(g.harness.state().kept.entries.is_empty(), "a composite of two gammas was kept");
}
/// The same while another document is shown: its redraw goes on without being polled, so the
/// change is seen every frame, not only when it shows again.
#[test]
fn a_hidden_documents_redraw_across_a_text_gamma_change_and_back_is_not_kept() {
let _gpu = photocraft_testkit::gpu_lock();
let Some(mut g) = gpu_app() else { return };
let doc = cpu_only_document(g.harness.state_mut(), 4096, 3072);
let gamma = g.harness.state().session.color.settings.blend_text_gamma;
g.harness.step();
g.harness.state_mut().run("file.new", json!({"width": 64, "height": 64})).expect("another document");
g.harness.step();
g.harness.state_mut().session.color.settings.blend_text_gamma = 1.0;
g.harness.step();
g.harness.state_mut().session.color.settings.blend_text_gamma = gamma;
g.harness.step();
assert!(g.harness.state().redraws.contains_key(&doc), "the hidden document's redraw stopped");
let i = g.harness.state().session.documents().iter().position(|st| st.doc.id == doc).expect("document");
g.harness.state_mut().session.set_active(i);
settle(&mut g);
assert!(g.harness.state().kept.entries.iter().all(|k| k.id != doc), "a composite of two gammas was kept");
}
/// Memory Usage lowered with no redraw running: the kept composites go at the next frame, not
/// only when the next redraw starts.
#[test]
fn kept_composites_go_when_memory_usage_drops_between_redraws() {
let _gpu = photocraft_testkit::gpu_lock();
let Some(mut g) = gpu_app() else { return };
cpu_only_document(g.harness.state_mut(), 2600, 1700);
settle(&mut g);
assert!(!g.harness.state().kept.entries.is_empty(), "the finished redraw is kept");
g.harness.state_mut().session.edit_prefs(|p| p.performance.memory_usage_mb = 1);
g.harness.step();
assert!(g.harness.state().redraws.is_empty());
assert!(g.harness.state().kept.entries.is_empty(), "kept over the current cap");
}
/// An edit too small for a background redraw that grows the document past what Memory Usage
/// leaves: the kept composite goes at the next frame. The test holds the kept state itself, so
/// only the cap can drop it, not History letting go of the state.
#[test]
fn kept_composites_go_when_a_small_edit_grows_the_document_past_the_cap() {
let _gpu = photocraft_testkit::gpu_lock();
let Some(mut g) = gpu_app() else { return };
cpu_only_document(g.harness.state_mut(), 2600, 1700);
settle(&mut g);
let app = g.harness.state_mut();
let st = app.session.active().expect("document");
let held = st.doc.clone();
let (used, kept) = (st.memory_bytes() as u64, app.kept.bytes());
assert!(kept > 0, "the finished redraw is kept");
// Room for the kept composite and 2-3 MB more.
let mb = (used + kept).div_ceil(1 << 20) + 2;
app.session.edit_prefs(|p| p.performance.memory_usage_mb = mb as _);
g.harness.step();
assert_eq!(g.harness.state().kept.entries.len(), 1, "still within the cap");
// 2 MP of new pixels (8 MB): under the size that redraws in the background.
for (cmd, p) in
[("layer.new.layer", json!({})), ("select.rect", json!({"x": 0, "y": 0, "width": 2000, "height": 1000})), ("edit.fill", json!({"color": "#cc2244"}))]
{
g.harness.state_mut().run(cmd, p).expect(cmd);
g.harness.step();
assert!(g.harness.state().redraws.is_empty(), "{cmd} redrew in the background");
}
assert!(g.harness.state().kept.entries.is_empty(), "kept over the cap after the edit");
drop(held);
}
/// The same for an older kept composite of another document: every kept document's edits count,
/// not only the newest one's.
#[test]
fn an_older_kept_composite_goes_when_its_document_grows_past_the_cap() {
let _gpu = photocraft_testkit::gpu_lock();
let Some(mut g) = gpu_app() else { return };
let a = cpu_only_document(g.harness.state_mut(), 2600, 1700);
settle(&mut g);
let b = cpu_only_document(g.harness.state_mut(), 2600, 1700);
settle(&mut g);
let app = g.harness.state_mut();
let held: Vec<_> = app.session.documents().iter().map(|st| st.doc.clone()).collect();
assert_eq!(app.kept.entries.iter().map(|k| k.id).collect::<Vec<_>>(), [b, a], "both kept, the second newest");
let used = app.session.documents().iter().map(|st| st.memory_bytes() as u64).max().unwrap_or(0);
let mb = (used + app.kept.bytes()).div_ceil(1 << 20) + 2;
app.session.edit_prefs(|p| p.performance.memory_usage_mb = mb as _);
let i = app.session.documents().iter().position(|st| st.doc.id == a).expect("document");
app.session.set_active(i);
g.harness.step();
assert_eq!(g.harness.state().kept.entries.len(), 2, "still within the cap");
for (cmd, p) in
[("layer.new.layer", json!({})), ("select.rect", json!({"x": 0, "y": 0, "width": 2000, "height": 1000})), ("edit.fill", json!({"color": "#cc2244"}))]
{
g.harness.state_mut().run(cmd, p).expect(cmd);
g.harness.step();
assert!(g.harness.state().redraws.is_empty(), "{cmd} redrew in the background");
}
assert!(g.harness.state().kept.entries.len() < 2, "kept over the cap after the older document grew");
drop(held);
}
/// A kept state History no longer holds goes at the next frame: its texels aren't held until
/// some later redraw.
#[test]
fn a_kept_state_history_purged_goes_at_the_next_frame() {
let _gpu = photocraft_testkit::gpu_lock();
let Some(mut g) = gpu_app() else { return };
cpu_only_document(g.harness.state_mut(), 2600, 1700);
let layer = g.harness.state().session.active().and_then(|st| st.active_layer).expect("layer");
settle(&mut g);
// Hidden, the document is drawn on the GPU: the kept state is the one before, in History.
g.harness.state_mut().run("layer.setProps", json!({"layer": layer.0, "visible": false})).expect("hide");
settle(&mut g);
assert_eq!(g.harness.state().kept.entries.len(), 1, "the state before hiding is kept");
g.harness.state_mut().run("edit.purge.histories", json!({})).expect("purge");
g.harness.step();
assert!(g.harness.state().redraws.is_empty());
assert!(g.harness.state().kept.entries.is_empty(), "the purged state's composite is still held");
}
#[test]
fn closing_the_document_stops_its_redraw() {
let _gpu = photocraft_testkit::gpu_lock();
+18
View File
@@ -63,3 +63,21 @@ fn install_plugin_path_is_chosen_with_browse() {
}
assert_eq!(field(&h, "path"), Some(json!("/tmp/blur.wasm")));
}
/// #9357: Image Processor's source and destination folders are chosen with Browse…, as
/// Photoshop's Select Folder… buttons choose them, instead of being typed.
#[test]
fn image_processor_folders_are_chosen_with_browse() {
let (mut h, asked) = harness(vec![Some(FileDialogAnswer::FolderTo("/tmp/shoot".into())), Some(FileDialogAnswer::FolderTo("/tmp/web".into()))]);
let ctx = h.ctx.clone();
crate::menus::invoke(h.state_mut(), &ctx, "file.scripts.imageProcessor", json!({})).unwrap();
h.run_steps(4);
assert_eq!(h.query_all_by_label("Browse…").count(), 2, "one Browse… for the source and one for the destination");
h.query_all_by_label("Browse…").next().unwrap().click();
h.run_steps(4);
assert_eq!(field(&h, "input"), Some(json!("/tmp/shoot")));
h.query_all_by_label("Browse…").nth(1).unwrap().click();
h.run_steps(4);
assert_eq!(field(&h, "output"), Some(json!("/tmp/web")));
assert!(matches!(asked.borrow().as_slice(), [FileDialogRequest::Folder { .. }, FileDialogRequest::Folder { .. }]));
}
+71
View File
@@ -92,6 +92,18 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let rows_h = if fill { ui.available_height() - footer } else { f32::INFINITY };
let mut mask_click = None;
let mut drawn = Vec::new();
// An alpha or spot channel dragged over the rows reorders like Photoshop's (#9163): a line marks
// where it lands, above the colour channels it lands first, and colour channels don't move.
let dragged_alpha = ctx.data(|d| d.get_temp::<(Row, String)>(drag_id())).and_then(|(row, _)| match row {
Row::Alpha(i) => Some(i),
_ => None,
});
let pointer = ctx.input(|i| i.pointer.interact_pos());
let released = ctx.input(|i| i.pointer.any_released());
let alpha_count = doc.channels.len();
// The slot the dragged channel would take (0 = first alpha) and where its line goes.
let mut insertion: Option<(usize, f32)> = None;
let mut alphas_top: Option<f32> = None;
egui::ScrollArea::vertical().max_height(rows_h).min_scrolled_height(if fill { rows_h } else { 0.0 }).auto_shrink([false, !fill]).show(ui, |ui| {
for row in rows {
let (name, thumb_idx, slot, visible, selected) = match row {
@@ -125,6 +137,20 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
};
let row_h = if t.pro { 36.0 } else { 40.0 };
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag());
if let Row::Alpha(0) = row {
alphas_top = Some(rect.top());
}
if dragged_alpha.is_some()
&& let Some(p) = pointer.filter(|p| rect.contains(*p))
{
insertion = match row {
Row::Alpha(j) if p.y < rect.center().y => Some((j, rect.top())),
Row::Alpha(j) => Some((j + 1, rect.bottom())),
Row::QuickMask => Some((alpha_count, rect.top())),
// Above the alpha channels: Photoshop puts it first among them.
Row::Composite | Row::Color(_) | Row::LayerMask => Some((0, f32::NAN)),
};
}
if resp.drag_started() {
ctx.data_mut(|d| d.insert_temp(drag_id(), (row, name.clone())));
}
@@ -297,6 +323,18 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
});
});
}
if let (Some(from), Some((slot, y))) = (dragged_alpha, insertion) {
let y = if y.is_nan() { alphas_top.unwrap_or(y) } else { y };
if y.is_finite() {
let r = ui.min_rect();
ui.painter().hline(r.x_range(), y, Stroke::new(2.0, t.accent));
}
// `channel.move` takes the channel's final index: a slot below it is one less.
let to = if slot > from { slot - 1 } else { slot };
if released && to != from {
actions.push(("channel.move".into(), json!({ "channel": from, "to": to })));
}
}
});
// A row being dragged: its name follows the pointer until it is dropped.
let dragged = ctx.data(|d| d.get_temp::<(Row, String)>(drag_id()));
@@ -597,6 +635,39 @@ mod tests {
h.state().session.active().unwrap().doc.channels.iter().map(|c| c.name.clone()).collect()
}
/// #9163: alpha channels reorder by dragging, as in Photoshop: a drop in a row's upper half
/// lands above it, over the colour channels it lands first among the alphas, colour channels
/// don't move, and each reorder is one history step.
#[test]
fn alpha_channels_reorder_by_dragging() {
let (mut h, _) = channels_harness();
h.state_mut().run("channel.new", json!({"name": "Alpha 2"})).unwrap();
h.state_mut().run("channel.new", json!({"name": "Alpha 3"})).unwrap();
h.run_steps(3);
assert_eq!(alphas(&h), ["Alpha 1", "Alpha 2", "Alpha 3"]);
let steps = h.state().session.active().unwrap().history.past_len();
let (from, onto) = (at(&h, "Alpha 3"), at(&h, "Alpha 1"));
drag(&mut h, from, onto - vec2(0.0, 8.0));
assert_eq!(alphas(&h), ["Alpha 3", "Alpha 1", "Alpha 2"]);
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "one history step");
// The lower half of a row: below it.
let (from, onto) = (at(&h, "Alpha 3"), at(&h, "Alpha 1"));
drag(&mut h, from, onto + vec2(0.0, 8.0));
assert_eq!(alphas(&h), ["Alpha 1", "Alpha 3", "Alpha 2"]);
// Dropped on a colour channel: first among the alphas.
drag_to(&mut h, "Alpha 2", "Red");
assert_eq!(alphas(&h), ["Alpha 2", "Alpha 1", "Alpha 3"]);
// A colour channel dragged onto the alphas stays where it is.
let before = h.state().session.active().unwrap().history.past_len();
drag_to(&mut h, "Green", "Alpha 3");
assert_eq!(alphas(&h), ["Alpha 2", "Alpha 1", "Alpha 3"]);
assert_eq!(h.state().session.active().unwrap().history.past_len(), before);
}
/// #4689: as in Photoshop, a channel dropped on New is duplicated, on Delete deleted, and on
/// Load Channel as Selection loaded.
#[test]
+29 -2
View File
@@ -104,9 +104,10 @@ pub fn fill(s: &CropShield, canvas: Color32, editing: bool) -> Option<Color32> {
(a > 0).then(|| Color32::from_rgba_unmultiplied(r, g, b, a))
}
/// The pasteboard colour the shield matches: Preferences › Interface's, else the theme's canvas.
/// The pasteboard colour the shield matches: Preferences › Interface's, else black in Full Screen
/// Mode (the backdrop there) and the theme's canvas otherwise.
pub fn canvas_color(app: &PhotocraftApp, ctx: &egui::Context) -> Color32 {
crate::prefs_ui::pasteboard_color(app).unwrap_or_else(|| crate::theme::Tokens::get(ctx).canvas)
crate::prefs_ui::pasteboard_color(app).unwrap_or_else(|| if app.ui.view.hides_chrome() { Color32::BLACK } else { crate::theme::Tokens::get(ctx).canvas })
}
/// The shield over the pending crop box now ([`fill`]).
@@ -307,6 +308,32 @@ mod tests {
app
}
#[test]
fn match_canvas_is_the_black_full_screen_pasteboard() {
let mut app = crop_app();
let ctx = egui::Context::default();
let theme_canvas = crate::theme::Tokens::get(&ctx).canvas;
assert_ne!(theme_canvas, Color32::BLACK);
app.ui.tool_options.crop_shield.show_cropped_area = false;
assert!(hides_outside(&app));
// Full Screen Mode shows the document on black (lib.rs backdrop); the opaque shield
// over the cropped area matches it.
for (mode, want) in [
("view.screenMode.fullScreen", Color32::BLACK),
("view.screenMode.fullScreenWithMenuBar", theme_canvas),
("view.screenMode.standard", theme_canvas),
] {
crate::menus::invoke(&mut app, &ctx, mode, serde_json::json!({})).unwrap();
assert_eq!(current(&app, &ctx), Some(want), "{mode}");
}
// A chosen pasteboard colour is painted in every screen mode, and the shield follows it.
app.run("prefs.set", serde_json::json!({"values": {"interface.canvasColor": "custom", "interface.canvasCustomColor": "#123456"}})).unwrap();
for mode in ["view.screenMode.fullScreen", "view.screenMode.standard"] {
crate::menus::invoke(&mut app, &ctx, mode, serde_json::json!({})).unwrap();
assert_eq!(current(&app, &ctx), Some(Color32::from_rgb(0x12, 0x34, 0x56)), "{mode}");
}
}
#[test]
fn h_toggles_show_cropped_area_while_the_crop_tool_shows_a_box() {
let mut app = crop_app();
+10 -3
View File
@@ -11,11 +11,18 @@ use crate::widgets::{ButtonRole, DialogButton, dialog_buttons};
/// [`show`] takes it out again and opens the matching file or folder dialog.
pub(crate) const BROWSE_FIELD: &str = "__browse";
/// Browse… beside a path field of dialog `id`: a folder picker for `dir` fields, a `.wasm` picker
/// for Install Plug-in…'s `path`. The answer fills the field.
/// A text field that names a folder: an export's `dir`, and the source (`input`) and destination
/// (`output`) folders of Image Processor, Batch, Lens Correction, Contact Sheet and Create
/// Droplet, which Photoshop picks with Select Folder… / Choose… (#9357).
pub(crate) fn is_folder_field(key: &str) -> bool {
matches!(key, "dir" | "input" | "output")
}
/// Browse… beside a path field of dialog `id`: a folder picker for folder fields
/// ([`is_folder_field`]), a `.wasm` picker for Install Plug-in…'s `path`. The answer fills the field.
fn browse_field(app: &mut PhotocraftApp, id: u64, fields: &serde_json::Map<String, Value>, key: &str) {
let key = key.to_string();
let r = if key == "dir" {
let r = if is_folder_field(&key) {
app.ask_folder(move |app, path| fill_field(app, id, &key, path))
} else if fields.get("__command").and_then(Value::as_str) == Some("plugin.install") {
let initial_dir = crate::file_dialog::last_used_dir(&app.ui.recent_files);
+31 -3
View File
@@ -202,13 +202,17 @@ mod web_preview;
const WEB_FORMATS: [(&str, &str); 5] = [("gif", "GIF"), ("png8", "PNG-8"), ("png24", "PNG-24"), ("jpeg", "JPEG"), ("wbmp", "WBMP")];
const WEB_COMMAND: &str = "file.export.saveForWebLegacy";
/// Save for Web's preview tabs: the `__view` field's values and their labels.
const WEB_VIEWS: [(&str, &str); 4] = [("original", "Original"), ("optimized", "Optimized"), ("2up", "2-Up"), ("4up", "4-Up")];
pub fn open_web(app: &mut PhotocraftApp) -> u64 {
let mut f = Map::new();
f.insert("__web".into(), json!(true));
f.insert("__command".into(), json!(WEB_COMMAND));
f.insert("__label".into(), json!("Save for Web (Legacy)"));
f.insert("__view".into(), json!("2up"));
// The preview tab chosen last time, 2-Up at first (#9174).
let view = app.ui.web_view.as_deref().filter(|v| WEB_VIEWS.iter().any(|(k, _)| k == v)).unwrap_or("2up");
f.insert("__view".into(), json!(view));
// Remember encoding choices, but each document starts at its own full size. This session's
// last export lives in `file_menu`; the last one before a restart comes from the
// preferences' remembered dialog settings (`web_cmds::remember_web_settings`).
@@ -485,9 +489,11 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return };
ui.horizontal(|ui| {
let mut view = s(f, "__view", "2up");
for (k, l) in [("original", tl!("Original")), ("optimized", tl!("Optimized")), ("2up", "2-Up"), ("4up", "4-Up")] {
if ui.selectable_label(view == k, tl!(&l)).clicked() {
for (k, l) in WEB_VIEWS {
if ui.selectable_label(view == k, tl!(l)).clicked() {
view = k.into();
// The next Save for Web opens on this tab, as Photoshop's does.
app.ui.web_view = Some(view.clone());
}
}
f.insert("__view".into(), json!(view));
@@ -2016,6 +2022,28 @@ mod web_playback_tests {
h
}
/// #9174: Save for Web reopens on the preview tab chosen last, as Photoshop's does; 2-Up at
/// first, and for a remembered value it doesn't know.
#[test]
fn save_for_web_reopens_on_the_last_preview_tab() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 40, "height": 30})).unwrap();
let id = open_web(&mut app);
let fields = app.ui.dialog_mut(id).unwrap().fields.clone();
assert_eq!(fields["__view"], "2up");
let mut h = harness(app, fields);
use egui_kittest::kittest::Queryable;
h.get_by_label("4-Up").click();
h.run_steps(2);
assert_eq!(h.state().1["__view"], "4up");
let (mut app, _) = h.into_state();
let id = open_web(&mut app);
assert_eq!(app.ui.dialog_mut(id).unwrap().fields["__view"], "4up");
app.ui.web_view = Some("6up".into());
let id = open_web(&mut app);
assert_eq!(app.ui.dialog_mut(id).unwrap().fields["__view"], "2up");
}
#[test]
fn animated_save_for_web_offers_playback() {
let mut app = animated_app(8);
+103 -7
View File
@@ -80,6 +80,8 @@ pub struct GalleryDialog {
background: [f32; 4],
/// Screen px per document px (0 = fit) and the view centre (document px).
pub zoom: f32,
/// The zoom Fit in View showed last (1 before the first frame).
fit: f32,
center: [f32; 2],
tex: Option<TextureHandle>,
/// The key of the preview in `tex` and the document rect it covers.
@@ -365,6 +367,7 @@ fn open_on(
foreground: colours[0],
background: colours[1],
zoom: 0.0,
fit: 1.0,
center: [(canvas.x0 + canvas.x1) as f32 / 2.0, (canvas.y0 + canvas.y1) as f32 / 2.0],
tex: None,
shown: None,
@@ -437,9 +440,9 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result<Value, String> {
d.history.settle(&d.effects, false);
match ui.get("zoom") {
Some(Value::String(s)) if s == "fit" => d.zoom = 0.0,
// The gallery's own preview zoom, not the canvas (`zoom_levels` doesn't apply): its − / +
// buttons and menu stop at 400 %, an agent may set up to 3200 %.
Some(v) if v.is_number() => d.zoom = (v.as_f64().unwrap_or(1.0) as f32).clamp(0.01, 32.0),
// The gallery's own preview zoom, not the canvas: its menu stops at 400 %, the − / +
// buttons, ⌘+ and an agent go up to 3200 %.
Some(v) if v.is_number() => d.zoom = (v.as_f64().unwrap_or(1.0) as f32).clamp(ZOOM_MIN, ZOOM_MAX),
_ => {}
}
Ok(d.describe())
@@ -462,12 +465,39 @@ impl Drop for PreviewJob {
}
}
/// The preview's zoom range (1 = 100 %), as Liquify's.
const ZOOM_MIN: f32 = 0.01;
const ZOOM_MAX: f32 = 32.0;
/// Zoom In (`dir` > 0) or Zoom Out along the canvas's ladder, as Liquify's preview does (#7210),
/// from the zoom on screen: Fit in View steps from the zoom it fits at, so zooming in from a
/// large image's Fit never shrinks it, and zooming out from a Fit below [`ZOOM_MIN`] stays at Fit.
fn step_zoom(d: &GalleryDialog, dir: i32) -> f32 {
let on_screen = if d.zoom > 0.0 { d.zoom } else { d.fit };
let next = crate::zoom_levels::step_in_range(on_screen, dir, ZOOM_MIN, ZOOM_MAX);
if d.zoom <= 0.0 && dir < 0 && next >= d.fit { 0.0 } else { next }
}
/// ⌘+ / ⌘− / ⌘0 / ⌘1 (or their overrides) zoom the preview like its − / + buttons, Fit in View
/// and 100 %, not the canvas behind the dialog.
pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) {
let nav = if app.distort.gallery.is_some() { crate::shortcuts::nav_shortcut(app, ctx) } else { None };
let (Some(d), Some(id)) = (app.distort.gallery.as_mut(), nav) else { return };
d.zoom = match id {
"view.zoomIn" => step_zoom(d, 1),
"view.zoomOut" => step_zoom(d, -1),
"view.fitOnScreen" => 0.0,
_ => 1.0,
};
}
/// The preview's zoom (screen px per document px), the visible document rect and the factor the
/// texture is reduced by. Fit in View also recentres the view.
fn view(d: &mut GalleryDialog, area: ERect) -> (f32, Rect, u32) {
let (cw, ch) = (d.canvas.width().max(1) as f32, d.canvas.height().max(1) as f32);
let zoom = if d.zoom > 0.0 { d.zoom } else { ((area.width() - 24.0) / cw).min((area.height() - 24.0) / ch).clamp(0.005, 1.0) };
if d.zoom <= 0.0 {
d.fit = zoom;
d.center = [d.canvas.x0 as f32 + cw / 2.0, d.canvas.y0 as f32 + ch / 2.0];
}
let (hw, hh) = (area.width() / zoom / 2.0, area.height() / zoom / 2.0);
@@ -643,16 +673,21 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
// ---- Zoom bar ----
painter.rect_filled(zoom_bar, 0.0, t.dock);
let mut zb = ui.new_child(egui::UiBuilder::new().max_rect(zoom_bar.shrink2(vec2(10.0, 3.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
let steps = [0.0f32, 0.125, 0.25, 0.5, 1.0, 2.0, 4.0];
if crate::icons::button(&mut zb, "minus", 22.0, false, tl!("Zoom out")).clicked() {
d.zoom = steps.iter().rev().copied().find(|&s| s > 0.0 && s < d.zoom.max(0.0) - 1e-3).unwrap_or(0.0);
d.zoom = step_zoom(d, -1);
}
if crate::icons::button(&mut zb, "plus", 22.0, false, tl!("Zoom in")).clicked() {
d.zoom = steps.iter().copied().find(|&s| s > d.zoom + 1e-3).unwrap_or(4.0);
d.zoom = step_zoom(d, 1);
}
let mut z = d.zoom;
let opts: Vec<(f32, &str)> =
let mut opts: Vec<(f32, &str)> =
vec![(0.0, tl!("Fit in View")), (0.125, "12.5%"), (0.25, "25%"), (0.5, "50%"), (1.0, "100%"), (2.0, "200%"), (4.0, "400%")];
// A zoom off the menu (stepped to 300 %, say) shows as itself, not as "—".
let off_menu = format!("{:.0}%", z * 100.0);
if !opts.iter().any(|(v, _)| *v == z) {
let at = opts.iter().position(|(v, _)| *v > z).unwrap_or(opts.len());
opts.insert(at, (z, &off_menu));
}
if widgets::dropdown(&mut zb, "gallery-zoom", &mut z, &opts, 110.0) {
d.zoom = z;
}
@@ -879,6 +914,67 @@ mod tests {
app
}
/// ⌘+ / ⌘− step the preview along the canvas's zoom ladder as Liquify's do, from the zoom Fit
/// in View shows; ⌘0 fits it and ⌘1 is 100 %, through the canvas's own shortcuts; the canvas
/// behind keeps its zoom.
#[test]
fn zoom_shortcuts_zoom_the_preview_not_the_canvas() {
let ctx = egui::Context::default();
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
// Large enough that Fit in View is well under 100 % in a 1000 × 700 window.
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.session.execute("file.new", json!({"width": 3000, "height": 2000})).unwrap();
app.sync_views();
open(&mut app).unwrap();
let canvas_zoom = app.ui.views.first().map(|v| v.zoom);
let mut run = |key: Option<egui::Key>| {
let events = key
.map(|key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND })
.into_iter()
.collect();
let screen = ERect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 700.0));
let raw = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
crate::shortcuts::handle(&mut app, ui.ctx());
show(&mut app, ui.ctx());
});
out.textures_delta.clear();
let d = app.distort.gallery.as_ref().unwrap();
(d.zoom, d.fit)
};
let (zoom, fit) = run(None);
assert_eq!(zoom, 0.0, "opens at Fit in View");
assert!(fit > 0.0 && fit < 0.5, "fits a 3000 px image well under 100 %: {fit}");
let step = |z, dir| crate::zoom_levels::step_in_range(z, dir, ZOOM_MIN, ZOOM_MAX);
let (zoom, _) = run(Some(egui::Key::Equals));
assert_eq!(zoom, step(fit, 1), "⌘= steps up from the fitted zoom, not from the menu's first entry");
assert!(zoom > fit);
assert_eq!(run(Some(egui::Key::Plus)).0, step(step(fit, 1), 1), "⌘+ typed as Plus too");
assert_eq!(run(Some(egui::Key::Num0)).0, 0.0, "Fit in View");
assert!(run(Some(egui::Key::Minus)).0 < fit, "⌘− steps down from the fitted zoom");
assert_eq!(run(Some(egui::Key::Num1)).0, 1.0);
assert_eq!(run(Some(egui::Key::Equals)).0, 2.0);
assert_eq!(run(Some(egui::Key::Equals)).0, 3.0, "past the menu's steps, on the canvas's ladder");
assert_eq!(run(Some(egui::Key::Minus)).0, 2.0);
assert!(app.distort.gallery.is_some(), "the dialog stays open");
assert_eq!(app.ui.views.first().map(|v| v.zoom), canvas_zoom, "the canvas behind keeps its zoom");
}
/// A huge image can fit below the 1 % minimum: Zoom Out keeps Fit in View rather than
/// enlarging it to 1 %, and Zoom In still steps up from there.
#[test]
fn zoom_out_from_a_fit_below_the_minimum_stays_at_fit() {
let mut app = app();
open(&mut app).unwrap();
let d = app.distort.gallery.as_mut().unwrap();
d.zoom = 0.0;
d.fit = 0.005;
assert_eq!(step_zoom(d, -1), 0.0, "no smaller zoom than Fit: stays at Fit in View");
assert!(step_zoom(d, 1) > d.fit);
d.fit = 0.05;
assert!(step_zoom(d, -1) > 0.0 && step_zoom(d, -1) < 0.05, "a Fit above the minimum still steps down");
}
#[test]
fn stack_editing_and_commit_match_the_engine() {
let ctx = egui::Context::default();
+93 -19
View File
@@ -12,7 +12,8 @@ use crate::{PhotocraftApp, widgets};
/// `edit.transform` params that scale the content box `b` = [x0, y0, x1, y1] to `w` x `h`, keeping
/// the top-left corner (Photoshop's Properties W/H fields), or `layer.translate` params for X/Y.
pub fn transform_params(layer: u64, b: [i32; 4], w: Option<f32>, h: Option<f32>, linked: bool) -> Option<Value> {
/// `tol` is the smallest change, in px, that counts as an edit (see [`edit_tolerance`]).
pub fn transform_params(layer: u64, b: [f64; 4], w: Option<f32>, h: Option<f32>, linked: bool, tol: f32) -> Option<Value> {
let (bw, bh) = ((b[2] - b[0]) as f32, (b[3] - b[1]) as f32);
if bw <= 0.0 || bh <= 0.0 {
return None;
@@ -25,7 +26,7 @@ pub fn transform_params(layer: u64, b: [i32; 4], w: Option<f32>, h: Option<f32>,
nw = (nh * bw / bh).max(1.0);
}
}
if (nw - bw).abs() < 0.5 && (nh - bh).abs() < 0.5 {
if (nw - bw).abs() < tol && (nh - bh).abs() < tol {
return None;
}
let (x0, y0) = (b[0] as f32, b[1] as f32);
@@ -35,7 +36,21 @@ pub fn transform_params(layer: u64, b: [i32; 4], w: Option<f32>, h: Option<f32>,
/// A number field that reports a value once committed (drag released, Enter, focus lost). `label` is its own
/// label and the one stacked with it in the same column ("W" over "X"), which share a centre.
fn field(ui: &mut egui::Ui, id: &str, label: (&str, &str), current: f32, width: f32, u: LenUnit, extent: f64) -> Option<f32> {
/// The smallest W/H change, in px, that counts as an edit. A pixel frame is whole pixels: under
/// half a pixel is noise. A shape's path is fractional and is scaled to exactly the typed size,
/// so anything past the field's own rounding (half its last shown decimal) is an edit: 400.25 px
/// typed as 400 applies, while focusing and leaving the field (which re-reads its rounded text)
/// doesn't.
fn edit_tolerance(layer: &Layer, u: LenUnit, extent: f64) -> f32 {
match &layer.content {
LayerContent::Shape(sh) if !sh.path.inverted => ((u.px(0.05, extent) - u.px(0.0, extent)).abs() as f32 + 1e-3).min(0.5),
_ => 0.5,
}
}
/// `len` is the unit shown and its 100% extent (for percent).
fn field(ui: &mut egui::Ui, id: &str, label: (&str, &str), current: f32, width: f32, len: (LenUnit, f64), tol: f32) -> Option<f32> {
let (u, extent) = len;
stacked_label(ui, tl!(label.0), &[tl!(label.0), tl!(label.1)], LABEL_W, 2.0);
let key = ui.make_persistent_id(("layer-props-field", id));
let mut v = ui.data(|d| d.get_temp::<f32>(key)).unwrap_or_else(|| u.shown(f64::from(current), extent));
@@ -49,7 +64,7 @@ fn field(ui: &mut egui::Ui, id: &str, label: (&str, &str), current: f32, width:
return None;
}
let px = u.px(v, extent) as f32;
((px - current).abs() >= 0.5).then_some(px)
((px - current).abs() >= tol).then_some(px)
}
/// Lengths in the Units & Rulers unit, as Photoshop's Properties panel shows them (cm when the
@@ -116,6 +131,17 @@ fn align_glyph(ui: &egui::Ui, r: Rect, kind: &str, color: egui::Color32) {
}
}
/// The box the Transform fields show and edit, [x0, y0, x1, y1]: the layer's pixel frame `px`,
/// or for a shape its path's geometry, as Photoshop's Properties shows it, without the pixels its
/// stroke adds around it; W/H then scale the path to exactly the typed size.
fn frame(layer: &Layer, px: photocraft_geom::Rect) -> [f64; 4] {
let path = match &layer.content {
LayerContent::Shape(sh) if !sh.path.inverted => sh.path.control_bounds().filter(|c| c.2 > c.0 && c.3 > c.1),
_ => None,
};
path.map_or([f64::from(px.x0), f64::from(px.y0), f64::from(px.x1), f64::from(px.y1)], |c| [c.0, c.1, c.2, c.3])
}
/// Width of the link toggle between the W and H fields (the X/Y row leaves the same gap).
const LINK_W: f32 = 20.0;
@@ -125,36 +151,36 @@ const LINK_W: f32 = 20.0;
pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
let mut run: Vec<(String, Value)> = Vec::new();
if let Some(s) = layer.surface() {
let b = app.layer_frame_bounds(layer, s);
let bb = frame(layer, app.layer_frame_bounds(layer, s));
let u = LenUnit::of(app);
let (dw, dh) = app.session.active().map_or((1.0, 1.0), |s| (f64::from(s.doc.size.width), f64::from(s.doc.size.height)));
let (tw, th) = (edit_tolerance(layer, u, dw), edit_tolerance(layer, u, dh));
if section(ui, "transform", tl!("Transform")) {
let link_key = egui::Id::new("layer-props-link");
let linked = ui.data(|d| d.get_temp::<bool>(link_key)).unwrap_or(true);
let bb = [b.x0, b.y0, b.x1, b.y1];
// Two label+field columns with the link toggle between them, filling the panel.
let w = ((ui.available_width() - 2.0 * (LABEL_W + LABEL_GAP) - LINK_W - 2.0 * LABEL_GAP) / 2.0).max(36.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = LABEL_GAP;
if let Some(v) = field(ui, "w", ("W", "X"), b.width() as f32, w, u, dw) {
run.extend(transform_params(layer.id.0, bb, Some(v), None, linked).map(|p| ("edit.transform".to_string(), p)));
if let Some(v) = field(ui, "w", ("W", "X"), (bb[2] - bb[0]) as f32, w, (u, dw), tw) {
run.extend(transform_params(layer.id.0, bb, Some(v), None, linked, tw).map(|p| ("edit.transform".to_string(), p)));
}
if crate::icons::button(ui, if linked { "link" } else { "unlink" }, LINK_W, linked, tl!("Link width and height")).clicked() {
ui.data_mut(|d| d.insert_temp(link_key, !linked));
}
if let Some(v) = field(ui, "h", ("H", "Y"), b.height() as f32, w, u, dh) {
run.extend(transform_params(layer.id.0, bb, None, Some(v), linked).map(|p| ("edit.transform".to_string(), p)));
if let Some(v) = field(ui, "h", ("H", "Y"), (bb[3] - bb[1]) as f32, w, (u, dh), th) {
run.extend(transform_params(layer.id.0, bb, None, Some(v), linked, th).map(|p| ("edit.transform".to_string(), p)));
}
});
ui.add_space(ROW_GAP);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = LABEL_GAP;
if let Some(v) = field(ui, "x", ("X", "W"), b.x0 as f32, w, u, dw) {
run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": (v - b.x0 as f32).round() as i32, "dy": 0})));
if let Some(v) = field(ui, "x", ("X", "W"), bb[0] as f32, w, (u, dw), 0.5) {
run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": (v - bb[0] as f32).round() as i32, "dy": 0})));
}
ui.add_space(LINK_W + LABEL_GAP);
if let Some(v) = field(ui, "y", ("Y", "H"), b.y0 as f32, w, u, dh) {
run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": 0, "dy": (v - b.y0 as f32).round() as i32})));
if let Some(v) = field(ui, "y", ("Y", "H"), bb[1] as f32, w, (u, dh), 0.5) {
run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": 0, "dy": (v - bb[1] as f32).round() as i32})));
}
});
ui.add_space(ROW_GAP);
@@ -333,13 +359,13 @@ mod tests {
#[test]
fn width_edit_scales_from_the_top_left() {
let p = transform_params(7, [10, 20, 110, 70], Some(200.0), None, true).unwrap();
let p = transform_params(7, [10.0, 20.0, 110.0, 70.0], Some(200.0), None, true, 0.5).unwrap();
assert_eq!(p["layer"], 7);
assert_eq!(p["quad"], json!([[10.0, 20.0], [210.0, 20.0], [210.0, 120.0], [10.0, 120.0]]));
let p = transform_params(7, [10, 20, 110, 70], None, Some(25.0), false).unwrap();
let p = transform_params(7, [10.0, 20.0, 110.0, 70.0], None, Some(25.0), false, 0.5).unwrap();
assert_eq!(p["quad"][2], json!([110.0, 45.0]));
assert!(transform_params(7, [10, 20, 110, 70], Some(100.0), None, true).is_none());
assert!(transform_params(7, [0, 0, 0, 0], Some(5.0), None, true).is_none());
assert!(transform_params(7, [10.0, 20.0, 110.0, 70.0], Some(100.0), None, true, 0.5).is_none());
assert!(transform_params(7, [0.0, 0.0, 0.0, 0.0], Some(5.0), None, true, 0.5).is_none());
}
#[test]
@@ -351,9 +377,57 @@ mod tests {
s.execute("select.deselect", json!({})).unwrap();
let st = s.active().unwrap();
let id = st.active_layer.unwrap();
let p = transform_params(id.0, [10, 20, 110, 70], Some(50.0), None, true).unwrap();
let p = transform_params(id.0, [10.0, 20.0, 110.0, 70.0], Some(50.0), None, true, 0.5).unwrap();
s.execute("edit.transform", p).unwrap();
let b = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds();
assert_eq!((b.x0, b.y0, b.width(), b.height()), (10, 20, 50, 25));
}
/// A stroked shape's Transform fields show its path, not the stroke's pixels, and a W edit
/// scales the path to that width, as in Photoshop.
#[test]
fn shape_fields_show_and_scale_the_path_without_its_stroke() {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 800, "height": 600})).unwrap();
s.execute("shape.create", json!({"kind": "rect", "rect": [200, 150, 400, 300], "fill": "#3366cc", "stroke": {"width": 15, "color": "#000000"}}))
.unwrap();
let st = s.active().unwrap();
let id = st.active_layer.unwrap();
let layer = st.doc.layer(id).unwrap();
let px = photocraft_compose::bounds::content_bounds(layer.surface().unwrap());
assert!(px.width() > 400, "the stroke's pixels reach past the path: {px:?}");
let bb = frame(layer, px);
assert_eq!(bb, [200.0, 150.0, 600.0, 450.0]);
s.execute("edit.transform", transform_params(id.0, bb, Some(200.0), None, true, 0.5).unwrap()).unwrap();
let LayerContent::Shape(sh) = &s.active().unwrap().doc.layer(id).unwrap().content else { panic!("still a shape") };
let c = sh.path.control_bounds().unwrap();
assert_eq!((c.0, c.1, c.2 - c.0, c.3 - c.1), (200.0, 150.0, 200.0, 150.0), "the path is exactly 200 × 150");
}
/// #9181 review: a shape's fractional path takes a W edit smaller than half a pixel (400.25
/// px typed as 400), while a pixel layer still ignores sub-pixel noise.
#[test]
fn a_shape_path_takes_a_width_edit_under_half_a_pixel() {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 800, "height": 600})).unwrap();
s.execute("shape.create", json!({"kind": "rect", "rect": [200, 150, 400.25, 100], "fill": "#3366cc"})).unwrap();
let st = s.active().unwrap();
let id = st.active_layer.unwrap();
let layer = st.doc.layer(id).unwrap();
let u = LenUnit { unit: photocraft_engine::prefs::Unit::Pixels, dpi: 72.0, ppi: 72.0 };
let bb = frame(layer, photocraft_compose::bounds::content_bounds(layer.surface().unwrap()));
assert_eq!(bb[2] - bb[0], 400.25);
let tol = edit_tolerance(layer, u, 800.0);
assert!(tol < 0.25, "{tol}");
let p = transform_params(id.0, bb, Some(400.0), None, false, tol).expect("a quarter-pixel edit applies");
s.execute("edit.transform", p).unwrap();
let LayerContent::Shape(sh) = &s.active().unwrap().doc.layer(id).unwrap().content else { panic!("still a shape") };
let c = sh.path.control_bounds().unwrap();
assert!((c.2 - c.0 - 400.0).abs() < 1e-6, "the path is exactly 400 wide: {c:?}");
// The field's own rounding (400.25 shown as 400.3) is not an edit.
assert!(transform_params(id.0, bb, Some(400.3), None, false, tol).is_none());
s.execute("layer.new.layer", json!({})).unwrap();
let st = s.active().unwrap();
assert_eq!(edit_tolerance(st.doc.layer(st.active_layer.unwrap()).unwrap(), u, 800.0), 0.5, "a pixel layer keeps half a pixel");
}
}
+33
View File
@@ -1558,6 +1558,39 @@ mod tests {
assert_eq!(rows, KINDS.len(), "one empty checkbox per effect row");
}
// Regression test for #2209: Make Default and Reset to Default sat in a non-wrapping row, so
// translated labels wider than the page column (Russian) pushed one button over the other
// and out of the column instead of wrapping to a second row.
#[test]
fn default_buttons_wrap_inside_the_page_column_with_long_labels() {
use egui_kittest::kittest::Queryable;
let ru = crate::i18n::Lang::from_code("ru").unwrap();
crate::i18n::with_language(ru, || {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
let l = Layer::raster("x", photocraft_doc::PixelFormat::RGBA8);
let fields = initial_fields(&l, Some("dropShadow"), 120.0);
let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state(
move |ui, app: &mut PhotocraftApp| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
let mut f = fields.clone();
body(app, ui, &mut f);
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium);
h.run_steps(3);
let reset = h.get_by_role_and_label(egui::accesskit::Role::Button, crate::i18n::tr(ru, "Reset to Default")).rect();
let make = h.get_by_role_and_label(egui::accesskit::Role::Button, crate::i18n::tr(ru, "Make Default")).rect();
// Together they are wider than the page column: the second wraps to a row below
// instead of sharing the row and running left over the effect list.
assert!(make.top() >= reset.bottom(), "the buttons share one row: {make:?} {reset:?}");
assert_eq!(make.right(), reset.right(), "the wrapped row stays right-aligned");
});
}
/// Double-clicking an effect row of a layer that isn't active selects that layer first, so
/// the dialog holds its effects (and OK edits it), not the active layer's.
#[test]
+1 -1
View File
@@ -3304,7 +3304,7 @@ impl PhotocraftApp {
let r = photocraft_geom::Rect::new(0, 0, w as i32, h as i32);
let mut surface = photocraft_raster::Surface::from_interleaved(photocraft_color::PixelFormat::RGBA8, r, &bytes);
surface.prune();
self.session.clipboard = Some(photocraft_engine::edit_cmds::Clip { surface, bounds: r, layers: None, resolution: None });
self.session.clipboard = Some(photocraft_engine::edit_cmds::Clip { surface, bounds: r, layers: None, resolution: None, source: None });
self.os_clip_sig = Some(sig);
self.clip_external = true;
self.clip_unmirrored = false;
+74
View File
@@ -1736,6 +1736,80 @@ mod tests {
assert_eq!(app.distort.liquify.as_ref().unwrap().strokes.len(), 1, "Cmd+Shift+Z redoes the last stroke");
}
// Regression test for #4134: Cmd+Z and Cmd+Shift+Z were ignored in Liquify while a numeric
// field's text edit (Size, Pressure, ...) had keyboard focus, so a stroke could not be undone.
#[test]
fn liquify_undo_and_redo_work_while_a_numeric_field_is_focused() {
let ctx = egui::Context::default();
let mut app = app_with_layer();
open(&mut app, &ctx).unwrap();
for ev in [ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 }, ToolEvent::Move { x: 52.0, y: 40.0, pressure: 1.0 }, ToolEvent::Up { x: 52.0, y: 40.0 }]
{
pointer(&mut app, ev, egui::Modifiers::NONE);
}
let strokes = |app: &PhotocraftApp| app.distort.liquify.as_ref().unwrap().strokes.len();
assert_eq!(strokes(&app), 1);
// One frame of the real shortcut handler, then a focused single-line text edit (the
// numeric fields are text edits), as the app orders them.
let mut text = String::from("100");
let field = egui::Id::new("liquify-size-field");
let mut frame = |app: &mut PhotocraftApp, events: Vec<egui::Event>| {
let raw = egui::RawInput { events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
crate::shortcuts::handle(app, ui.ctx());
let edit = ui.add(egui::TextEdit::singleline(&mut text).id(field));
edit.request_focus();
});
out.textures_delta.clear();
};
frame(&mut app, Vec::new());
assert!(ctx.text_edit_focused(), "the numeric field is a focused text edit");
let z = |modifiers| egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: true, repeat: false, modifiers };
frame(&mut app, vec![z(egui::Modifiers::COMMAND)]);
assert_eq!(strokes(&app), 0, "Cmd+Z undoes the stroke with the field focused");
frame(&mut app, vec![z(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT)]);
assert_eq!(strokes(&app), 1, "Cmd+Shift+Z redoes it");
}
// Regression test for #4134: other Liquify keys must not steal characters from a focused
// numeric field, and the bracket keys step the brush by at least one pixel.
#[test]
fn liquify_tool_keys_leave_a_focused_field_alone_and_brackets_reach_one_pixel() {
let ctx = egui::Context::default();
let mut app = app_with_layer();
open(&mut app, &ctx).unwrap();
app.distort.liquify.as_mut().unwrap().opts.size = 2.0;
let size = |app: &PhotocraftApp| app.distort.liquify.as_ref().unwrap().opts.size;
let press = |app: &mut PhotocraftApp, key: egui::Key| {
let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx()));
out.textures_delta.clear();
};
// 2 px * 0.9 used to round back to 2, so `[` never reached 1 px; `]` steps up from 1.
press(&mut app, egui::Key::OpenBracket);
assert_eq!(size(&app), 1.0, "`[` steps a 2 px brush down to 1 px");
press(&mut app, egui::Key::CloseBracket);
assert_eq!(size(&app), 2.0, "`]` steps it back up");
// With a text edit focused, `[` belongs to the field.
let mut text = String::new();
let field = egui::Id::new("liquify-size-field");
let focused_frame = |app: &mut PhotocraftApp, text: &mut String, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
crate::shortcuts::handle(app, ui.ctx());
ui.add(egui::TextEdit::singleline(text).id(field)).request_focus();
});
out.textures_delta.clear();
};
focused_frame(&mut app, &mut text, Vec::new());
assert!(ctx.text_edit_focused());
let bracket = egui::Event::Key { key: egui::Key::OpenBracket, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
focused_frame(&mut app, &mut text, vec![bracket]);
assert_eq!(size(&app), 2.0, "the focused field keeps its keys");
}
fn key(key: egui::Key) -> egui::Event {
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }
}
@@ -86,6 +86,28 @@ fn deleting_the_vector_mask_prunes_its_thumbnail() {
assert!(!h.state().thumbs.contains_key(&key));
}
// Regression test for #119: the Layers panel's thumbnail textures of a deleted layer, and of
// every layer of a closed document, were kept for the rest of the session.
#[test]
fn deleting_a_layer_or_closing_its_document_releases_the_thumbnails() {
let (s, masked, shape) = session();
let mut h = harness(s, 0, 1.0, 290.0);
let layer_key = |id: u64| (photocraft_doc::LayerId(id), crate::mask_thumbs_ui::THUMB_LAYER);
assert!(h.state().thumbs.contains_key(&layer_key(masked)) && h.state().thumbs.contains_key(&layer_key(shape)), "both rows drew a thumbnail");
let before = h.state().thumbs.len();
h.state_mut().run("layer.delete", json!({"layer": shape})).unwrap();
h.run_steps(3);
assert!(!h.state().thumbs.contains_key(&layer_key(shape)), "the deleted layer's thumbnail is released");
assert!(h.state().thumbs.contains_key(&layer_key(masked)), "the other layer keeps its thumbnail");
assert!(h.state().thumbs.len() < before);
h.state_mut().run("file.close", json!({})).unwrap();
h.run_steps(3);
assert!(h.state().session.documents().is_empty());
assert!(h.state().thumbs.is_empty(), "no thumbnails outlive their document: {:?}", h.state().thumbs.keys().collect::<Vec<_>>());
}
#[test]
fn footer_trash_deletes_only_the_targeted_mask_linked_or_unlinked() {
for linked in [true, false] {
+20
View File
@@ -699,6 +699,11 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<Strin
}
}
});
// Under Orientation, as in Photoshop: the document starts as one artboard (#9086).
let mut artboard = f.get("artboard").and_then(Value::as_bool).unwrap_or(false);
if widgets::checkbox(ui, &mut artboard, tl!("Artboards")).changed() {
f.insert("artboard".into(), json!(artboard));
}
ui.add_space(4.0);
small_label(ui, tl!("Resolution"));
ui.horizontal(|ui| {
@@ -881,6 +886,21 @@ mod tests {
assert_eq!(f["profile"], "coated-cmyk");
}
/// #9086: the Artboards checkbox's field reaches `file.new`, which starts the document as one
/// artboard; the dialog's own `__` keys don't.
#[test]
fn the_artboards_box_creates_an_artboard_document() {
let mut s = photocraft_engine::Session::new();
let mut f = Map::new();
for (k, v) in [("width", json!(64)), ("height", json!(48)), ("artboard", json!(true)), ("__orientation", json!("landscape"))] {
f.insert(k.into(), v);
}
s.execute("file.new", command_params(&f)).unwrap();
let doc = &s.active().unwrap().doc;
assert_eq!(doc.artboards().len(), 1);
assert_eq!(doc.artboards()[0].2.rect, doc.bounds());
}
#[test]
fn units_round_trip_through_pixels() {
assert_eq!(to_unit(2100.0, "in", 300.0), 7.0);
@@ -197,3 +197,29 @@ fn options_tab_moves_from_hardness_to_opacity_without_a_caption_stop() {
assert_eq!(h.state().session.tools.brush.hardness, 0.5);
assert!((h.state().session.tools.brush.opacity - 0.99).abs() < 0.0001);
}
// Regression test for #3425: the options bar drew its own 1 px top and bottom separator inside
// the panel, on top of the panel's own border, so the borders doubled (three lines, not one).
#[test]
fn options_bar_has_a_single_full_width_border() {
for kind in [crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Classic] {
let mut h = harness();
PhotocraftApp::setup_context(&h.ctx, kind);
h.run_steps(4);
let separator = crate::theme::Tokens::get(&h.ctx).separator;
let borders: Vec<_> = h
.output()
.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::LineSegment { points, stroke }
if stroke.color == separator && (points[0].y - points[1].y).abs() < 0.01 && (points[1].x - points[0].x).abs() > 400.0 =>
{
Some(*points)
}
_ => None,
})
.collect();
assert_eq!(borders.len(), 1, "{kind:?}: the options bar has exactly one border, the panel's (its own would double it): {borders:?}");
}
}
+32
View File
@@ -6672,6 +6672,38 @@ mod toolbar_tests {
}
}
// Regression test for #2241: Quick Mask and Screen Mode ignored the two-column toolbar and
// stayed stacked in one column; in two columns they sit side by side.
#[test]
fn quick_mask_and_screen_mode_follow_the_toolbar_columns() {
for theme in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::Classic] {
let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 1400.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
toolbar(app, ui);
}
},
app,
);
PhotocraftApp::setup_context(&h.ctx, theme);
h.run_steps(2);
let modes = |h: &egui_kittest::Harness<'_, PhotocraftApp>| {
(h.get_by_label("Edit in Quick Mask Mode (Q)").rect(), h.get_by_label("Change Screen Mode (F)").rect())
};
let (quick, screen) = modes(&h);
assert_eq!(quick.left(), screen.left(), "{theme:?}: one column stacks them");
assert!(screen.top() >= quick.bottom(), "{theme:?}: Screen Mode below Quick Mask: {quick:?} {screen:?}");
h.get_by_label("Toolbar").click();
h.run_steps(2);
assert!(h.state().ui.panels.toolbar_double, "{theme:?}");
let (quick, screen) = modes(&h);
assert_eq!(quick.top(), screen.top(), "{theme:?}: two columns put them on one row: {quick:?} {screen:?}");
assert!(screen.left() >= quick.right(), "{theme:?}: Screen Mode beside Quick Mask: {quick:?} {screen:?}");
}
}
fn click(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, p: egui::Pos2) {
h.hover_at(p);
h.run_steps(1);
+2 -1
View File
@@ -176,9 +176,10 @@ pub fn paint(app: &mut PhotocraftApp, painter: &Painter, xf: &ViewXform, idx: us
cache.grid = Some(GridLines::build(&doc, revision, preview_key, cache_region(visible, doc.bounds())));
}
let Some(grid) = &cache.grid else { return };
let width = 1.0 / painter.ctx().pixels_per_point();
for s in &grid.segs {
let (a, b) = (xf.to_screen(s.from[0] as f32, s.from[1] as f32), xf.to_screen(s.to[0] as f32, s.to[1] as f32));
painter.line_segment([a, b], Stroke::new(1.0, s.color));
painter.line_segment([a, b], Stroke::new(width, s.color));
}
}
+4
View File
@@ -473,6 +473,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
crate::liquify_ui::keys(app, ctx);
}
}
// The Filter Gallery's preview takes the zoom keys from the canvas behind it.
if app.distort.gallery.is_some() && !ctx.text_edit_focused() {
crate::gallery_ui::keys(app, ctx);
}
// The Content-Aware Fill workspace likewise.
if app.distort.caf.is_some() && !ctx.text_edit_focused() {
crate::caf_ui::keys(app, ctx);
+5
View File
@@ -1536,6 +1536,10 @@ pub struct UiState {
/// Preset panels (Gradients, Patterns, Styles, Shapes, Tool Presets, Clone Source).
#[serde(default)]
pub presets_ui: crate::preset_panels::PresetUi,
/// Save for Web: the preview tab last chosen (`original`, `optimized`, `2up`, `4up`), which the
/// dialog reopens on (#9174).
#[serde(default)]
pub web_view: Option<String>,
/// Character/Paragraph Styles, Glyphs and Check Spelling (see `type_panels_ui`).
#[serde(default)]
pub type_panels: crate::type_panels_ui::TypePanelsUi,
@@ -1704,6 +1708,7 @@ impl Default for UiState {
actions: Default::default(),
layer_comp_selected: None,
presets_ui: Default::default(),
web_view: None,
type_panels: Default::default(),
analysis: Default::default(),
timeline: Default::default(),
+23
View File
@@ -1131,6 +1131,29 @@ mod tests {
assert_eq!(ctx.zoom_factor(), 1.0);
}
// Regression test for #4630: the stock thin scroll bar fades its handle to the same opacity
// as its track while the pointer is on it, so the thumb disappeared when it was grabbed.
#[test]
fn scrollbar_thumb_stays_more_visible_than_its_track_in_every_theme() {
for kind in ThemeKind::ALL {
let ctx = egui::Context::default();
apply(&ctx, kind);
for theme in [egui::Theme::Dark, egui::Theme::Light] {
let scroll = ctx.style_of(theme).spacing.scroll;
if !scroll.floating {
continue;
}
assert!(
scroll.interact_handle_opacity > scroll.interact_background_opacity,
"{kind:?}/{theme:?}: handle {} vs track {} under the pointer",
scroll.interact_handle_opacity,
scroll.interact_background_opacity
);
assert_eq!(scroll.interact_handle_opacity, 1.0, "{kind:?}/{theme:?}: the thumb is opaque under the pointer");
}
}
}
/// The desktop app installs fonts and loads the saved UI Font Size in its first frame, while
/// egui's default fonts are still active. Any size but Small then panicked on the next pass
/// ("FontFamily::Name("medium") is not bound to any fonts") because the plugin rescaled a
+50
View File
@@ -4864,6 +4864,56 @@ mod tests {
assert_eq!(rotate_cursor_dir(&app, [f64::NAN, 32.0]), None);
}
// Regression test for #4139: the rotate zone hides the OS pointer (`CursorIcon::None`), so the
// canvas has to draw the curved turn arrow itself where the box has no preview to draw it
// (the preview's overlay draws it otherwise); the pointer vanished over the zone.
#[test]
fn the_rotate_zone_draws_the_turn_arrow_where_the_pointer_is_hidden() {
let mut app = app_with_square(256, photocraft_geom::Rect::new(32, 32, 160, 160));
app.ui.tool = Tool::Move;
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
app
});
h.run_steps(4);
let ctx = h.ctx.clone();
crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap();
h.run_steps(3);
let tol = handle_tolerance(h.state());
let xf = ViewXform::active(h.state()).unwrap();
let accent = crate::theme::Tokens::get(&h.ctx).accent_text;
// The arrow's bright stroke: a 17-point arc in the accent colour around the pointer.
let arrows = |h: &egui_kittest::Harness<'_, PhotocraftApp>, at: egui::Pos2| {
h.output()
.shapes
.iter()
.filter(|c| match &c.shape {
egui::Shape::Path(p) => {
p.points.len() == 17
&& p.stroke.width == 1.5
&& p.stroke.color == egui::epaint::ColorMode::Solid(accent)
&& p.points.iter().all(|q| q.distance(at) < 40.0)
}
_ => false,
})
.count()
};
let outside = xf.to_screen((32.0 - tol * 1.5) as f32, (32.0 - tol * 1.5) as f32);
// A box with no preview (its overlay is what draws the arrow otherwise).
h.state_mut().transform_preview = None;
h.hover_at(outside);
h.run_steps(3);
assert!(h.state().ui.transform.is_some() && h.state().transform_preview.is_none());
assert_eq!(h.output().platform_output.cursor_icon, CursorIcon::None, "the OS pointer is hidden in the rotate zone");
assert_eq!(arrows(&h, outside), 1, "and the curved arrow is drawn in its place");
let inside = xf.to_screen(96.0, 96.0);
h.hover_at(inside);
h.run_steps(3);
assert_eq!(arrows(&h, inside), 0, "no arrow over the box");
}
/// The resize arrows follow the box's turn and the view's rotation and flip: each handle
/// shows the arrow it resizes along on screen, not the one it had on the upright box.
#[test]
+2 -2
View File
@@ -1170,8 +1170,8 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
None => {
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut s).desired_width(260.0));
// A folder field gets the system folder picker (#7942).
if k == "dir" && crate::widgets::secondary_button(ui, tl!("Browse…"), 0.0).clicked() {
// A folder field gets the system folder picker (#7942, #9357).
if crate::dialogs::is_folder_field(&k) && crate::widgets::secondary_button(ui, tl!("Browse…"), 0.0).clicked() {
browse = Some(k.clone());
}
});
+70 -18
View File
@@ -20,8 +20,12 @@ fn harness() -> Option<Harness> {
/// The app with the GPU canvas (`gpu`), or without it: the CPU fallback path.
fn harness_with(gpu: bool) -> Option<Harness> {
harness_with_scale(gpu, 1.0)
}
fn harness_with_scale(gpu: bool, ppp: f32) -> Option<Harness> {
let built = std::panic::catch_unwind(|| {
egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 800.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(|cc| {
egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 800.0)).with_pixels_per_point(ppp).with_max_steps(64).wgpu().build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
if let Some(rs) = cc.wgpu_render_state.as_ref().filter(|_| gpu) {
@@ -111,13 +115,15 @@ fn render_on(h: &mut Harness, gpu: bool, zoom: f32, doc: u32, black: bool, grid:
let rendered = h.render().expect("render");
let (width, height) = (rendered.width(), rendered.height());
let img = Shot { w: width, px: rendered.into_raw() };
if black {
// View geometry also works on scaled displays, independently of grid colour.
let origin = photocraft_ui_egui::canvas::ViewXform::active(h.state()).expect("view").to_screen(0.0, 0.0) * h.ctx.pixels_per_point();
return (img, origin.x.round() as u32, origin.y.round() as u32, (doc as f32 * zoom).round() as u32);
}
let r = h.state().last_canvas_rect;
let (cx, cy) = (r.center().x as u32, r.center().y as u32);
// Black fill is 0 (64 on a grid line), the pasteboard is 39.
// (Two CPU grid lines crossing blend twice: about 112.)
let on_doc = |p: [u8; 4]| if black { p[0] < 12 || (58..=70).contains(&p[0]) || (105..=120).contains(&p[0]) } else { is_checker_grey(p) };
let (x0, w) = longest_run((0..width).map(|x| on_doc(img.at(x, cy))));
let (y0, hgt) = longest_run((0..height).map(|y| on_doc(img.at(cx, y))));
let (x0, w) = longest_run((0..width).map(|x| is_checker_grey(img.at(x, cy))));
let (y0, hgt) = longest_run((0..height).map(|y| is_checker_grey(img.at(cx, y))));
let doc_px = (doc as f32 * zoom).round() as i64;
assert!((w as i64 - doc_px).abs() <= 1 && (hgt as i64 - doc_px).abs() <= 1, "zoom {zoom}: found a {w}x{hgt} document, want {doc_px}");
(img, x0 as u32, y0 as u32, w as u32)
@@ -210,18 +216,20 @@ fn pixel_grid_is_not_drawn_over_empty_checker() {
#[test]
fn cpu_pixel_grid_shows_above_500_percent_over_content() {
let _gpu = photocraft_testkit::gpu_lock();
let Some(mut h) = harness_with(false) else { return };
h.run_steps(4);
let (flat, x0, y0, _) = render_on(&mut h, false, 5.0, 48, true, true);
assert!(lit_columns(&flat, x0, y0 + 20, 100).is_empty(), "no grid at 500%");
for zoom in [6.0f32, 8.0, 12.0] {
let (img, x0, y0, _) = render_on(&mut h, false, zoom, 48, true, true);
let y = y0 + (zoom * 3.5) as u32;
let lit = lit_columns(&img, x0, y, (zoom * 10.0) as u32);
assert_eq!(lit.len(), 10, "zoom {zoom}: grid columns {lit:?}");
assert!(lit.windows(2).all(|p| p[1] - p[0] == zoom as u32), "zoom {zoom}: spacing of {lit:?}");
let v = img.at(x0 + lit[2], y)[0];
assert!((60..=68).contains(&v), "zoom {zoom}: a grid line over black is {v}, want about 64");
for ppp in [1.0, 2.0] {
let Some(mut h) = harness_with_scale(false, ppp) else { return };
h.run_steps(4);
let (flat, x0, y0, _) = render_on(&mut h, false, 5.0, 48, true, true);
assert!(lit_columns(&flat, x0, y0 + 20, 100).is_empty(), "no grid at 500%");
for zoom in [6.0f32, 8.0, 12.0] {
let (img, x0, y0, _) = render_on(&mut h, false, zoom, 48, true, true);
let y = y0 + (zoom * 3.5) as u32;
let lit = lit_columns(&img, x0, y, (zoom * 10.0) as u32);
assert_eq!(lit.len(), 10, "scale {ppp}, zoom {zoom}: one physical pixel per line, columns {lit:?}");
assert!(lit.windows(2).all(|p| p[1] - p[0] == zoom as u32), "zoom {zoom}: spacing of {lit:?}");
let v = img.at(x0 + lit[2], y)[0];
assert!((60..=68).contains(&v), "zoom {zoom}: a grid line over black is {v}, want about 64");
}
}
}
@@ -295,3 +303,47 @@ fn pixel_grid_does_not_flip_colour_across_mid_tones() {
assert!(c >= 20, "grey {grey:#04x}: grid line differs from its pixel by only {c} levels");
}
}
#[test]
fn pixel_grid_is_solid_and_visible_on_white_and_black_at_both_display_scales() {
let _gpu = photocraft_testkit::gpu_lock();
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Document, Layer, LayerContent, Size};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
for gpu in [true, false] {
for ppp in [1.0, 2.0] {
let Some(mut h) = harness_with_scale(gpu, ppp) else { return };
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for v in [0.0, 1.0] {
let mut doc = Document::new("grid contrast", Size::new(32, 16), ColorMode::Rgb, depth);
let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true));
surface.fill_rect(Rect::new(0, 0, 32, 16), &[v, v, v, 1.0]);
doc.layers.push(Layer::new("opaque", LayerContent::Raster(surface)));
let app = h.state_mut();
app.session.add_document(doc, None);
app.sync_views();
app.ui.view.show.pixel_grid = true;
for zoom in [7.119, 8.0, 22.825] {
control(&mut h, "ui.set", json!({"zoom": zoom, "center": [16.0, 8.0]}));
assert_eq!(h.state().perf.gpu, gpu);
let img = h.render().expect("render opaque grid");
let center = h.state().last_canvas_rect.center() * ppp;
let x0 = center.x - 16.0 * zoom;
let y = (center.y + 3.25 * zoom).floor() as u32;
for x in 2..30 {
let edge = (x0 + x as f32 * zoom).floor() as u32;
for sy in y..y + 3 {
let inside = img.get_pixel((x0 + (x as f32 + 0.5) * zoom).floor() as u32, sy)[0];
assert_eq!(inside, (v * 255.0) as u8);
let contrast = (edge - 1..=edge + 1).map(|sx| img.get_pixel(sx, sy)[0].abs_diff(inside)).max().expect("line");
assert!(contrast >= 28, "{gpu:?}/{depth:?}, scale {ppp}, zoom {zoom}, fill {v}, ({x}, {sy}): invisible grid ({contrast})");
}
}
}
}
}
}
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-24 · **Change:** minor (Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-24 · **Change:** minor (Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`) · **Target:** Adobe Photoshop 2026
Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and
tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write
@@ -47,7 +47,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity.
| DICOM | R/W | yes (#1048) | no | Read-only, uncompressed syntaxes: MONOCHROME1/2, RGB, YBR_FULL |
| PCX | R/W | no | no | |
| Pixar | R/W | no | no | |
| Amiga IFF | R/W | yes | no | Read-only: 1–8 planes with CMAP, stored and ByteRun1 |
| Amiga IFF | R/W | yes | yes (Save As `.iff`) | Read: 1–8 planes with CMAP and 24-plane RGB, stored and ByteRun1; write: 24-plane ILBM, ByteRun1, alpha as a 1-bit mask plane, 8-bit only |
| Wireless Bitmap | R/W | no | no | |
| MPO / JPS (stereo) | R/W | no | no | |
| PICT | R | no | no | Legacy |
@@ -145,6 +145,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
| 2026-10-24 | minor | Layered TIFF: the `LMsk` block in tag 37724 carries Photoshop's default global layer mask (14 bytes) instead of none; with an empty block Photoshop read only the composite (#9095). PSD output unchanged |
| 2026-10-24 | minor | Layered TIFF writes the TySh text version and descriptor in PSD big-endian order while retaining container order for the block header, transform matrix, warp section and bounds (#9098); the mixed-order read path remains covered by #4758/#4771. |
| 2026-10-23 | patch | PSD and layered TIFF: the `MTrn` block Photoshop writes in a layered TIFF saved with Save Transparency (a positive layer count, `nrTM` in an `II` file) marks the merged image's first extra channel as its transparency, like `Mtrn`/`Mt16`/`Mt32`: such a file no longer opens with its transparency also as an alpha channel (named "Transparency" in Photoshop 27.11's file), and a PSD save no longer writes it twice. A PSD save with an opaque composite drops any of these markers kept from the opened file, so its first alpha channel no longer reopens as transparency and vanishes (split from #7415; credit #3121) |
| 2026-10-23 | minor | Amiga IFF write: Save As `.iff` stores a 24-plane ILBM (ByteRun1, alpha as a 1-bit mask plane), and 24-plane ILBMs open |
| 2026-10-22 | minor | 32-bit Hue/Saturation as Photoshop does it: the raised saturation isn't clipped (saturated colours leave 0..1), the chroma is scaled around the lightness by Photoshop's saturation factor (measured at every whole percent), so colours at or above lightness 1 keep their hue, and Colorize builds its colour with the saturation times 1 − \|Lightness\| (8 and 16 bits unchanged): photoshop rgb32 hue-saturation and hue-saturation-colorize pass; Photoshop-oracle floor 188 → 190 of 258 (#5781) |
| 2026-10-19 | minor | JPEG Options on File › Save, Save As and Save a Copy (FILE-215-19 partial: no Matte, progressive Scans or size preview) |
| 2026-10-18 | minor | Scripted TIFF saves take `tiffCompression` (none, LZW, ZIP), and the CLI `--tiff-compression` (#5301) |
+6 -5
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -56,7 +56,7 @@ Dispatch order matches `dialogs::show` in `dialogs.rs` (check earlier rows befor
| `__resetAllTools` | `tool_reset.rs` | Reset All Tools confirmation |
| `__cropPresetNew` / `__cropPresetDelete` | `crop_presets.rs` | New / delete crop preset |
| `__variables` | `variables_ui.rs` | Image › Variables (Define, Data Sets, Apply) |
| `__web` / `__print` | `file_ui.rs` | Save for Web, Print |
| `__web` / `__print` | `file_ui.rs` | Save for Web (reopens on the preview tab chosen last, #9174), Print |
| `__colorPicker` | `color_picker_ui.rs` | Foreground/background, nested field/command pickers (see nested keys below) |
| `__colorRange` | `color_range_ui.rs` | Select › Color Range… |
| `__prefsui` | `prefs_ui.rs` | Edit › Preferences… and related preset/import subdialogs |
+2 -1
View File
@@ -1,6 +1,6 @@
# UI parity: tools, handles, modifiers and feel
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026
How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the
handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,
@@ -250,6 +250,7 @@ Paragraph; the rest open as in-app floating windows.
| 2026-10-24 | minor | Swatches: a click recolours the selected type layers and shape fills whatever the tool, as Photoshop 2025 does (measured with the Move and Brush tools); the Color panel still recolours them only with a Type, shape or Pen tool (#9238). Scores unchanged. |
| 2026-10-24 | minor | Type options bar: the alignment and paragraph-direction buttons show the edited or selected type layer's paragraph instead of the tool defaults, as Photoshop's bar does. Scores unchanged. |
| 2026-10-24 | minor | Font menus (options bar, Character panel): the current family is marked in the recent group when it is a recent one, and the menu opens showing it, as Photoshop's does; the arrows walk on from the marked row (#9160). Scores unchanged. |
| 2026-10-24 | minor | Properties › Transform shows and edits a shape layer's path geometry (W/H/X/Y), not the pixel bounds its stroke adds, as in Photoshop; W/H scale the path to exactly the typed size. Scores unchanged. |
| 2026-10-22 | minor | Crop default mode: a drag inside an upright box snaps the frame's edges and centre to the document, layers, guides and grid, as a Classic Mode frame's move does (#8580); no percentage changed |
| 2026-10-21 | minor | Adjustment clipping moved above Properties editors to remain visible in compact docks (#5231); scope, undo/redo and 8/16/32-bit pixel regressions; readiness estimate unchanged |
| 2026-10-19 | minor | A guide being moved or dragged out of a ruler shows an `X:` / `Y:` position readout beside the pointer, from the zero point in the ruler unit (#7680) |