mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-19 13:44:11 -04:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16dd4b19ad | ||
|
|
32552a61a1 | ||
|
|
f86517a029 | ||
|
|
62346f7fb6 | ||
|
|
3d0c819d46 | ||
|
|
dd708f4ce4 | ||
|
|
e79d6571ba | ||
|
|
a1a7b17ee0 | ||
|
|
79cfa2b65a | ||
|
|
bade115202 | ||
|
|
6d03075ed7 | ||
|
|
5bdb12ec51 | ||
|
|
ae0e5eb731 | ||
|
|
ad5fb53f68 | ||
|
|
a2cdc6c2d7 | ||
|
|
9f49cae093 | ||
|
|
6d21a69968 | ||
|
|
4736a4bcbe | ||
|
|
8b21621671 | ||
|
|
dbf9d0d998 |
No files matched your search
Generated
+29
-29
@@ -4182,7 +4182,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"async-io",
|
||||
@@ -4224,7 +4224,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-affinity"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4234,7 +4234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4247,7 +4247,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"cap-std",
|
||||
@@ -4269,7 +4269,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
@@ -4283,7 +4283,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
@@ -4291,7 +4291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"exr",
|
||||
"flate2",
|
||||
@@ -4312,7 +4312,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
@@ -4320,7 +4320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
@@ -4335,7 +4335,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4346,7 +4346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"exr",
|
||||
@@ -4380,7 +4380,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc32fast",
|
||||
@@ -4400,14 +4400,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"log",
|
||||
@@ -4424,14 +4424,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-heif"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc32fast",
|
||||
@@ -4460,14 +4460,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4478,7 +4478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4493,7 +4493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
@@ -4504,7 +4504,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4514,7 +4514,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4529,7 +4529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-tablet"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
@@ -4541,7 +4541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
@@ -4552,7 +4552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
@@ -4566,7 +4566,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"craft-ui",
|
||||
@@ -4605,7 +4605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
@@ -4616,7 +4616,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -7449,7 +7449,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -311,6 +311,9 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let mut reported_fonts = std::collections::HashSet::<String>::new();
|
||||
for (id, params) in cmds {
|
||||
let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?;
|
||||
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
|
||||
return Err(format!("`{id}`: {e}"));
|
||||
}
|
||||
let new_warnings: Vec<String> = h
|
||||
.session
|
||||
.active()
|
||||
|
||||
@@ -203,6 +203,50 @@ fn run_commands_and_save() {
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_stops_after_failed_action_without_exporting() {
|
||||
let d = tmp("run-failed-action");
|
||||
let out = d.join("result.png");
|
||||
let result = bin()
|
||||
.args(["run", "--new", r#"{"width":8,"height":8}"#])
|
||||
.args(["--cmd", "layer.new.layer"])
|
||||
.args(["--cmd", "actions.record", "--params", r#"{"name":"Delete"}"#])
|
||||
.args(["--cmd", "layer.delete", "--cmd", "actions.stop"])
|
||||
// The recorded deletion succeeded with two layers; replay cannot delete the last one.
|
||||
.args(["--cmd", "actions.play", "--params", r#"{"action":"Delete"}"#])
|
||||
.args(["--cmd", "document.inspect", "--out"])
|
||||
.arg(&out)
|
||||
.output()
|
||||
.unwrap();
|
||||
let error = String::from_utf8_lossy(&result.stderr);
|
||||
assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout));
|
||||
assert!(error.contains("actions.play") && error.contains("Delete") && error.contains("step 1"), "{error}");
|
||||
let rows: Vec<Value> = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run");
|
||||
assert!(!out.exists(), "a failed action must not export a partial result");
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_continues_after_successful_action_and_exports() {
|
||||
let d = tmp("run-successful-action");
|
||||
let out = d.join("result.png");
|
||||
let (stdout, _) = ok(bin()
|
||||
.args(["run", "--new", r#"{"width":8,"height":8}"#])
|
||||
.args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#])
|
||||
.args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"])
|
||||
.args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#])
|
||||
.args(["--cmd", "document.inspect", "--out"])
|
||||
.arg(&out));
|
||||
let rows: Vec<Value> = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap();
|
||||
assert_eq!(playback["result"]["ran"], 1);
|
||||
assert!(playback["result"].get("failed").is_none());
|
||||
assert!(rows.iter().any(|row| row["command"] == "document.inspect"));
|
||||
assert!(out.is_file());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_errors() {
|
||||
let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap();
|
||||
|
||||
@@ -305,27 +305,22 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
let error = self.presets.error();
|
||||
if error.is_some() || self.presets.unsaved() {
|
||||
if let Some(error) = self.presets.error() {
|
||||
egui::Panel::top("browser_preset_storage").show(ui, |ui| {
|
||||
if let Some(error) = error {
|
||||
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
|
||||
// Put retry first so floating brush windows cannot cover the control.
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
|
||||
self.presets.retry();
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
|
||||
});
|
||||
ui.label(if self.database.is_some() {
|
||||
i18n::t("Keep this tab open to retain your brushes.")
|
||||
} else {
|
||||
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
|
||||
});
|
||||
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
|
||||
// Put retry first so floating brush windows cannot cover the control.
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
|
||||
self.presets.retry();
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
|
||||
});
|
||||
ui.label(if self.database.is_some() {
|
||||
i18n::t("Keep this tab open to retain your brushes.")
|
||||
} else {
|
||||
ui.label(i18n::t("Saving brush presets… Keep this tab open until saving finishes."));
|
||||
}
|
||||
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
|
||||
});
|
||||
});
|
||||
}
|
||||
self.app.ui(ui, frame);
|
||||
|
||||
@@ -59,6 +59,22 @@ fn main() {
|
||||
prev = p;
|
||||
}
|
||||
println!("forward-warp segment (600 px brush, 25 px moves) + proxy update: mean {:.2} ms, worst {:.2} ms", total / n as f64, worst);
|
||||
// One 3000 px drag with a 200 px brush (a straight leg, then a diagonal one), as a slow
|
||||
// (every 2 px) and a fast (every 60 px) pointer reports it: the field cost of the whole
|
||||
// stroke, and how far the two results differ.
|
||||
let mut drag = LiquifyStroke::new(LiquifyTool::ForwardWarp, 200.0);
|
||||
let mut fields = Vec::new();
|
||||
let along = |d: f64| if d <= 1500.0 { vec![1500.0 + d, 2000.0] } else { vec![3000.0 + (d - 1500.0) * 0.6, 2000.0 + (d - 1500.0) * 0.8] };
|
||||
for step in [2.0, 60.0] {
|
||||
drag.points = (0..=(3000.0 / step) as usize).map(|k| along(k as f64 * step)).collect();
|
||||
let t = Instant::now();
|
||||
let mut f = LiquifyField::new(b, cell);
|
||||
f.apply_stroke(&drag);
|
||||
println!("forward-warp 3000 px drag (200 px brush), an event every {step} px: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
fields.push(f);
|
||||
}
|
||||
let diff = fields[0].d.iter().zip(&fields[1].d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
|
||||
println!(" slow vs fast pointer: fields differ by at most {diff:.3} px (max displacement {:.1} px)", fields[0].max_displacement());
|
||||
st.tool = LiquifyTool::Bloat;
|
||||
let t = Instant::now();
|
||||
let mut d = Rect::EMPTY;
|
||||
|
||||
+196
-56
@@ -154,7 +154,7 @@ impl LiquifyStroke {
|
||||
}
|
||||
|
||||
/// The displacement field plus the freeze mask, over `bounds` (document pixels).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LiquifyField {
|
||||
pub bounds: Rect,
|
||||
/// Document pixels per field node.
|
||||
@@ -165,6 +165,38 @@ pub struct LiquifyField {
|
||||
pub d: Vec<[f32; 2]>,
|
||||
/// Freeze mask per node (0 = free, 1 = frozen).
|
||||
pub freeze: Vec<f32>,
|
||||
/// Where the brush stroke in progress stands, so dab spacing runs on across its segments.
|
||||
cursor: Option<StrokeCursor>,
|
||||
}
|
||||
|
||||
/// Two fields are equal when their displacement and freeze mask are: the stroke cursor is
|
||||
/// transient brush state, not part of the result.
|
||||
impl PartialEq for LiquifyField {
|
||||
fn eq(&self, o: &Self) -> bool {
|
||||
self.bounds == o.bounds && self.cell == o.cell && self.w == o.w && self.h == o.h && self.d == o.d && self.freeze == o.freeze
|
||||
}
|
||||
}
|
||||
|
||||
/// The brush stroke in progress: the point its last segment ended at, its last dab and how far
|
||||
/// the brush has travelled along the path since that dab.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct StrokeCursor {
|
||||
end: [f64; 2],
|
||||
last_dab: [f64; 2],
|
||||
carry: f64,
|
||||
}
|
||||
|
||||
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
|
||||
/// drag is many small pushes and its edge stays smooth.
|
||||
fn dab_spacing(r: f64) -> f64 {
|
||||
(r * 0.05).max(0.5)
|
||||
}
|
||||
|
||||
/// Per-dab strength of a stationary tool (Twirl, Pucker, Bloat, Reconstruct, Smooth, Freeze,
|
||||
/// Thaw) while it moves: scaled to the spacing so its effect per distance travelled stays what it
|
||||
/// was with dabs a fifth of the radius apart.
|
||||
fn moving_weight(r: f64) -> f64 {
|
||||
dab_spacing(r) / (r * 0.2).max(0.5)
|
||||
}
|
||||
|
||||
/// The default field resolution for `bounds`: 2 px per node up to 4 MP, 4 px beyond.
|
||||
@@ -177,7 +209,7 @@ impl LiquifyField {
|
||||
let cell = cell.clamp(1.0, 64.0);
|
||||
let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2;
|
||||
let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2;
|
||||
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h] }
|
||||
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
|
||||
}
|
||||
|
||||
/// Document position of node (i, j) (node 0 sits on the first pixel centre).
|
||||
@@ -220,6 +252,7 @@ impl LiquifyField {
|
||||
|
||||
/// Applies one stroke. Returns the document rectangle whose output changed.
|
||||
pub fn apply_stroke(&mut self, s: &LiquifyStroke) -> Rect {
|
||||
self.cursor = None;
|
||||
match s.tool {
|
||||
LiquifyTool::LassoMask => return self.apply_lasso(s),
|
||||
LiquifyTool::ReconstructAll => {
|
||||
@@ -306,77 +339,120 @@ impl LiquifyField {
|
||||
/// callers use this plus [`Self::stroke_segment`] per new point, which is exactly what
|
||||
/// [`Self::apply_stroke`] does with the whole point list.
|
||||
pub fn stroke_begin(&mut self, s: &LiquifyStroke, p: [f64; 3]) -> Rect {
|
||||
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0)) } else { Rect::EMPTY }
|
||||
self.cursor = Some(StrokeCursor { end: [p[0], p[1]], last_dab: [p[0], p[1]], carry: 0.0 });
|
||||
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0), 1.0) } else { Rect::EMPTY }
|
||||
}
|
||||
|
||||
/// Continues a stroke from `a` to `b`: dabs every fifth of the brush radius (a stationary
|
||||
/// tool held still dabs once at `b`).
|
||||
/// Continues a stroke from `a` to `b`. Dabs fall every [`dab_spacing`] of distance travelled
|
||||
/// along the whole stroke, carried from one segment to the next, so the result is the same
|
||||
/// however finely the pointer path was sampled (a fast and a slow mouse give one warp). A
|
||||
/// stationary tool held still dabs once at `b`. When `a` is not where the previous segment
|
||||
/// ended, the stroke starts afresh at `a`.
|
||||
pub fn stroke_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
|
||||
self.walk_segment(s, a, b, true)
|
||||
}
|
||||
|
||||
/// [`Self::stroke_segment`]; `skip` leaves out the dabs that cannot reach a field node (they
|
||||
/// would change nothing), which tests compare with the full walk.
|
||||
fn walk_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3], skip: bool) -> Rect {
|
||||
let mut dirty = Rect::EMPTY;
|
||||
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
|
||||
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
|
||||
let stationary = s.tool.is_stationary();
|
||||
let cur = match self.cursor {
|
||||
Some(c) if c.end == [a[0], a[1]] => c,
|
||||
_ => StrokeCursor { end: [a[0], a[1]], last_dab: [a[0], a[1]], carry: 0.0 },
|
||||
};
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let len = dx.hypot(dy);
|
||||
if len == 0.0 {
|
||||
return if s.tool.is_stationary() { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb) } else { dirty };
|
||||
self.cursor = Some(cur);
|
||||
return if stationary { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb, 1.0) } else { dirty };
|
||||
}
|
||||
// NaN or a length that overflowed: nothing sensible to walk; the next segment restarts.
|
||||
if !len.is_finite() {
|
||||
self.cursor = None;
|
||||
return dirty;
|
||||
}
|
||||
let r = (s.size / 2.0).max(0.5);
|
||||
let spacing = (r * 0.2).max(0.5);
|
||||
let steps = (len / spacing).ceil().max(1.0) as usize;
|
||||
// Walk only the steps whose dab can reach a field node: the rest return from `dab` before
|
||||
// touching anything, and a segment to a far-off point has trillions of them (#937).
|
||||
let Some((k0, k1)) = self.reachable_steps(a, b, steps, r, s.tool.is_stationary()) else { return dirty };
|
||||
for k in k0..=k1 {
|
||||
let t0 = (k - 1) as f64 / steps as f64;
|
||||
let t1 = k as f64 / steps as f64;
|
||||
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
|
||||
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
|
||||
let pr = pa + (pb - pa) * t1;
|
||||
// Forward Warp centres the dab where the brush was (it drags what is under it).
|
||||
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
|
||||
dirty = dirty.union(&self.dab(s, centre, delta, pr));
|
||||
let spacing = dab_spacing(r);
|
||||
let weight = if stationary { moving_weight(r) } else { 1.0 };
|
||||
// Distance along this segment of its first dab, then one every `spacing`.
|
||||
let first = (spacing - cur.carry).clamp(0.0, spacing);
|
||||
// Distances are compared with a little slack: a dab that rounding puts a hair past the
|
||||
// segment's end still falls on it, so cutting one segment in two never adds or drops a dab.
|
||||
let slack = spacing * 1e-6;
|
||||
if first > len + slack {
|
||||
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: cur.last_dab, carry: cur.carry + len });
|
||||
return dirty;
|
||||
}
|
||||
let last_k = ((len - first + slack) / spacing).floor().max(0.0);
|
||||
let at = |dist: f64| [a[0] + dx * dist / len, a[1] + dy * dist / len];
|
||||
let (mut k0, mut k1) = (0.0f64, last_k);
|
||||
if skip {
|
||||
match self.reachable_span(a, [dx, dy], len, r) {
|
||||
// A dragging dab is centred one spacing behind its position: pad by that and a step.
|
||||
Some((d0, d1)) => {
|
||||
k0 = k0.max(((d0 - spacing - first) / spacing).floor() - 1.0);
|
||||
k1 = k1.min(((d1 + spacing - first) / spacing).ceil() + 1.0);
|
||||
}
|
||||
None => k1 = -1.0,
|
||||
}
|
||||
}
|
||||
if k0 <= k1 {
|
||||
let mut prev = if k0 > 0.0 { at(first + (k0 - 1.0) * spacing) } else { cur.last_dab };
|
||||
for k in (k0 as u64)..=(k1 as u64) {
|
||||
let dist = (first + k as f64 * spacing).min(len);
|
||||
let c = at(dist);
|
||||
let delta = [c[0] - prev[0], c[1] - prev[1]];
|
||||
// Forward Warp centres the dab where the brush was (it drags what is under it).
|
||||
let centre = if stationary { c } else { prev };
|
||||
dirty = dirty.union(&self.dab(s, centre, delta, pa + (pb - pa) * dist / len, weight));
|
||||
prev = c;
|
||||
}
|
||||
}
|
||||
let last = (first + last_k * spacing).min(len);
|
||||
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: at(last), carry: (len - last).clamp(0.0, spacing) });
|
||||
dirty
|
||||
}
|
||||
|
||||
/// The steps `k0..=k1` of a segment walked in `steps` whose dab can touch a field node, or
|
||||
/// `None` when none can. Step `k` centres its dab at `a + (b - a)·(k - o)/steps`, with `o` = 1
|
||||
/// for the dragging tools and 0 for the stationary ones (see `stroke_segment`). A dab reaches a
|
||||
/// node only when its centre is within `r` plus a node of the node grid, so the window below
|
||||
/// pads that by another node and a step: the dabs it leaves out would each change nothing.
|
||||
fn reachable_steps(&self, a: [f64; 3], b: [f64; 3], steps: usize, r: f64, stationary: bool) -> Option<(usize, usize)> {
|
||||
/// The distances along a segment from `a` (direction `dir`, length `len`) whose points lie
|
||||
/// within a dab radius `r` plus two nodes of the node grid, or `None` when none do. Only dabs
|
||||
/// near that span can touch a node; a segment to a far-off point has trillions of others
|
||||
/// (#937).
|
||||
fn reachable_span(&self, a: [f64; 3], dir: [f64; 2], len: f64, r: f64) -> Option<(f64, f64)> {
|
||||
if self.w == 0 || self.h == 0 {
|
||||
return None;
|
||||
}
|
||||
let pad = r + 2.0 * self.cell;
|
||||
let lo = [f64::from(self.bounds.x0) + 0.5 - pad, f64::from(self.bounds.y0) + 0.5 - pad];
|
||||
let hi = [lo[0] + (self.w - 1) as f64 * self.cell + 2.0 * pad, lo[1] + (self.h - 1) as f64 * self.cell + 2.0 * pad];
|
||||
let (n, o) = (steps as f64, if stationary { 0.0 } else { 1.0 });
|
||||
let (mut k0, mut k1) = (1.0f64, n);
|
||||
let (mut u0, mut u1) = (0.0f64, 1.0f64);
|
||||
for ax in 0..2 {
|
||||
let d = b[ax] - a[ax];
|
||||
let d = dir[ax];
|
||||
if d == 0.0 {
|
||||
if a[ax].is_nan() || a[ax] < lo[ax] || a[ax] > hi[ax] {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (u0, u1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
|
||||
k0 = k0.max((u0.min(u1) * n + o).floor() - 1.0);
|
||||
k1 = k1.min((u0.max(u1) * n + o).ceil() + 1.0);
|
||||
let (v0, v1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
|
||||
u0 = u0.max(v0.min(v1));
|
||||
u1 = u1.min(v0.max(v1));
|
||||
}
|
||||
// NaN: a segment whose length overflowed.
|
||||
if k0.is_nan() || k1.is_nan() || k0 > k1 {
|
||||
if u0.is_nan() || u1.is_nan() || u0 > u1 {
|
||||
return None;
|
||||
}
|
||||
Some((k0 as usize, k1 as usize))
|
||||
Some((u0 * len, u1 * len))
|
||||
}
|
||||
|
||||
/// One dab at `c` with brush motion `delta`. Returns the document rect it affected.
|
||||
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64) -> Rect {
|
||||
/// One dab at `c` with brush motion `delta`, its strength scaled by `weight`. Returns the
|
||||
/// document rect it affected.
|
||||
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64, weight: f64) -> Rect {
|
||||
if s.tool == LiquifyTool::LassoMask {
|
||||
return Rect::EMPTY; // the lasso is polygon-only; never a brush dab
|
||||
}
|
||||
let r = (s.size / 2.0).max(0.5);
|
||||
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure;
|
||||
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure * weight;
|
||||
let rate = (s.rate / 100.0).clamp(0.0, 1.0);
|
||||
let density = (s.density / 100.0).clamp(0.0, 1.0);
|
||||
if strength <= 0.0 {
|
||||
@@ -921,21 +997,9 @@ mod tests {
|
||||
use super::*;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
|
||||
/// The segment walk before #937: every step, whether or not its dab can reach the field.
|
||||
/// The segment walk before #937: every dab, whether or not it can reach the field.
|
||||
fn full_walk(f: &mut LiquifyField, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
|
||||
let mut dirty = Rect::EMPTY;
|
||||
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
|
||||
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
|
||||
let r = (s.size / 2.0).max(0.5);
|
||||
let steps = (len / (r * 0.2).max(0.5)).ceil().max(1.0) as usize;
|
||||
for k in 1..=steps {
|
||||
let (t0, t1) = ((k - 1) as f64 / steps as f64, k as f64 / steps as f64);
|
||||
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
|
||||
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
|
||||
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
|
||||
dirty = dirty.union(&f.dab(s, centre, delta, pa + (pb - pa) * t1));
|
||||
}
|
||||
dirty
|
||||
f.walk_segment(s, a, b, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -945,7 +1009,7 @@ mod tests {
|
||||
// every result bit-identical to the full walk.
|
||||
use LiquifyTool::*;
|
||||
let bounds = Rect::new(-7, 3, 89, 67);
|
||||
let segments: [([f64; 3], [f64; 3]); 9] = [
|
||||
let segments: [([f64; 3], [f64; 3]); 10] = [
|
||||
([10.0, 20.0, 1.0], [70.0, 50.0, 0.5]), // inside
|
||||
([-900.0, 30.0, 1.0], [40.0, 31.0, 1.0]), // in from far left
|
||||
([40.0, 30.0, 1.0], [40.0, 4000.0, 0.2]), // out downwards
|
||||
@@ -955,6 +1019,7 @@ mod tests {
|
||||
([88.5, -50.0, 1.0], [88.5, 120.0, 1.0]), // vertical, along the right edge
|
||||
([200.0, 200.0, 1.0], [201.0, 900.0, 1.0]), // entirely outside
|
||||
([0.0, 0.0, 1.0], [0.25, 0.1, 1.0]), // shorter than one step
|
||||
([60.0, 40.0, 1.0], [-2000.0, 45.0, 1.0]), // continues the stroke before it, out left
|
||||
];
|
||||
for tool in [ForwardWarp, PushLeft, Reconstruct, Smooth, TwirlCw, TwirlCcw, Pucker, Bloat, Freeze, Thaw] {
|
||||
for size in [6.0, 40.0] {
|
||||
@@ -973,6 +1038,81 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same path sampled by `parts` pointer events per corner-to-corner leg, unevenly.
|
||||
fn sampled(path: &[[f64; 3]], parts: usize) -> Vec<Vec<f64>> {
|
||||
let mut out = vec![path[0].to_vec()];
|
||||
for w in path.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
// Uneven steps, as a real pointer gives: t grows quadratically.
|
||||
for k in 1..=parts {
|
||||
let t = (k * k) as f64 / (parts * parts) as f64;
|
||||
out.push((0..3).map(|i| a[i] + (b[i] - a[i]) * t).collect());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// #2606: dabs restarted at every pointer event, so a fast mouse (few far-apart events) and a
|
||||
/// slow one (many close ones) warped the same path differently. Spacing now runs on along the
|
||||
/// whole stroke: any sampling of a path gives the same field.
|
||||
#[test]
|
||||
fn the_result_does_not_depend_on_how_finely_the_path_was_sampled() {
|
||||
use LiquifyTool::*;
|
||||
let path = [[20.0, 20.0, 1.0], [62.0, 26.0, 0.6], [70.0, 50.0, 0.9], [28.0, 55.0, 1.0], [30.0, 30.0, 1.0]];
|
||||
for tool in [ForwardWarp, PushLeft, TwirlCw, Bloat, Reconstruct, Smooth, Freeze] {
|
||||
for size in [12.0, 40.0] {
|
||||
let field = |parts: usize| {
|
||||
let mut warp = LiquifyStroke::new(ForwardWarp, 30.0);
|
||||
warp.points = vec![vec![30.0, 30.0], vec![50.0, 40.0]];
|
||||
let mut s = LiquifyStroke::new(tool, size);
|
||||
s.points = sampled(&path, parts);
|
||||
// Some displacement first, so Reconstruct and Smooth have something to change.
|
||||
LiquifyField::from_strokes(bounds(), 2.0, &[warp, s])
|
||||
};
|
||||
let coarse = field(1);
|
||||
for parts in [3, 17, 60] {
|
||||
let fine = field(parts);
|
||||
let dd = coarse.d.iter().zip(&fine.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
|
||||
let df = coarse.freeze.iter().zip(&fine.freeze).map(|(p, q)| (p - q).abs()).fold(0.0f32, f32::max);
|
||||
assert!(dd < 1e-3 && df < 1e-4, "{tool:?} {size}px, {parts} events per leg: field differs by {dd} px, mask by {df}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A long drag whose events land exactly on dab positions, along a diagonal: rounding must not
|
||||
/// add or drop the dab at a segment's end, or the deep displacement a long Forward Warp builds
|
||||
/// up differs by a whole dab between a slow and a fast pointer.
|
||||
#[test]
|
||||
fn a_long_diagonal_drag_gives_one_field_at_any_event_spacing() {
|
||||
let field = |step: f64| {
|
||||
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0);
|
||||
s.points = (0..=(300.0 / step) as usize).map(|k| vec![40.0 + k as f64 * step * 0.6, 20.0 + k as f64 * step * 0.8]).collect();
|
||||
LiquifyField::from_strokes(Rect::new(0, 0, 320, 300), 2.0, &[s])
|
||||
};
|
||||
let (slow, fast) = (field(2.0), field(60.0));
|
||||
let dd = slow.d.iter().zip(&fast.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
|
||||
assert!(slow.max_displacement() > 50.0, "the drag builds up a deep displacement");
|
||||
assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() {
|
||||
// A segment that does not start where the last one ended begins a new run of dabs there.
|
||||
let s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 20.0);
|
||||
let mut f = LiquifyField::new(bounds(), 2.0);
|
||||
f.stroke_begin(&s, [10.0, 10.0, 1.0]);
|
||||
f.stroke_segment(&s, [10.0, 10.0, 1.0], [10.3, 10.0, 1.0]);
|
||||
let mut g = LiquifyField::new(bounds(), 2.0);
|
||||
f.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
|
||||
g.stroke_begin(&s, [40.0, 30.0, 1.0]);
|
||||
g.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
|
||||
assert_eq!(f, g);
|
||||
// Hostile points: nothing panics and the next segment starts afresh.
|
||||
f.stroke_segment(&s, [f64::NAN, 3.0, 1.0], [4.0, f64::INFINITY, 1.0]);
|
||||
f.stroke_segment(&s, [4.0, 3.0, f64::NAN], [9.0, 3.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segment_to_a_far_off_point_returns() {
|
||||
// The issue's request: about 3.3e14 steps before the fix. Also a segment whose length
|
||||
@@ -1110,8 +1250,8 @@ mod tests {
|
||||
/// dab's circle (a hard-edged block at Density 0, half strength at the default 50).
|
||||
#[test]
|
||||
fn forward_warp_leaves_the_brush_rim_in_place() {
|
||||
// A drag of one dab spacing (a fifth of the radius): one dab, centred where it started.
|
||||
let (r, drag) = (20.0, 4.0);
|
||||
// A drag of one dab spacing (a twentieth of the radius): one dab, centred where it started.
|
||||
let (r, drag) = (20.0, 1.0);
|
||||
for density in [0.0, 50.0, 100.0] {
|
||||
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 2.0 * r);
|
||||
s.density = density;
|
||||
|
||||
@@ -22,6 +22,7 @@ mod image;
|
||||
mod options;
|
||||
pub mod orientation;
|
||||
pub mod resolution;
|
||||
pub mod source_settings;
|
||||
pub mod web;
|
||||
|
||||
pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color};
|
||||
@@ -39,6 +40,7 @@ pub use crate::image::{
|
||||
pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
|
||||
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
|
||||
pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution};
|
||||
pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding};
|
||||
pub use half::f16;
|
||||
|
||||
use crate::codecs::{exr, fits, heif, jpeg, png, pnm, tiff, via_image, webp};
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
//! The encoder settings a lossy file was written with, read back from its own data, so a save
|
||||
//! can write it again the way it came (File › Save keeps a JPEG's quality and a WebP's lossy or
|
||||
//! lossless encoding).
|
||||
//!
|
||||
//! * **JPEG**: the quality is estimated from the quantisation tables (DQT). Most encoders scale
|
||||
//! the example tables of ITU-T T.81 Annex K (K.1 luminance, K.2 chrominance) by the
|
||||
//! Independent JPEG Group's quality curve, so the quality whose scaled tables match the file's
|
||||
//! best is the one it was saved at (exact for such tables; the closest fit for others).
|
||||
//! * **WebP**: a `VP8L` image is lossless; a `VP8 ` image is lossy, and its base quantiser index
|
||||
//! (RFC 6386 §9.6) maps back to a quality on the scale our encoder uses.
|
||||
//!
|
||||
//! Everything here reads hostile bytes: every index is checked and a malformed file gives `None`.
|
||||
|
||||
use crate::codecs::vp8;
|
||||
|
||||
/// ITU-T T.81 Annex K.1, the example luminance quantisation table, in natural (row) order.
|
||||
const ANNEX_K_LUMA: [u16; 64] = [
|
||||
16, 11, 10, 16, 24, 40, 51, 61, //
|
||||
12, 12, 14, 19, 26, 58, 60, 55, //
|
||||
14, 13, 16, 24, 40, 57, 69, 56, //
|
||||
14, 17, 22, 29, 51, 87, 80, 62, //
|
||||
18, 22, 37, 56, 68, 109, 103, 77, //
|
||||
24, 35, 55, 64, 81, 104, 113, 92, //
|
||||
49, 64, 78, 87, 103, 121, 120, 101, //
|
||||
72, 92, 95, 98, 112, 100, 103, 99,
|
||||
];
|
||||
|
||||
/// ITU-T T.81 Annex K.2, the example chrominance quantisation table, in natural order.
|
||||
const ANNEX_K_CHROMA: [u16; 64] = [
|
||||
17, 18, 24, 47, 99, 99, 99, 99, //
|
||||
18, 21, 26, 66, 99, 99, 99, 99, //
|
||||
24, 26, 56, 99, 99, 99, 99, 99, //
|
||||
47, 66, 99, 99, 99, 99, 99, 99, //
|
||||
99, 99, 99, 99, 99, 99, 99, 99, //
|
||||
99, 99, 99, 99, 99, 99, 99, 99, //
|
||||
99, 99, 99, 99, 99, 99, 99, 99, //
|
||||
99, 99, 99, 99, 99, 99, 99, 99,
|
||||
];
|
||||
|
||||
/// The natural-order index of the k-th coefficient in zig-zag order (T.81 Figure A.6): DQT
|
||||
/// stores its 64 values in this order.
|
||||
const ZIGZAG: [usize; 64] = [
|
||||
0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29,
|
||||
22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63,
|
||||
];
|
||||
|
||||
/// One quantisation table from a DQT segment, in natural order, with its precision.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Table {
|
||||
values: [u16; 64],
|
||||
/// 16-bit entries (Pq = 1) rather than 8-bit.
|
||||
wide: bool,
|
||||
}
|
||||
|
||||
/// The quality (1–100, the Independent JPEG Group's scale that Save As and Export As use) a
|
||||
/// JPEG was most likely saved at, from its quantisation tables; `None` when it has no usable
|
||||
/// luminance table (or isn't a JPEG).
|
||||
pub fn jpeg_quality(bytes: &[u8]) -> Option<u8> {
|
||||
let tables = jpeg_tables(bytes);
|
||||
let [luma, chroma, ..] = tables;
|
||||
let luma = luma?;
|
||||
let mut best: Option<(u64, u8)> = None;
|
||||
for q in 1..=100u8 {
|
||||
let mut err = table_error(&luma, &ANNEX_K_LUMA, q);
|
||||
if let Some(c) = chroma {
|
||||
err = err.saturating_add(table_error(&c, &ANNEX_K_CHROMA, q));
|
||||
}
|
||||
if best.is_none_or(|(e, _)| err < e) {
|
||||
best = Some((err, q));
|
||||
}
|
||||
}
|
||||
best.map(|(_, q)| q)
|
||||
}
|
||||
|
||||
/// The Independent JPEG Group's quality scaling of `base` (libjpeg's `jpeg_quality_scaling` and
|
||||
/// `jpeg_add_quant_table`, as published with its source): a percentage scale of 5000 / q below
|
||||
/// quality 50 and 200 − 2q from it, rounded, and kept within 1..=255 (8-bit) or 1..=32767.
|
||||
fn scaled(base: u16, q: u8, wide: bool) -> u32 {
|
||||
let q = u32::from(q.clamp(1, 100));
|
||||
let scale = if q < 50 { 5000 / q } else { 200 - 2 * q };
|
||||
let v = (u32::from(base) * scale + 50) / 100;
|
||||
v.clamp(1, if wide { 32767 } else { 255 })
|
||||
}
|
||||
|
||||
/// How far `t` is from `base` scaled to quality `q`: the summed absolute difference.
|
||||
fn table_error(t: &Table, base: &[u16; 64], q: u8) -> u64 {
|
||||
t.values.iter().zip(base).map(|(&v, &b)| u64::from(u32::from(v).abs_diff(scaled(b, q, t.wide)))).sum()
|
||||
}
|
||||
|
||||
/// The quantisation tables 0..=3 defined before the first scan (a later definition of the same
|
||||
/// slot replaces an earlier one).
|
||||
fn jpeg_tables(b: &[u8]) -> [Option<Table>; 4] {
|
||||
let mut tables = [None; 4];
|
||||
if b.get(..2) != Some(&[0xFF, 0xD8]) {
|
||||
return tables;
|
||||
}
|
||||
let mut i = 2usize;
|
||||
while let (Some(&0xFF), Some(&marker)) = (b.get(i), b.get(i.saturating_add(1))) {
|
||||
if marker == 0xFF {
|
||||
i = i.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
if marker == 0xD8 || (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
|
||||
i = i.saturating_add(2);
|
||||
continue;
|
||||
}
|
||||
if marker == 0xDA || marker == 0xD9 {
|
||||
break;
|
||||
}
|
||||
let Some(len) = b.get(i + 2..i + 4).map(|l| usize::from(u16::from_be_bytes([l[0], l[1]]))) else { break };
|
||||
let Some(seg) = len.checked_sub(2).and_then(|n| b.get(i + 4..i + 4 + n)) else { break };
|
||||
if marker == 0xDB {
|
||||
read_dqt(seg, &mut tables);
|
||||
}
|
||||
i = i.saturating_add(2).saturating_add(len);
|
||||
}
|
||||
tables
|
||||
}
|
||||
|
||||
/// The tables of one DQT segment; a truncated or malformed table ends the segment.
|
||||
fn read_dqt(mut seg: &[u8], tables: &mut [Option<Table>; 4]) {
|
||||
while let Some((&pq_tq, rest)) = seg.split_first() {
|
||||
let wide = pq_tq >> 4 == 1;
|
||||
if pq_tq >> 4 > 1 {
|
||||
return;
|
||||
}
|
||||
let slot = usize::from(pq_tq & 0x0F);
|
||||
let n = if wide { 128 } else { 64 };
|
||||
let Some(data) = rest.get(..n) else { return };
|
||||
let mut values = [0u16; 64];
|
||||
for (k, &natural) in ZIGZAG.iter().enumerate() {
|
||||
let v = if wide { data.get(2 * k..2 * k + 2).map_or(0, |p| u16::from_be_bytes([p[0], p[1]])) } else { data.get(k).map_or(0, |&p| u16::from(p)) };
|
||||
if let Some(dst) = values.get_mut(natural) {
|
||||
*dst = v;
|
||||
}
|
||||
}
|
||||
if let Some(dst) = tables.get_mut(slot) {
|
||||
*dst = Some(Table { values, wide });
|
||||
}
|
||||
seg = rest.get(n..).unwrap_or(&[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// How a WebP file's image is compressed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WebpEncoding {
|
||||
/// VP8L.
|
||||
Lossless,
|
||||
/// VP8, with the quality (0–100, our encoder's scale) its quantiser maps back to, when the
|
||||
/// frame header could be read.
|
||||
Lossy { quality: Option<u8> },
|
||||
}
|
||||
|
||||
/// The compression of a WebP file's (first) image, or `None` when it isn't a WebP or holds no
|
||||
/// image chunk.
|
||||
pub fn webp_encoding(bytes: &[u8]) -> Option<WebpEncoding> {
|
||||
if bytes.get(..4) != Some(b"RIFF") || bytes.get(8..12) != Some(b"WEBP") {
|
||||
return None;
|
||||
}
|
||||
find_image_chunk(bytes.get(12..)?, 0)
|
||||
}
|
||||
|
||||
/// The first `VP8 ` or `VP8L` chunk among `chunks`, looking inside animation frames.
|
||||
fn find_image_chunk(mut chunks: &[u8], depth: u8) -> Option<WebpEncoding> {
|
||||
while let Some(head) = chunks.get(..8) {
|
||||
let fourcc = head.get(..4)?;
|
||||
let size = usize::try_from(u32::from_le_bytes([head[4], head[5], head[6], head[7]])).ok()?;
|
||||
let end = 8usize.checked_add(size)?;
|
||||
// A truncated last chunk still holds its header.
|
||||
let payload = chunks.get(8..end.min(chunks.len()))?;
|
||||
match fourcc {
|
||||
b"VP8L" => return Some(WebpEncoding::Lossless),
|
||||
b"VP8 " => return Some(WebpEncoding::Lossy { quality: vp8_quality(payload) }),
|
||||
// An animation frame: a 16-byte header, then the frame's own chunks.
|
||||
b"ANMF" if depth == 0 => {
|
||||
if let Some(found) = payload.get(16..).and_then(|c| find_image_chunk(c, 1)) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
chunks = chunks.get(end.checked_add(size % 2)?..).unwrap_or(&[]);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The quality a VP8 key frame's base quantiser index maps back to on our encoder's scale (the
|
||||
/// quality whose index is nearest; the middle one when several share it).
|
||||
fn vp8_quality(frame: &[u8]) -> Option<u8> {
|
||||
let qi = vp8_base_quantizer(frame)?;
|
||||
let distance = |q: u8| vp8::quantizer_index(q).abs_diff(qi);
|
||||
let best = (0..=100u8).map(distance).min()?;
|
||||
let matches: Vec<u8> = (0..=100u8).filter(|&q| distance(q) == best).collect();
|
||||
let mid = matches.get(matches.len() / 2)?;
|
||||
Some(*mid)
|
||||
}
|
||||
|
||||
/// The base quantiser index of a VP8 key frame: `y_ac_qi` from its frame header (RFC 6386
|
||||
/// §9.2–9.6, §19.2), averaged over the segments when segmentation sets absolute quantisers or
|
||||
/// adjusted by their mean delta otherwise.
|
||||
fn vp8_base_quantizer(frame: &[u8]) -> Option<u8> {
|
||||
let tag = frame.get(..3)?;
|
||||
// Bit 0 clear: a key frame, which carries the start code and dimensions.
|
||||
if tag[0] & 1 != 0 || frame.get(3..6)? != [0x9D, 0x01, 0x2A] {
|
||||
return None;
|
||||
}
|
||||
let mut d = BoolDecoder::new(frame.get(10..)?);
|
||||
d.literal(1); // color_space
|
||||
d.literal(1); // clamping_type
|
||||
let mut segments: Option<(bool, [i32; 4])> = None;
|
||||
if d.flag() {
|
||||
let update_map = d.flag();
|
||||
if d.flag() {
|
||||
let absolute = d.flag();
|
||||
let mut q = [0i32; 4];
|
||||
for v in &mut q {
|
||||
*v = if d.flag() { d.signed(7) } else { 0 };
|
||||
}
|
||||
for _ in 0..4 {
|
||||
if d.flag() {
|
||||
d.signed(6);
|
||||
}
|
||||
}
|
||||
segments = Some((absolute, q));
|
||||
}
|
||||
if update_map {
|
||||
for _ in 0..3 {
|
||||
if d.flag() {
|
||||
d.literal(8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
d.literal(1); // filter_type
|
||||
d.literal(6); // loop_filter_level
|
||||
d.literal(3); // sharpness_level
|
||||
if d.flag() && d.flag() {
|
||||
// loop_filter_adj_enable, mode_ref_lf_delta_update: 4 reference and 4 mode deltas.
|
||||
for _ in 0..8 {
|
||||
if d.flag() {
|
||||
d.signed(6);
|
||||
}
|
||||
}
|
||||
}
|
||||
d.literal(2); // log2_nbr_of_dct_partitions
|
||||
if d.exhausted() {
|
||||
return None;
|
||||
}
|
||||
let base = d.literal(7) as i32;
|
||||
let qi = match segments {
|
||||
Some((true, q)) => q.iter().sum::<i32>() / 4,
|
||||
Some((false, q)) => base + q.iter().sum::<i32>() / 4,
|
||||
None => base,
|
||||
};
|
||||
u8::try_from(qi.clamp(0, 127)).ok()
|
||||
}
|
||||
|
||||
/// RFC 6386 §7's boolean decoder, reading zeros past the end of its data.
|
||||
struct BoolDecoder<'a> {
|
||||
data: &'a [u8],
|
||||
pos: usize,
|
||||
value: u32,
|
||||
range: u32,
|
||||
bit_count: u32,
|
||||
/// Bytes read past the end of the data.
|
||||
overrun: usize,
|
||||
}
|
||||
|
||||
impl<'a> BoolDecoder<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
let mut d = BoolDecoder { data, pos: 0, value: 0, range: 255, bit_count: 0, overrun: 0 };
|
||||
d.value = (d.next_byte() << 8) | d.next_byte();
|
||||
d
|
||||
}
|
||||
|
||||
fn next_byte(&mut self) -> u32 {
|
||||
match self.data.get(self.pos) {
|
||||
Some(&b) => {
|
||||
self.pos += 1;
|
||||
u32::from(b)
|
||||
}
|
||||
None => {
|
||||
self.overrun = self.overrun.saturating_add(1);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The header was read from beyond the end of the data (a couple of bytes of look-ahead
|
||||
/// are normal).
|
||||
fn exhausted(&self) -> bool {
|
||||
self.overrun > 2
|
||||
}
|
||||
|
||||
fn bool(&mut self, prob: u32) -> bool {
|
||||
let split = 1 + (((self.range - 1) * prob) >> 8);
|
||||
let big_split = split << 8;
|
||||
let bit = if self.value >= big_split {
|
||||
self.range -= split;
|
||||
self.value -= big_split;
|
||||
true
|
||||
} else {
|
||||
self.range = split;
|
||||
false
|
||||
};
|
||||
while self.range < 128 {
|
||||
self.value <<= 1;
|
||||
self.range <<= 1;
|
||||
self.bit_count += 1;
|
||||
if self.bit_count == 8 {
|
||||
self.bit_count = 0;
|
||||
self.value |= self.next_byte();
|
||||
}
|
||||
}
|
||||
bit
|
||||
}
|
||||
|
||||
fn flag(&mut self) -> bool {
|
||||
self.bool(128)
|
||||
}
|
||||
|
||||
fn literal(&mut self, bits: u32) -> u32 {
|
||||
(0..bits).fold(0, |v, _| (v << 1) | u32::from(self.flag()))
|
||||
}
|
||||
|
||||
/// A magnitude of `bits` bits followed by a sign bit.
|
||||
fn signed(&mut self, bits: u32) -> i32 {
|
||||
let v = self.literal(bits) as i32;
|
||||
if self.flag() { -v } else { v }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scaling_matches_the_published_curve() {
|
||||
// Quality 50 is the Annex K table itself; 100 is all ones; 75 halves it.
|
||||
assert_eq!(scaled(16, 50, false), 16);
|
||||
assert_eq!(scaled(121, 100, false), 1);
|
||||
assert_eq!(scaled(16, 75, false), 8);
|
||||
assert_eq!(scaled(121, 1, false), 255);
|
||||
assert_eq!(scaled(121, 1, true), 6050);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zigzag_is_a_permutation() {
|
||||
let mut seen = [false; 64];
|
||||
for &n in &ZIGZAG {
|
||||
seen[n] = true;
|
||||
}
|
||||
assert!(seen.iter().all(|&s| s));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Reading back the settings a lossy file was saved with: a JPEG's quality from its
|
||||
//! quantisation tables, a WebP's lossy or lossless encoding (and its quality).
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use photocraft_codecs::*;
|
||||
|
||||
fn photo(w: u32, h: u32, layout: ChannelLayout) -> Image {
|
||||
synth(w, h, layout, SampleType::U8, 5, 0.03)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_quality_is_estimated_within_two() {
|
||||
for layout in [ChannelLayout::Rgb, ChannelLayout::Gray] {
|
||||
let img = photo(40, 24, layout);
|
||||
for q in [1u8, 5, 10, 25, 40, 50, 60, 70, 75, 80, 85, 90, 95, 98, 100] {
|
||||
let bytes = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: q, ..Default::default() }).unwrap();
|
||||
let est = jpeg_quality(&bytes).unwrap_or_else(|| panic!("{layout:?} q{q}: no estimate"));
|
||||
assert!(est.abs_diff(q) <= 2, "{layout:?}: saved at {q}, estimated {est}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_quality_of_hand_built_tables() {
|
||||
// A minimal JPEG prefix: SOI, one DQT holding the Annex K luminance table (quality 50) in
|
||||
// zig-zag order, then SOS.
|
||||
let luma: [u8; 64] = [
|
||||
16, 11, 12, 14, 12, 10, 16, 14, 13, 14, 18, 17, 16, 19, 24, 40, 26, 24, 22, 22, 24, 49, 35, 37, 29, 40, 58, 51, 61, 60, 57, 51, 56, 55, 64, 72, 92, 78,
|
||||
64, 68, 87, 69, 55, 56, 80, 109, 81, 87, 95, 98, 103, 104, 103, 62, 77, 113, 121, 112, 100, 120, 92, 101, 103, 99,
|
||||
];
|
||||
let mut b = vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x00];
|
||||
b.extend_from_slice(&luma);
|
||||
b.extend_from_slice(&[0xFF, 0xDA, 0, 2]);
|
||||
assert_eq!(jpeg_quality(&b), Some(50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_jpeg_tables_give_no_estimate_and_never_panic() {
|
||||
let img = photo(16, 16, ChannelLayout::Rgb);
|
||||
let good = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 70, ..Default::default() }).unwrap();
|
||||
// Every truncation of a real file.
|
||||
for n in 0..good.len().min(800) {
|
||||
let _ = jpeg_quality(&good[..n]);
|
||||
}
|
||||
let cases: Vec<Vec<u8>> = vec![
|
||||
vec![],
|
||||
vec![0xFF],
|
||||
vec![0xFF, 0xD8],
|
||||
// A DQT whose length runs past the end.
|
||||
vec![0xFF, 0xD8, 0xFF, 0xDB, 0xFF, 0xFF, 0x00, 1, 2, 3],
|
||||
// A DQT of length 0 and 1 (less than its own length field).
|
||||
vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x00],
|
||||
vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x01],
|
||||
// A table cut short, a bad precision, a slot past 3, a 16-bit table cut mid-entry.
|
||||
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 12, 0x00], vec![9; 9]].concat(),
|
||||
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x70], vec![9; 64]].concat(),
|
||||
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x0F], vec![9; 64]].concat(),
|
||||
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 68, 0x10], vec![0xFF; 65]].concat(),
|
||||
// All-zero and all-0xFF tables.
|
||||
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x00], vec![0; 64]].concat(),
|
||||
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 131, 0x10], vec![0xFF; 128]].concat(),
|
||||
vec![0xFF; 64],
|
||||
];
|
||||
for c in &cases {
|
||||
let _ = jpeg_quality(c);
|
||||
}
|
||||
assert_eq!(jpeg_quality(&[0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x00]), None);
|
||||
assert_eq!(jpeg_quality(b"not a jpeg at all"), None);
|
||||
let png = encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(jpeg_quality(&png), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_lossless_and_lossy_are_told_apart() {
|
||||
let img = photo(33, 21, ChannelLayout::Rgb);
|
||||
let lossless = encode(&img, Format::WebP, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(webp_encoding(&lossless), Some(WebpEncoding::Lossless));
|
||||
for q in [10u8, 30, 50, 75, 80, 90, 100] {
|
||||
let bytes = encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: q, ..Default::default() }).unwrap();
|
||||
let Some(WebpEncoding::Lossy { quality: Some(est) }) = webp_encoding(&bytes) else { panic!("q{q}: {:?}", webp_encoding(&bytes)) };
|
||||
assert!(est.abs_diff(q) <= 2, "lossy WebP saved at {q}, estimated {est}");
|
||||
}
|
||||
// With alpha and metadata (the extended container).
|
||||
let rgba = photo(20, 20, ChannelLayout::Rgba);
|
||||
let bytes = encode(&rgba, Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: 60, ..Default::default() }).unwrap();
|
||||
assert!(matches!(webp_encoding(&bytes), Some(WebpEncoding::Lossy { quality: Some(58..=62) })), "{:?}", webp_encoding(&bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_webp_never_panics() {
|
||||
let img = photo(24, 24, ChannelLayout::Rgb);
|
||||
for opts in [EncodeOptions::default(), EncodeOptions { webp_lossless: false, ..Default::default() }] {
|
||||
let good = encode(&img, Format::WebP, &opts).unwrap();
|
||||
for n in 0..good.len().min(400) {
|
||||
let _ = webp_encoding(&good[..n]);
|
||||
}
|
||||
let mut flipped = good.clone();
|
||||
for i in 12..flipped.len().min(64) {
|
||||
flipped[i] ^= 0xA5;
|
||||
let _ = webp_encoding(&flipped);
|
||||
}
|
||||
}
|
||||
let mut huge = b"RIFF\0\0\0\0WEBPVP8 ".to_vec();
|
||||
huge.extend_from_slice(&u32::MAX.to_le_bytes());
|
||||
let _ = webp_encoding(&huge);
|
||||
let mut anmf = b"RIFF\0\0\0\0WEBPANMF".to_vec();
|
||||
anmf.extend_from_slice(&3u32.to_le_bytes());
|
||||
let _ = webp_encoding(&anmf);
|
||||
assert_eq!(webp_encoding(b"RIFF\0\0\0\0WAVE"), None);
|
||||
}
|
||||
@@ -84,10 +84,10 @@ pub fn shapeless(layer: &Layer) -> bool {
|
||||
!layer.advanced.transparency_shapes && (layer.advanced.knockout != Knockout::None || effects::has_effects(layer))
|
||||
}
|
||||
|
||||
/// The Background layer of a stack: the bottom layer, a locked, opaque-by-convention pixel layer
|
||||
/// named "Background" (how PSD import and File › New create it).
|
||||
/// Whether `l`, the bottom layer of a stack, is the Background: identified by its locks and kind,
|
||||
/// never by its (localized) name ([`photocraft_doc::Document::background`]).
|
||||
pub fn is_background(l: &Layer) -> bool {
|
||||
l.name == "Background" && l.locks.transparency && !l.clipped && matches!(l.content, LayerContent::Raster(_))
|
||||
l.has_background_traits()
|
||||
}
|
||||
|
||||
/// What a deep knockout reveals over `rect`: the document's initial backdrop with its Background
|
||||
|
||||
@@ -437,3 +437,26 @@ fn a_knockout_background_does_not_recurse() {
|
||||
let got = render(&d, Rect::from_xywh(0, 0, 1, 1)).px[0];
|
||||
assert!(got[3] <= 1.0);
|
||||
}
|
||||
|
||||
/// The Background is found by its structure, not its English name: a file from a German or
|
||||
/// Japanese Photoshop calls it "Hintergrund" or "背景", and a deep knockout still reveals it. A
|
||||
/// bottom layer merely called "Background", without the Background's locks, is not one.
|
||||
#[test]
|
||||
fn deep_knockout_reveals_a_localized_background() {
|
||||
for depth in DEPTHS {
|
||||
for name in ["Hintergrund", "背景", "Arrière-plan"] {
|
||||
let mut d = doc(depth, true);
|
||||
d.layers[0].name = name.into();
|
||||
let green = solid(&d, "green", full(&d), GREEN);
|
||||
d.layers.push(green);
|
||||
d.layers.push(knocker(&d, Knockout::Deep));
|
||||
check(&d, 2, 2, WHITE, name);
|
||||
}
|
||||
let mut d = doc(depth, true);
|
||||
d.layers[0].locks = Default::default();
|
||||
let green = solid(&d, "green", full(&d), GREEN);
|
||||
d.layers.push(green);
|
||||
d.layers.push(knocker(&d, Knockout::Deep));
|
||||
check(&d, 2, 2, [0.0; 4], "an unlocked layer named Background");
|
||||
}
|
||||
}
|
||||
@@ -1455,7 +1455,9 @@ pub fn bevel_geom(b: &Bevel) -> BevelGeom {
|
||||
BevelGeom {
|
||||
paint,
|
||||
width,
|
||||
depth: b.depth * if smooth { width } else { size } * sign,
|
||||
// A bevel rises `depth × width / 2` px: Smooth with a slope falling from `depth` at the
|
||||
// edge (the tent), a chisel with the constant slope of its ramp (see `bevel_chisel_h`).
|
||||
depth: b.depth * if smooth { width } else { size / 2.0 } * sign,
|
||||
pillow: b.style == BevelStyle::PillowEmboss,
|
||||
chisel_soft: if b.technique == BevelTechnique::ChiselSoft { (size / 4.0).max(1.0) } else { 0.0 },
|
||||
}
|
||||
@@ -1476,10 +1478,13 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern
|
||||
let sharp = sharp_edges(shape);
|
||||
let (din, dout) = (bevel_dist_inside(shape, &sharp, size), bevel_dist_outside(shape, &sharp, size));
|
||||
let mut h = Map::new(shape.w, shape.h, 0.0);
|
||||
let soft = g.chisel_soft > 0.0;
|
||||
for i in 0..h.v.len() {
|
||||
h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i]);
|
||||
h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i], soft);
|
||||
}
|
||||
if g.chisel_soft > 0.0 {
|
||||
// Inner and outer Chisel Soft are not blurred in Photoshop (their facets equal Chisel
|
||||
// Hard's across a straight edge); emboss styles keep the blur until measured.
|
||||
if soft && g.paint == BevelPaint::Both {
|
||||
tent(&mut h, g.chisel_soft);
|
||||
}
|
||||
h
|
||||
@@ -1521,17 +1526,46 @@ pub struct TextureCtx<'a> {
|
||||
}
|
||||
|
||||
/// Chiselled height from the inside / outside distances (keep in sync with `fs_mbevelh`).
|
||||
pub fn bevel_chisel_h(paint: BevelPaint, size: f32, din: f32, dout: f32) -> f32 {
|
||||
///
|
||||
/// Photoshop 27.11, measured across straight edges (sizes 10 and 20, depth 100 and 300): a chisel
|
||||
/// facet has the constant slope `depth × size / (2 (size + 1))`, i.e. one height step of
|
||||
/// `1 / (size + 1)` per pixel at the `depth × size / 2` scale of [`bevel_geom`]. Inner Hard's ramp
|
||||
/// runs from half a pixel beyond the edge to `size + ½` (the facet is flat right up to the edge
|
||||
/// pixel); Inner Soft starts a pixel further out, so its edge pixel is steeper; Outer Hard ends a
|
||||
/// pixel further out. All within 1 level of Photoshop on those profiles.
|
||||
pub fn bevel_chisel_h(paint: BevelPaint, size: f32, din: f32, dout: f32, soft: bool) -> f32 {
|
||||
let ramp = size + 1.0;
|
||||
match paint {
|
||||
BevelPaint::Outer => 1.0 - (dout / size).clamp(0.0, 1.0),
|
||||
BevelPaint::Both => {
|
||||
if din > 0.0 {
|
||||
0.5 + 0.5 * (din / (size / 2.0)).clamp(0.0, 1.0)
|
||||
BevelPaint::Outer if !soft => {
|
||||
let top = (size + 2.0) / ramp;
|
||||
if dout > 0.0 { ((size + 1.5 - dout) / ramp).clamp(0.0, top) } else { top }
|
||||
}
|
||||
// Outer Soft: Outer Hard's ramp a pixel shorter, with a steeper edge pixel.
|
||||
BevelPaint::Outer => {
|
||||
if dout > 0.0 {
|
||||
1.0 - ((dout + 0.5) / ramp).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.5 - 0.5 * (dout / (size / 2.0)).clamp(0.0, 1.0)
|
||||
(size + 2.0) / ramp
|
||||
}
|
||||
}
|
||||
BevelPaint::Both => {
|
||||
let half = size / 2.0 + 0.5;
|
||||
if din > 0.0 { 0.5 + 0.5 * (din / half).clamp(0.0, 1.0) } else { 0.5 - 0.5 * (dout / half).clamp(0.0, 1.0) }
|
||||
}
|
||||
BevelPaint::Inner if soft => {
|
||||
if din > 0.0 {
|
||||
((din + 1.5) / ramp).clamp(0.0, (size + 2.0) / ramp)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
BevelPaint::Inner => {
|
||||
if din > 0.0 {
|
||||
((din + 0.5) / ramp).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
BevelPaint::Inner => (din / size).clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1549,7 +1583,9 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
|
||||
// past the blur's reach (a raised 4-neighbour: it still slopes; psd-tools layer_effects'
|
||||
// emboss shadow runs one row past it), outer bevels don't (Photoshop oracle
|
||||
// bevel-outer-smooth).
|
||||
let reach_past = g.paint == BevelPaint::Both;
|
||||
// Outer chisel bevels too: the pixel where their ramp reaches zero still slopes (Photoshop
|
||||
// 27.11 shades it, half strength, at `size + 1½` px out).
|
||||
let reach_past = g.paint == BevelPaint::Both || (g.paint == BevelPaint::Outer && b.technique != BevelTechnique::Smooth);
|
||||
let on_bevel = |x: i64, y: i64| {
|
||||
hmap.get(x, y) > BEVEL_H_EPS || (reach_past && [(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| hmap.get(x + dx, y + dy) > BEVEL_H_EPS))
|
||||
};
|
||||
@@ -2334,6 +2370,40 @@ mod tests {
|
||||
assert!(at(&maps[0], 20, 15) < 0.05);
|
||||
}
|
||||
|
||||
/// Photoshop 27.11, measured across a straight edge (size 10, depth 100 %, light from the top
|
||||
/// at 30°): a chisel facet has the slope `depth × size / (2 (size + 1))`, flat right up to the
|
||||
/// edge pixel for Inner Hard; Inner Soft's edge pixel is half as steep again; Outer Hard
|
||||
/// still shades, partly, the pixel where its ramp reaches zero `size + 1½` px out.
|
||||
#[test]
|
||||
fn chisel_facets_match_photoshop() {
|
||||
let amount = |g: f32| {
|
||||
let (se, ce) = 30f32.to_radians().sin_cos();
|
||||
((g * ce + se) / (1.0 + g * g).sqrt() - se) / (1.0 - se)
|
||||
};
|
||||
let n = 80;
|
||||
let shape = square(n, 20, 60);
|
||||
let maps = |style, technique| {
|
||||
let mut b = bevel_of(style, technique);
|
||||
b.size = 10.0;
|
||||
bevel_maps(&shape, &b, &GlobalLight::default(), &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES)).0
|
||||
};
|
||||
let at = |m: &Map, y: usize| m.v[y * n + 40];
|
||||
let facet = amount(10.0 / 22.0);
|
||||
let hard = maps(BevelStyle::InnerBevel, BevelTechnique::ChiselHard);
|
||||
for y in 20..30 {
|
||||
assert!((at(&hard[0], y) - facet).abs() < 0.01, "inner hard facet at row {y}: {} vs {facet}", at(&hard[0], y));
|
||||
}
|
||||
assert!(at(&hard[0], 30) > 0.05 && at(&hard[0], 30) < facet, "one partial pixel at the end");
|
||||
let soft = maps(BevelStyle::InnerBevel, BevelTechnique::ChiselSoft);
|
||||
assert!((at(&soft[0], 20) - amount(1.5 * 10.0 / 22.0)).abs() < 0.01, "steeper edge pixel: {}", at(&soft[0], 20));
|
||||
assert!((at(&soft[0], 24) - facet).abs() < 0.01, "then Chisel Hard's facet");
|
||||
let outer = maps(BevelStyle::OuterBevel, BevelTechnique::ChiselHard);
|
||||
// Below the shape (rows 60..): shadow facet through 10½ px, partial at 11½.
|
||||
assert!((at(&outer[1], 70) - at(&outer[1], 61)).abs() < 1e-4);
|
||||
assert!(at(&outer[1], 71) > 0.05 && at(&outer[1], 71) < at(&outer[1], 70));
|
||||
assert_eq!(at(&outer[1], 72), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emboss_paints_both_sides_and_pillow_flips_the_outside() {
|
||||
let shape = square(40, 10, 30);
|
||||
|
||||
@@ -636,6 +636,13 @@ pub struct Layer {
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
/// Whether the layer has the Background's own properties: an unclipped pixel layer with
|
||||
/// transparency and position locked. Only the bottom layer of a document with them is the
|
||||
/// Background: use [`Document::background`] / [`Document::is_background`] to identify it.
|
||||
pub fn has_background_traits(&self) -> bool {
|
||||
self.locks.transparency && self.locks.position && !self.clipped && matches!(self.content, LayerContent::Raster(_))
|
||||
}
|
||||
|
||||
pub fn new(name: impl Into<String>, content: LayerContent) -> Self {
|
||||
Layer {
|
||||
id: LayerId::fresh(),
|
||||
@@ -875,6 +882,32 @@ pub struct Document {
|
||||
pub variables: Variables,
|
||||
/// Window › Timeline (None until a video timeline is created).
|
||||
pub timeline: Option<Timeline>,
|
||||
/// How the document's lossy file was encoded (its JPEG quality, WebP lossy or lossless), so
|
||||
/// a save writes it back the same way rather than at fixed defaults.
|
||||
pub file_encoding: FileEncoding,
|
||||
}
|
||||
|
||||
/// The encoder settings a document's file was opened or saved with: what File › Save and the
|
||||
/// Save As defaults reuse for that format. Empty for a new document or a file without lossy
|
||||
/// options. Qualities are on the codecs' 1–100 scale.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct FileEncoding {
|
||||
/// JPEG quality (estimated from the file's quantisation tables on open).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jpeg_quality: Option<u8>,
|
||||
/// WebP: `Some(true)` lossless (VP8L), `Some(false)` lossy (VP8 at [`Self::webp_quality`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub webp_lossless: Option<bool>,
|
||||
/// Lossy WebP quality.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub webp_quality: Option<u8>,
|
||||
}
|
||||
|
||||
impl FileEncoding {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == FileEncoding::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a layer lives in the tree: indices from the root down.
|
||||
@@ -913,6 +946,7 @@ impl Document {
|
||||
slices: Slices::default(),
|
||||
variables: Variables::default(),
|
||||
timeline: None,
|
||||
file_encoding: FileEncoding::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1013,6 +1047,25 @@ impl Document {
|
||||
self.path_of(id).map_or_else(Locks::default, |p| self.locks_at(&p))
|
||||
}
|
||||
|
||||
/// The Background layer, identified structurally as Photoshop does, never by its name (a file
|
||||
/// from a German or Japanese Photoshop names it "Hintergrund" or "背景"): the bottom layer of
|
||||
/// the stack, a pixel layer that is not clipped, with transparency and position locked (a PSD
|
||||
/// stores it without a transparency channel and transparency-protected; File › New and opaque
|
||||
/// flat files create it the same way).
|
||||
pub fn background(&self) -> Option<&Layer> {
|
||||
self.layers.first().filter(|l| Layer::has_background_traits(l))
|
||||
}
|
||||
|
||||
/// [`Self::background`], mutably.
|
||||
pub fn background_mut(&mut self) -> Option<&mut Layer> {
|
||||
self.layers.first_mut().filter(|l| Layer::has_background_traits(l))
|
||||
}
|
||||
|
||||
/// Whether `id` is the document's Background layer ([`Self::background`]).
|
||||
pub fn is_background(&self, id: LayerId) -> bool {
|
||||
self.background().is_some_and(|b| b.id == id)
|
||||
}
|
||||
|
||||
pub fn layer(&self, id: LayerId) -> Option<&Layer> {
|
||||
fn rec(layers: &[Layer], id: LayerId) -> Option<&Layer> {
|
||||
layers.iter().find_map(|l| if l.id == id { Some(l) } else { l.children().and_then(|ch| rec(ch, id)) })
|
||||
@@ -1178,6 +1231,30 @@ mod tests {
|
||||
assert_eq!(s.pixel(100, 0), vec![0.0; 4]);
|
||||
}
|
||||
|
||||
/// The Background is the locked bottom pixel layer, whatever it is called.
|
||||
#[test]
|
||||
fn background_is_structural_not_named() {
|
||||
let mut d = doc();
|
||||
let id = d.layers[0].id;
|
||||
assert!(d.is_background(id));
|
||||
for name in ["Hintergrund", "背景", "Arrière-plan"] {
|
||||
d.layers[0].name = name.into();
|
||||
assert_eq!(d.background().map(|l| l.id), Some(id), "{name}");
|
||||
}
|
||||
// A layer called "Background" without the locks, or above the bottom, is not it.
|
||||
let mut top = Layer::raster("Background", d.pixel_format());
|
||||
top.locks.transparency = true;
|
||||
top.locks.position = true;
|
||||
let top_id = top.id;
|
||||
d.layers.push(top);
|
||||
assert!(!d.is_background(top_id));
|
||||
d.layers[0].locks.position = false;
|
||||
assert!(d.background().is_none());
|
||||
d.layers[0].locks.position = true;
|
||||
d.layers[0].clipped = true;
|
||||
assert!(d.background_mut().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_group_depth_counts_nesting_without_recursing() {
|
||||
let mut d = doc();
|
||||
|
||||
@@ -1224,4 +1224,44 @@ mod tests {
|
||||
assert!(bad.actions.list.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// AUTO-219-4: a recording made on one document replays on a second one — a crop, a filter,
|
||||
/// a selection fill and styled text all act on the document the replay runs in.
|
||||
#[test]
|
||||
fn a_recorded_edit_run_replays_on_a_second_document() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap();
|
||||
s.execute("actions.record", json!({"name": "Studio"})).unwrap();
|
||||
s.execute("image.crop", json!({"x": 8, "y": 8, "width": 48, "height": 48})).unwrap();
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 12, "height": 12})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("type.create", json!({"x": 10, "y": 36, "text": "Hi", "size": 12, "color": "#000000"})).unwrap();
|
||||
s.execute("type.setStyle", json!({"fauxBold": true})).unwrap();
|
||||
let stopped = s.execute("actions.stop", json!({})).unwrap();
|
||||
let steps = stopped["steps"].as_u64().unwrap();
|
||||
assert!(steps >= 7, "the recording keeps every family: {stopped}");
|
||||
|
||||
// The second document: a different size, background and name. The replay must act here.
|
||||
s.execute("file.new", json!({"width": 100, "height": 80, "background": "black", "name": "Second"})).unwrap();
|
||||
let played = s.execute("actions.play", json!({"action": "Studio"})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "replay: {played}");
|
||||
assert_eq!(played["ran"].as_u64(), Some(steps), "every step ran: {played}");
|
||||
let d = &s.active().unwrap().doc;
|
||||
assert_eq!((d.size.width, d.size.height), (48, 48), "the crop replayed onto the second document");
|
||||
assert_eq!(pixel(&s, 0, 16, 16), vec![1.0, 0.0, 0.0, 1.0], "the selection fill replayed");
|
||||
assert_eq!(pixel(&s, 0, 2, 2), vec![0.0, 0.0, 0.0, 1.0], "outside the selection stays black");
|
||||
let last = d.layers.last().unwrap();
|
||||
match &last.content {
|
||||
photocraft_doc::LayerContent::Text(t) => {
|
||||
assert_eq!(t.text, "Hi", "the styled text replayed");
|
||||
assert!(t.char_runs().iter().any(|r| r.style.faux_bold), "the bold run replayed");
|
||||
}
|
||||
other => panic!("expected the recorded type layer, got {other:?}"),
|
||||
}
|
||||
// The first document is untouched.
|
||||
let first = &s.documents()[0].doc;
|
||||
assert_eq!((first.size.width, first.size.height), (48, 48), "the recording document keeps its own crop");
|
||||
}
|
||||
}
|
||||
@@ -428,6 +428,7 @@ fn hdr_toning(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let data: Vec<f32> = px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect();
|
||||
let mut bg = Layer::raster("Background", fmt);
|
||||
bg.locks.transparency = true;
|
||||
bg.locks.position = true;
|
||||
crate::pixels_mut(&mut bg)?.write_region(doc.bounds(), &data);
|
||||
*active = Some(bg.id);
|
||||
doc.layers = vec![bg];
|
||||
|
||||
@@ -670,7 +670,7 @@ fn straighten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({"angle": 0.0}));
|
||||
}
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| l.name == "Background" && l.locks.position);
|
||||
let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| d.is_background(l.id));
|
||||
let crop = p.get("crop").and_then(Value::as_bool).unwrap_or(active_bg || st.active_layer.is_none());
|
||||
let (w0, h0) = (f64::from(d.size.width), f64::from(d.size.height));
|
||||
compound(s, "Straighten", |s| {
|
||||
|
||||
@@ -10,14 +10,13 @@ use photocraft_doc::{Document, Layer, LayerContent, LayerId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::extra_cmds::is_background;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const CMD: &str = "layer.new.backgroundFromLayer";
|
||||
|
||||
/// The document's Background layer, if it has one (always the bottom layer).
|
||||
pub(crate) fn background_id(doc: &Document) -> Option<LayerId> {
|
||||
doc.layers.first().filter(|l| is_background(l)).map(|l| l.id)
|
||||
doc.background().map(|l| l.id)
|
||||
}
|
||||
|
||||
/// Refuses an edit that moved the Background `bg` (taken before the edit) off the bottom of the
|
||||
@@ -155,7 +154,7 @@ mod tests {
|
||||
assert_eq!(s.execute(CMD, json!({})).unwrap()["layer"], a.0);
|
||||
assert_eq!(order(&s), [a, l0, b], "{depth}: drops to the bottom");
|
||||
let l = &doc(&s).layers[0];
|
||||
assert!(is_background(l) && l.name == "Background", "{depth}");
|
||||
assert!(doc(&s).is_background(l.id) && l.name == "Background", "{depth}");
|
||||
assert_eq!(s.active().unwrap().active_layer, Some(a));
|
||||
// Its pixels stay; transparency takes the background colour (blue).
|
||||
let red = pixel(&s, 3, 3);
|
||||
@@ -170,10 +169,10 @@ mod tests {
|
||||
assert!(s.undo());
|
||||
assert_eq!(order(&s), [l0, a, b], "{depth}");
|
||||
let l = doc(&s).layer(a).unwrap();
|
||||
assert!(!is_background(l) && l.name == "A");
|
||||
assert!(!doc(&s).is_background(l.id) && l.name == "A");
|
||||
assert!(s.redo());
|
||||
assert_eq!(order(&s), [a, l0, b], "{depth}");
|
||||
assert!(is_background(&doc(&s).layers[0]));
|
||||
assert!(doc(&s).is_background(doc(&s).layers[0].id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +190,7 @@ mod tests {
|
||||
assert_eq!(d.layers[1].id, l0);
|
||||
assert_eq!(d.layers[2].id, a);
|
||||
let bg = &d.layers[0];
|
||||
assert!(is_background(bg) && !bg.visible, "visibility is kept");
|
||||
assert!(d.is_background(bg.id) && !bg.visible, "visibility is kept");
|
||||
assert_eq!(bg.opacity, 1.0);
|
||||
}
|
||||
|
||||
@@ -211,7 +210,7 @@ mod tests {
|
||||
for p in [json!({"layer": "A"}), json!({"layer": -1}), json!({"layer": 1.5}), json!({"layer": 99999}), json!({"layer": [a.0]})] {
|
||||
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert!(!doc(&s).layers.iter().any(is_background));
|
||||
assert!(doc(&s).background().is_none());
|
||||
// A group or an adjustment layer can't become the Background.
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||
@@ -220,7 +219,7 @@ mod tests {
|
||||
s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap();
|
||||
assert!(!s.is_enabled(CMD));
|
||||
assert!(s.execute(CMD, json!({})).is_err());
|
||||
assert!(!doc(&s).layers.iter().any(is_background));
|
||||
assert!(doc(&s).background().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -415,8 +415,9 @@ pub struct LiveStroke {
|
||||
symmetry: Option<crate::symmetry_cmds::PaintingSymmetry>,
|
||||
mirrors: Vec<StrokeRenderer>,
|
||||
mirror_distinct: Vec<bool>,
|
||||
/// Restore passes separately; their union may span an otherwise untouched document.
|
||||
symmetry_regions: Vec<Rect>,
|
||||
/// What the symmetric preview drew last, once a mirror differs from the stroke, and how many
|
||||
/// passes it merged (a mirror joining redraws every tile).
|
||||
union_live: Option<(photocraft_paint::UnionLive, usize)>,
|
||||
pre: Surface,
|
||||
sel: Option<Surface>,
|
||||
lock: bool,
|
||||
@@ -508,7 +509,7 @@ impl LiveStroke {
|
||||
symmetry,
|
||||
mirrors,
|
||||
mirror_distinct,
|
||||
symmetry_regions: Vec::new(),
|
||||
union_live: None,
|
||||
pre,
|
||||
sel,
|
||||
lock,
|
||||
@@ -567,33 +568,27 @@ impl LiveStroke {
|
||||
}
|
||||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
|
||||
if self.mirror_distinct.iter().any(|d| *d) {
|
||||
// Merge every finished pass once, both here and on commit, including shared axes.
|
||||
// The stroke and its distinct mirrors composite as one stroke, as the commit
|
||||
// merges them; only the tiles that changed are redrawn.
|
||||
let old = self.tail;
|
||||
let mut original_preview = self.renderer.clone();
|
||||
original_preview.finish();
|
||||
let copies: Vec<_> = self
|
||||
.mirrors
|
||||
.iter()
|
||||
.zip(&self.mirror_distinct)
|
||||
.filter(|(_, d)| **d)
|
||||
.map(|(r, _)| {
|
||||
let mut r = r.clone();
|
||||
r.finish();
|
||||
r
|
||||
})
|
||||
let passes = 1 + self.mirror_distinct.iter().filter(|d| **d).count();
|
||||
let (mut live, full, mut dmg) = match self.union_live.take() {
|
||||
Some((live, merged)) => (live, merged != passes, Rect::EMPTY),
|
||||
None => {
|
||||
// Leaving the single-pass preview: take its smoothing tail back first.
|
||||
if !old.is_empty() {
|
||||
surf.write_region(old, &self.pre.read_region(old));
|
||||
}
|
||||
(photocraft_paint::UnionLive::default(), true, old)
|
||||
}
|
||||
};
|
||||
let mut renderers: Vec<&mut StrokeRenderer> = std::iter::once(&mut self.renderer)
|
||||
.chain(self.mirrors.iter_mut().zip(&self.mirror_distinct).filter(|(_, d)| **d).map(|(r, _)| r))
|
||||
.collect();
|
||||
let bounds = copies.iter().fold(original_preview.bounds().union(&old), |b, r| b.union(&r.bounds()));
|
||||
if self.symmetry_regions.is_empty() && !old.is_empty() {
|
||||
surf.write_region(old, &self.pre.read_region(old));
|
||||
}
|
||||
for region in &self.symmetry_regions {
|
||||
surf.write_region(*region, &self.pre.read_region(*region));
|
||||
}
|
||||
self.symmetry_regions =
|
||||
std::iter::once(original_preview.bounds()).chain(copies.iter().map(StrokeRenderer::bounds)).filter(|r| !r.is_empty()).collect();
|
||||
let damage = original_preview.composite_union_many(&copies, &self.pre, surf, self.sel.as_ref(), self.lock);
|
||||
self.tail = bounds;
|
||||
return Ok(damage.union(&bounds));
|
||||
dmg = dmg.union(&StrokeRenderer::composite_union_live(&mut renderers, &mut live, full, &self.pre, surf, self.sel.as_ref(), self.lock));
|
||||
self.tail = old.union(&live.bounds());
|
||||
self.union_live = Some((live, passes));
|
||||
return Ok(dmg);
|
||||
}
|
||||
let mut dmg = Rect::EMPTY;
|
||||
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
|
||||
|
||||
@@ -608,7 +608,8 @@ pub(crate) fn mask_target_enabled(s: &Session, id: &str, p: &Value) -> Option<st
|
||||
/// (filters, adjustments, paint and fill commands); the shell adds its mask target to these.
|
||||
pub fn follows_target(id: &str) -> bool {
|
||||
// Moving the selected pixels (⌘-drag, Move tool) moves the targeted mask's, not the layer's.
|
||||
routed(id) || id == "select.float"
|
||||
// Moving a layer with its unlinked mask targeted moves the mask alone (`layer.translate`).
|
||||
routed(id) || id == "select.float" || id == "layer.translate"
|
||||
}
|
||||
|
||||
/// Commands whose target follows the Channels panel.
|
||||
|
||||
@@ -92,7 +92,7 @@ fn enabled(s: &Session) -> std::result::Result<(), String> {
|
||||
if locks.pixels || locks.all {
|
||||
return Err(format!("the layer \"{}\" is locked", l.name));
|
||||
}
|
||||
if locks.transparency && !crate::extra_cmds::is_background(l) {
|
||||
if locks.transparency && !d.doc.is_background(l.id) {
|
||||
return Err(format!("the transparency of layer \"{}\" is locked", l.name));
|
||||
}
|
||||
Ok(())
|
||||
@@ -217,7 +217,7 @@ mod tests {
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(name(&s), "Background");
|
||||
let d = s.active().unwrap();
|
||||
assert!(crate::extra_cmds::is_background(d.doc.layer(d.active_layer.unwrap()).unwrap()));
|
||||
assert!(d.doc.is_background(d.active_layer.unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -878,7 +878,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Move Layer",
|
||||
[],
|
||||
None,
|
||||
r##"{"layer":id?,"dx":i32,"dy":i32} (no layer: every selected layer; linked layers follow)"##,
|
||||
r##"{"layer":id?,"dx":i32,"dy":i32,"target":"pixels"|"mask"="pixels"} (no layer: every selected layer; linked layers follow; target mask: a layer whose mask is unlinked moves that mask alone, a linked mask moves with the layer)"##,
|
||||
has_layer,
|
||||
crate::layer_multi_cmds::translate
|
||||
),
|
||||
@@ -1416,7 +1416,7 @@ pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
|
||||
pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result<Layer> {
|
||||
let src = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
// A copy of the Background layer is an ordinary, unlocked layer (Photoshop).
|
||||
let from_background = src.name == "Background" && src.locks.transparency && doc.layers.first().is_some_and(|b| b.id == id);
|
||||
let from_background = doc.is_background(id);
|
||||
let mut dup = src.duplicate();
|
||||
dup.name = doc.copy_name(&dup.name);
|
||||
if from_background {
|
||||
|
||||
@@ -213,15 +213,6 @@ fn smart_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlocks a Background layer for an operation that exposes transparency (as Photoshop does).
|
||||
fn float_background(l: &mut Layer) {
|
||||
if l.locks.position && l.name == "Background" {
|
||||
l.locks.position = false;
|
||||
l.locks.transparency = false;
|
||||
l.name = "Layer 0".into();
|
||||
}
|
||||
}
|
||||
|
||||
fn check_locks(l: &Layer, group: Locks, position: bool) -> Result<()> {
|
||||
let locks = l.locks.union(group);
|
||||
if locks.all || locks.pixels || (position && locks.position) {
|
||||
@@ -247,14 +238,15 @@ fn run_on_layer(s: &mut Session, cmd: &str, label: &str, p: &Value, moves: bool,
|
||||
let canvas = doc.bounds();
|
||||
let selection = doc.selection.clone();
|
||||
let group = crate::transform_cmds::group_locks(doc, id);
|
||||
// A Background gains transparency when the distortion moves pixels (as in Photoshop).
|
||||
if moves {
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
}
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if let LayerContent::Smart(_) = l.content {
|
||||
let sf = SmartFilter { command: cmd.to_string(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
|
||||
return crate::smart_cmds::add_smart_filter(doc, id, sf, selection.as_ref());
|
||||
}
|
||||
if moves {
|
||||
float_background(l);
|
||||
}
|
||||
check_locks(l, group, moves)?;
|
||||
let LayerContent::Raster(surf) = &mut l.content else {
|
||||
return Err(EngineError::Other(format!("{label} needs a pixel layer (rasterize it first)")));
|
||||
|
||||
@@ -82,7 +82,7 @@ fn whole_layers(d: &crate::DocState, ids: &[LayerId]) -> Option<Box<Document>> {
|
||||
let mut copies = scratch_doc(&d.doc);
|
||||
for id in ids {
|
||||
let mut l = d.doc.layer(*id)?.clone();
|
||||
if crate::extra_cmds::is_background(&l) {
|
||||
if d.doc.is_background(*id) {
|
||||
l.name = "Layer 0".into();
|
||||
l.locks = Default::default();
|
||||
}
|
||||
@@ -277,7 +277,8 @@ fn clear_selected(doc: &mut Document, id: LayerId, background: [f32; 4]) -> Resu
|
||||
/// Cut / Clear on a layer: makes the selected pixels transparent. The Background can't hold
|
||||
/// transparency, so there the area is filled with the background colour instead (Photoshop).
|
||||
pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Option<&Surface>, background: [f32; 4]) -> Result<()> {
|
||||
let bg = crate::extra_cmds::is_background(doc.layer(id).ok_or(EngineError::NoLayer(id))?);
|
||||
doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let bg = doc.is_background(id);
|
||||
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
|
||||
if bg {
|
||||
crate::pixels::try_fill_surface(surf, area, background, sel, true)?;
|
||||
@@ -461,7 +462,7 @@ fn merge_visible(s: &mut Session) -> Result<Value> {
|
||||
}
|
||||
let lowest = visible[0];
|
||||
let base = doc.layers[lowest].clone();
|
||||
let is_background = base.name == "Background" && base.locks.transparency;
|
||||
let is_background = lowest == 0 && doc.is_background(base.id);
|
||||
// Composite of visible layers only (hidden ones stay where they are).
|
||||
let mut solo = doc.clone();
|
||||
solo.layers.retain(|l| l.visible);
|
||||
@@ -1363,7 +1364,7 @@ mod tests {
|
||||
let mut s = fresh();
|
||||
s.execute(cmd, json!({})).unwrap();
|
||||
let d = &s.active().unwrap().doc;
|
||||
assert!(crate::extra_cmds::is_background(&d.layers[0]), "{cmd}: still the Background");
|
||||
assert!(d.is_background(d.layers[0].id), "{cmd}: still the Background");
|
||||
assert_eq!(px(&s, 0, 8, 8), vec![1.0, 0.0, 0.0, 1.0], "{cmd}: cut area is the background colour");
|
||||
assert_eq!(px(&s, 0, 30, 20), vec![1.0, 1.0, 1.0, 1.0], "{cmd}: outside untouched");
|
||||
}
|
||||
@@ -1609,9 +1610,9 @@ mod tests {
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), 4);
|
||||
let pasted = &st.doc.layers[st.doc.layers.len() - 2];
|
||||
assert!(!crate::extra_cmds::is_background(pasted));
|
||||
assert!(!st.doc.is_background(pasted.id));
|
||||
assert_eq!(pasted.locks, Default::default());
|
||||
assert!(crate::extra_cmds::is_background(&st.doc.layers[0]), "the real Background is untouched");
|
||||
assert!(st.doc.is_background(st.doc.layers[0].id), "the real Background is untouched");
|
||||
}
|
||||
|
||||
/// #2428: a smart object copied in one document pastes into another as a smart object, in
|
||||
|
||||
@@ -59,19 +59,6 @@ fn active_pixel_layer(s: &Session, cmd: &str) -> Result<LayerId> {
|
||||
s.active().and_then(|d| d.active_layer).ok_or_else(|| bad(cmd, "no active layer"))
|
||||
}
|
||||
|
||||
fn is_background(l: &Layer) -> bool {
|
||||
l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_))
|
||||
}
|
||||
|
||||
/// Photoshop converts the Background to a normal layer before erasing to transparency.
|
||||
fn unlock_background(l: &mut Layer) {
|
||||
if is_background(l) {
|
||||
l.name = "Layer 0".into();
|
||||
l.locks.transparency = false;
|
||||
l.locks.position = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn pixels_locked(doc: &Document, l: &Layer) -> Option<EngineError> {
|
||||
let locks = doc.effective_locks(l.id);
|
||||
(locks.pixels || locks.all).then(|| EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)))
|
||||
@@ -114,7 +101,9 @@ fn magic_eraser(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bg = s.tools.background;
|
||||
let dmg = s.edit("Magic Eraser", |doc, _| {
|
||||
let selection = doc.selection.clone();
|
||||
unlock_background(doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?);
|
||||
// Photoshop converts the Background to a normal layer before erasing to transparency.
|
||||
doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
let lock = doc.effective_locks(id).transparency;
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?;
|
||||
@@ -149,15 +138,15 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(e) = pixels_locked(&d.doc, l) {
|
||||
return Err(e);
|
||||
}
|
||||
if d.doc.effective_locks(id).transparency && !is_background(l) {
|
||||
if d.doc.effective_locks(id).transparency && !d.doc.is_background(id) {
|
||||
return Err(EngineError::Other(format!("Could not use the Background Eraser because the transparency of layer \"{}\" is locked", l.name)));
|
||||
}
|
||||
}
|
||||
let stroke = Stroke { brush, points: pts };
|
||||
let dmg = s.edit("Background Eraser", |doc, _| {
|
||||
let selection = doc.selection.clone();
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
unlock_background(l);
|
||||
let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?;
|
||||
Ok(apply_background_eraser(surf, &stroke, &bs, selection.as_ref()))
|
||||
})?;
|
||||
|
||||
@@ -43,13 +43,9 @@ fn has_clip_and_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
d.doc.selection.as_ref().map(|_| ()).ok_or_else(|| "Paste Into needs a selection".into())
|
||||
}
|
||||
|
||||
pub(crate) fn is_background(l: &Layer) -> bool {
|
||||
l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_))
|
||||
}
|
||||
|
||||
fn has_background(s: &Session) -> std::result::Result<(), String> {
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
d.doc.layers.first().filter(|l| is_background(l)).map(|_| ()).ok_or_else(|| "the document has no Background layer".into())
|
||||
d.doc.background().map(|_| ()).ok_or_else(|| "the document has no Background layer".into())
|
||||
}
|
||||
|
||||
/// The layers a layer-style command acts on: as [`crate::layer_multi_cmds::targets`], but with
|
||||
@@ -60,7 +56,7 @@ pub(crate) fn style_targets(s: &Session, p: &Value, fits: impl Fn(&Layer) -> boo
|
||||
return Ok(ids);
|
||||
}
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !is_background(l) && fits(l))).collect();
|
||||
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !d.doc.is_background(l.id) && fits(l))).collect();
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("none of the selected layers can take this layer style change".into()));
|
||||
}
|
||||
@@ -441,7 +437,7 @@ pub fn reveal_all_bounds(doc: &Document) -> Rect {
|
||||
|
||||
fn layer_from_background(s: &mut Session) -> Result<Value> {
|
||||
let id = s.edit("Layer From Background", |doc, active| {
|
||||
let l = doc.layers.first_mut().filter(|l| is_background(l)).ok_or(EngineError::Other("the document has no Background layer".into()))?;
|
||||
let l = doc.background_mut().ok_or(EngineError::Other("the document has no Background layer".into()))?;
|
||||
unlock_background(l);
|
||||
*active = Some(l.id);
|
||||
Ok(l.id)
|
||||
@@ -458,7 +454,14 @@ pub(crate) fn unlock_background(l: &mut Layer) {
|
||||
/// Before `id` gets a layer mask: the Background can't have one, so it becomes a normal layer
|
||||
/// first ("Layer 0"), as when adding a mask to it in Photoshop. Other layers are left alone.
|
||||
pub(crate) fn background_to_layer_for_mask(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) {
|
||||
if let Some(l) = doc.layers.first_mut().filter(|l| l.id == id && is_background(l)) {
|
||||
background_to_layer(doc, id);
|
||||
}
|
||||
|
||||
/// If `id` is the Background ([`photocraft_doc::Document::background`], whatever its name), turns
|
||||
/// it into a normal layer ("Layer 0"), as Photoshop does before an edit the Background can't
|
||||
/// take (moving, transforming, exposing transparency, a style). Other layers are left alone.
|
||||
pub(crate) fn background_to_layer(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) {
|
||||
if let Some(l) = doc.background_mut().filter(|l| l.id == id) {
|
||||
unlock_background(l);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +285,9 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> R
|
||||
let save = save.into();
|
||||
let mut opts =
|
||||
photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, tga_bits: save.tga_bits, image_previews: save.image_previews, ..Default::default() };
|
||||
// No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP
|
||||
// lossy or lossless as it was), as Photoshop's Save does.
|
||||
photocraft_io::apply_file_encoding(doc, &mut opts.encode);
|
||||
if let Some(q) = save.quality {
|
||||
let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8;
|
||||
opts.encode.jpeg_quality = q;
|
||||
|
||||
@@ -58,7 +58,7 @@ impl CutParts {
|
||||
// A mask has no alpha: it keeps its format, and the cut leaves background colour.
|
||||
let mut rest = if mask { surf.clone() } else { full.clone() };
|
||||
let mut piece = Surface::new(with_alpha);
|
||||
let fill = !copy && (mask || crate::extra_cmds::is_background(l));
|
||||
let fill = !copy && (mask || doc.is_background(layer));
|
||||
// A mask's untouched pixels (its default, e.g. white) move too, so take the whole canvas.
|
||||
let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { surf.content_bounds() });
|
||||
if !b.is_empty() {
|
||||
@@ -165,7 +165,7 @@ fn can_float(s: &Session) -> std::result::Result<(), String> {
|
||||
/// floating? The Background is position-locked, but its selected pixels move (Photoshop).
|
||||
pub fn locked_for_float(doc: &Document, l: &photocraft_doc::Layer) -> bool {
|
||||
let locks = doc.effective_locks(l.id);
|
||||
locks.all || (locks.position && !crate::extra_cmds::is_background(l))
|
||||
locks.all || (locks.position && !doc.is_background(l.id))
|
||||
}
|
||||
|
||||
fn float(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -137,7 +137,7 @@ fn new_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let gid = match content {
|
||||
Some(id) => {
|
||||
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if !matches!(l.content, LayerContent::Raster(_)) || crate::extra_cmds::is_background(l) {
|
||||
if !matches!(l.content, LayerContent::Raster(_)) || doc.is_background(id) {
|
||||
return Err(bad("only a pixel layer (not the Background) can go in a frame".into()));
|
||||
}
|
||||
let gid = doc.insert_above(Some(id), Layer::group(name, vec![]));
|
||||
|
||||
@@ -298,7 +298,7 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi
|
||||
// A Background cannot hold transparent pixels: like an opaque Canvas Size extension, the area
|
||||
// an extending Crop adds is filled with the extension (background) colour.
|
||||
if extends
|
||||
&& let Some(bg) = doc.layers.first_mut().filter(|l| crate::extra_cmds::is_background(l))
|
||||
&& let Some(bg) = doc.background_mut()
|
||||
&& let LayerContent::Raster(surf) = &mut bg.content
|
||||
{
|
||||
let old = old_canvas.translate(dx, dy);
|
||||
@@ -379,16 +379,13 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A shrink or no-op resize must not silently convert the Background.
|
||||
if ext[3] == 0.0
|
||||
&& (nw > ow as u32 || nh > oh as u32)
|
||||
&& let Some(bg) = doc.layers.first_mut()
|
||||
&& crate::extra_cmds::is_background(bg)
|
||||
&& let Some(bg) = doc.background_mut()
|
||||
{
|
||||
crate::extra_cmds::unlock_background(bg);
|
||||
}
|
||||
// The locked Background layer is extended with the extension colour.
|
||||
if ext[3] > 0.0
|
||||
&& let Some(bg) = doc.layers.first_mut()
|
||||
&& bg.name == "Background"
|
||||
&& bg.locks.transparency
|
||||
&& let Some(bg) = doc.background_mut()
|
||||
&& let LayerContent::Raster(surf) = &mut bg.content
|
||||
{
|
||||
let fmt = surf.format();
|
||||
|
||||
@@ -163,7 +163,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let src_layer = sdoc.layer(*id).ok_or(EngineError::NoLayer(*id))?;
|
||||
let mut l = src_layer.duplicate();
|
||||
// A dragged Background arrives as an ordinary layer (the destination keeps its own).
|
||||
if crate::extra_cmds::is_background(src_layer) && sdoc.layers.first().is_some_and(|b| b.id == *id) {
|
||||
if sdoc.is_background(*id) {
|
||||
l.name = ddoc.next_layer_name("Layer");
|
||||
l.locks = Default::default();
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ pub(crate) fn mask_targets(s: &Session, p: &serde_json::Value) -> Result<Vec<Lay
|
||||
return Ok(ids);
|
||||
}
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| l.mask.is_none() && !crate::extra_cmds::is_background(l))).collect();
|
||||
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| l.mask.is_none() && !d.doc.is_background(l.id))).collect();
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("none of the selected layers can take a layer mask".into()));
|
||||
}
|
||||
@@ -163,13 +163,9 @@ pub fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"selected": selected(s).iter().map(|l| l.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
pub(crate) fn is_locked_background(l: &Layer) -> bool {
|
||||
l.name == "Background" && l.locks.transparency && matches!(l.content, LayerContent::Raster(_))
|
||||
}
|
||||
|
||||
fn select_all_layers(s: &mut Session) -> Result<Value> {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| !is_locked_background(l)).map(|(_, _, l)| l.id).collect();
|
||||
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| !d.doc.is_background(l.id)).map(|(_, _, l)| l.id).collect();
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("there are no layers to select".into()));
|
||||
}
|
||||
@@ -283,7 +279,14 @@ pub(crate) fn move_layers(doc: &mut Document, moves: &[(LayerId, i32, i32)]) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Does a move aimed at the layer mask (`"target":"mask"`) move `l`'s mask alone? Only an
|
||||
/// unlinked mask does (the chain off); a linked one moves with its layer, as in Photoshop.
|
||||
pub fn moves_mask_alone(l: &Layer) -> bool {
|
||||
l.mask.as_ref().is_some_and(|m| !m.linked)
|
||||
}
|
||||
|
||||
/// `layer.translate`: the explicit layer, or every selected layer, plus their linked layers.
|
||||
/// With `"target":"mask"` a layer whose mask is unlinked moves that mask alone.
|
||||
pub fn translate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dx = int_i32("layer.translate", p, "dx")?.unwrap_or(0);
|
||||
let dy = int_i32("layer.translate", p, "dy")?.unwrap_or(0);
|
||||
@@ -297,14 +300,27 @@ pub fn translate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if roots.is_empty() {
|
||||
return Err(EngineError::Other("no active layer".into()));
|
||||
}
|
||||
let mask_target = p.get("target").and_then(Value::as_str) == Some("mask");
|
||||
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||||
let ids = s.edit("Move", |doc, _| {
|
||||
let ids = move_targets(doc, &roots);
|
||||
if ids.is_empty() {
|
||||
let (alone, rest): (Vec<LayerId>, Vec<LayerId>) = roots.iter().partition(|id| mask_target && doc.layer(**id).is_some_and(moves_mask_alone));
|
||||
let mut ids = move_targets(doc, &rest);
|
||||
if ids.is_empty() && alone.is_empty() {
|
||||
return Err(EngineError::NoLayer(roots[0]));
|
||||
}
|
||||
let moves: Vec<_> = ids.iter().map(|&id| (id, dx, dy)).collect();
|
||||
move_layers(doc, &moves)?;
|
||||
for id in alone {
|
||||
let locks = doc.effective_locks(id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if locks.position || locks.all {
|
||||
return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name)));
|
||||
}
|
||||
if let Some(m) = &mut l.mask {
|
||||
m.surface = shift_surface(&m.surface, dx, dy);
|
||||
}
|
||||
ids.push(id);
|
||||
}
|
||||
// A board moved past any canvas edge grows the canvas, as a new one does (#1531, #3070).
|
||||
if ids.iter().any(|id| doc.layer(*id).is_some_and(|l| l.artboard().is_some())) {
|
||||
crate::artboard_cmds::fit_canvas(doc);
|
||||
@@ -326,6 +342,11 @@ pub fn move_targets(doc: &Document, roots: &[LayerId]) -> Vec<LayerId> {
|
||||
/// shift as they are instead of re-rendering (the commit re-renders them), so a drag frame costs
|
||||
/// copying the moving pixels only.
|
||||
pub fn moved(doc: &Document, ids: &[LayerId], dx: i32, dy: i32) -> Result<Document> {
|
||||
moved_target(doc, ids, dx, dy, false)
|
||||
}
|
||||
|
||||
/// [`moved`] for a drag aimed at the layer mask (`mask_target`): unlinked masks move alone.
|
||||
pub fn moved_target(doc: &Document, ids: &[LayerId], dx: i32, dy: i32, mask_target: bool) -> Result<Document> {
|
||||
let mut out = doc.clone();
|
||||
for &id in ids {
|
||||
let locks = doc.effective_locks(id);
|
||||
@@ -333,6 +354,12 @@ pub fn moved(doc: &Document, ids: &[LayerId], dx: i32, dy: i32) -> Result<Docume
|
||||
if locks.position || locks.all {
|
||||
return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name)));
|
||||
}
|
||||
if mask_target && moves_mask_alone(l) {
|
||||
if let Some(m) = &mut l.mask {
|
||||
m.surface = shift_surface(&m.surface, dx, dy);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
shift_shown(doc, l, dx, dy);
|
||||
}
|
||||
Ok(out)
|
||||
@@ -597,6 +624,7 @@ fn doc_pixels_equal(a: &Document, b: &Document) -> bool {
|
||||
slices: _,
|
||||
variables: _,
|
||||
timeline: _,
|
||||
file_encoding: _,
|
||||
} = a;
|
||||
*size == b.size
|
||||
&& resolution_dpi.to_bits() == b.resolution_dpi.to_bits()
|
||||
@@ -1443,6 +1471,59 @@ mod tests {
|
||||
s.active().unwrap().last_damage
|
||||
}
|
||||
|
||||
/// A mask with its chain off stays put while the layer moves; a linked one moves along.
|
||||
#[test]
|
||||
fn translate_leaves_an_unlinked_mask_in_place() {
|
||||
for depth in [8, 16] {
|
||||
let mut s = session(depth);
|
||||
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
s.execute("layer.layerMask.hideAll", json!({})).unwrap();
|
||||
s.edit("mask", |doc, _| {
|
||||
doc.layer_mut(a).unwrap().mask.as_mut().unwrap().surface.fill_rect(Rect::new(12, 12, 18, 18), &[1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let mval = |s: &Session, x, y| doc(s).layer(a).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0);
|
||||
s.execute("layer.layerMask.linked", json!({"linked": false})).unwrap();
|
||||
s.execute("layer.translate", json!({"dx": 30, "dy": 0})).unwrap();
|
||||
assert!(mval(&s, 14, 14) > 0.9, "{depth}-bit: unlinked mask stays");
|
||||
assert!(mval(&s, 44, 14) < 0.1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("layer.layerMask.linked", json!({"linked": true})).unwrap();
|
||||
s.execute("layer.translate", json!({"dx": 30, "dy": 0})).unwrap();
|
||||
assert!(mval(&s, 44, 14) > 0.9, "{depth}-bit: linked mask moves");
|
||||
}
|
||||
}
|
||||
|
||||
/// Aimed at the mask, a move shifts an unlinked mask alone and leaves a linked one with its layer.
|
||||
#[test]
|
||||
fn translate_with_the_mask_targeted_moves_an_unlinked_mask_alone() {
|
||||
for depth in [8, 16] {
|
||||
let mut s = session(depth);
|
||||
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
s.execute("layer.layerMask.hideAll", json!({})).unwrap();
|
||||
s.edit("mask", |doc, _| {
|
||||
doc.layer_mut(a).unwrap().mask.as_mut().unwrap().surface.fill_rect(Rect::new(12, 12, 18, 18), &[1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let mval = |s: &Session, x, y| doc(s).layer(a).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0);
|
||||
// Linked: the layer and its mask move together.
|
||||
s.execute("layer.translate", json!({"dx": 30, "dy": 0, "target": "mask"})).unwrap();
|
||||
assert_eq!(bounds(&s, a), Rect::new(40, 10, 50, 20), "{depth}-bit");
|
||||
assert!(mval(&s, 44, 14) > 0.9);
|
||||
s.execute("layer.layerMask.linked", json!({"linked": false})).unwrap();
|
||||
s.execute("layer.translate", json!({"dx": -30, "dy": 0, "target": "mask"})).unwrap();
|
||||
assert_eq!(bounds(&s, a), Rect::new(40, 10, 50, 20), "{depth}-bit: the layer stays");
|
||||
assert!(mval(&s, 14, 14) > 0.9, "the mask moved");
|
||||
assert!(mval(&s, 44, 14) < 0.1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(mval(&s, 44, 14) > 0.9, "undo puts the mask back");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_and_layer_props_report_only_their_area() {
|
||||
let mut s = session(8);
|
||||
|
||||
@@ -287,6 +287,7 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg:
|
||||
let canvas = doc.bounds();
|
||||
let selection = doc.selection.clone();
|
||||
let locks = doc.effective_locks(id);
|
||||
let background = doc.is_background(id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if let LayerContent::Smart(_) = l.content {
|
||||
let sf = SmartFilter { command: cmd.to_string(), params: stored.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
|
||||
@@ -296,7 +297,7 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg:
|
||||
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
|
||||
}
|
||||
// A Background layer gains transparency when the edge mode exposes it (as in Photoshop).
|
||||
if float_bg && l.locks.position && l.name == "Background" {
|
||||
if float_bg && background {
|
||||
l.locks.position = false;
|
||||
l.locks.transparency = false;
|
||||
l.name = "Layer 0".into();
|
||||
|
||||
@@ -91,8 +91,9 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let canvas = doc.bounds();
|
||||
let fmt = doc.pixel_format();
|
||||
let dpi = doc.resolution_dpi;
|
||||
let background_id = doc.background().map(|l| l.id);
|
||||
for l in doc.layers.iter_mut() {
|
||||
let background = l.name == "Background" && l.locks.position && matches!(l.content, LayerContent::Raster(_));
|
||||
let background = Some(l.id) == background_id;
|
||||
if background && let Some(surf) = l.surface_mut() {
|
||||
// The Background stays a Background: rotated pixels over the background colour.
|
||||
let src = surf.content_bounds();
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
use photocraft_algo::transform::{Homography, Interp, convex_quad, quad_size, quad_to_rect, warp_surface_clipped};
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_doc::{Document, Layer, LayerContent, Size};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Size};
|
||||
use photocraft_geom::{Affine, Rect};
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::{Value, json};
|
||||
@@ -154,12 +154,13 @@ fn warp_gray(s: &Surface, h: &Homography, interp: Interp, clip: Rect) -> Result<
|
||||
}
|
||||
|
||||
/// Maps every layer's pixels and masks through `h` into `clip`.
|
||||
fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, bg: &[f32]) -> Result<()> {
|
||||
/// `background_id` is the id of the document's Background, if it has one.
|
||||
fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, bg: &[f32], background_id: Option<LayerId>) -> Result<()> {
|
||||
for l in layers {
|
||||
crate::allocation::checkpoint("a perspective crop")?;
|
||||
let background = crate::extra_cmds::is_background(l);
|
||||
let background = background_id == Some(l.id);
|
||||
match &mut l.content {
|
||||
LayerContent::Group(g) => warp_layers(&mut g.children, h, interp, clip, bg)?,
|
||||
LayerContent::Group(g) => warp_layers(&mut g.children, h, interp, clip, bg, None)?,
|
||||
LayerContent::Raster(surf) => {
|
||||
let warped = warp_surface_clipped(surf, surf.content_bounds(), h, interp, clip)?;
|
||||
*surf = if background {
|
||||
@@ -188,7 +189,8 @@ fn apply(doc: &mut Document, q: &[[f64; 2]; 4], h: &Homography, size: (u32, u32)
|
||||
let (w, ht) = size;
|
||||
let clip = Rect::from_xywh(0, 0, w, ht);
|
||||
let bg = photocraft_raster::from_rgba(&doc.pixel_format(), bg);
|
||||
warp_layers(&mut doc.layers, h, interp, clip, &bg)?;
|
||||
let background_id = doc.background().map(|l| l.id);
|
||||
warp_layers(&mut doc.layers, h, interp, clip, &bg, background_id)?;
|
||||
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
|
||||
ch.surface = warp_gray(&ch.surface, h, interp, clip)?;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ pub fn layers_in_rect(doc: &Document, r: Rect, group: bool) -> Vec<LayerId> {
|
||||
let canvas = doc.bounds();
|
||||
for (path, _, l) in doc.walk().iter().rev() {
|
||||
if matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_))
|
||||
|| crate::layer_multi_cmds::is_locked_background(l)
|
||||
|| doc.is_background(l.id)
|
||||
|| doc.effective_locks(l.id).all
|
||||
|| !(1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|a| a.visible))
|
||||
{
|
||||
|
||||
@@ -191,13 +191,9 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mode = doc.mode;
|
||||
let effects: Vec<_> = effects.iter().cloned().map(|e| e.in_mode(mode)).collect();
|
||||
for id in &ids {
|
||||
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
|
||||
// Styles can't sit on the Background: Photoshop turns it into a normal layer first.
|
||||
if l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, photocraft_doc::LayerContent::Raster(_)) {
|
||||
l.name = "Layer 0".into();
|
||||
l.locks.transparency = false;
|
||||
l.locks.position = false;
|
||||
}
|
||||
crate::extra_cmds::background_to_layer(doc, *id);
|
||||
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
|
||||
if add {
|
||||
l.effects.items.extend(effects.iter().cloned());
|
||||
} else {
|
||||
|
||||
@@ -50,7 +50,7 @@ fn has_layer_for_slice(s: &Session) -> std::result::Result<(), String> {
|
||||
if matches!(l.content, LayerContent::Adjustment(_)) {
|
||||
return Err("adjustment layers have no bounds to slice".into());
|
||||
}
|
||||
if d.doc.layers.first().is_some_and(|b| b.id == id && b.name == "Background" && b.locks.transparency && b.locks.position) {
|
||||
if d.doc.is_background(id) {
|
||||
return Err("the Background layer can't have a layer-based slice".into());
|
||||
}
|
||||
if d.doc.slices.list.iter().any(|s| s.layer == Some(id)) {
|
||||
|
||||
@@ -666,7 +666,7 @@ fn any_layer(l: &Layer, f: &dyn Fn(&Layer) -> bool) -> bool {
|
||||
/// blend mode, clipping and label stay on the smart layer. The cache is the exact composite, so
|
||||
/// the document looks the same before and after.
|
||||
pub fn layer_to_smart(doc: &Document, l: &Layer) -> Result<Layer> {
|
||||
let background = l.name == "Background" && l.locks.position;
|
||||
let background = doc.is_background(l.id);
|
||||
let mut inner = l.clone();
|
||||
inner.visible = true;
|
||||
inner.opacity = 1.0;
|
||||
|
||||
@@ -32,9 +32,10 @@ fn contents(s: &Session, doc: &Document, sm: &SmartObject, intent: Intent, bpc:
|
||||
if src.depth != doc.depth {
|
||||
crate::image_cmds::convert_layers_depth(&mut src.layers, doc.depth);
|
||||
}
|
||||
let background_id = src.background().map(|l| l.id);
|
||||
let mut layers = std::mem::take(&mut src.layers);
|
||||
for l in &mut layers {
|
||||
if crate::extra_cmds::is_background(l) {
|
||||
if Some(l.id) == background_id {
|
||||
l.name = "Layer 0".into();
|
||||
l.locks = Default::default();
|
||||
}
|
||||
|
||||
@@ -201,10 +201,10 @@ pub(crate) fn group_locks(doc: &Document, id: LayerId) -> Locks {
|
||||
}
|
||||
|
||||
/// The locks a transform of `l` meets: its own and `group`'s (those it inherits). The Background's
|
||||
/// position lock doesn't count: Photoshop turns it into a normal layer first ([`transform_layer`]).
|
||||
fn transform_locks(l: &Layer, group: Locks) -> Locks {
|
||||
/// position lock doesn't count: Photoshop turns it into a normal layer first.
|
||||
fn transform_locks(l: &Layer, group: Locks, background: bool) -> Locks {
|
||||
let mut own = l.locks;
|
||||
if own.position && l.name == "Background" {
|
||||
if background {
|
||||
own.position = false;
|
||||
own.transparency = false;
|
||||
}
|
||||
@@ -217,29 +217,24 @@ fn transform_locks(l: &Layer, group: Locks) -> Locks {
|
||||
pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool {
|
||||
let Some(l) = doc.layer(id) else { return false };
|
||||
// An explicit stack rather than recursion: documents can nest deeply.
|
||||
let mut stack = vec![(l, group_locks(doc, id))];
|
||||
while let Some((l, group)) = stack.pop() {
|
||||
let locks = transform_locks(l, group);
|
||||
let mut stack = vec![(l, group_locks(doc, id), doc.is_background(id))];
|
||||
while let Some((l, group, background)) = stack.pop() {
|
||||
let locks = transform_locks(l, group, background);
|
||||
if locks.position || locks.all {
|
||||
return true;
|
||||
}
|
||||
if let LayerContent::Group(g) = &l.content {
|
||||
stack.extend(g.children.iter().map(|c| (c, locks)));
|
||||
stack.extend(g.children.iter().map(|c| (c, locks, false)));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]).
|
||||
/// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]). A Background
|
||||
/// is turned into a normal layer by the caller first ([`crate::extra_cmds::background_to_layer`]).
|
||||
pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option<Affine>, interp: Interp) -> Result<()> {
|
||||
crate::allocation::checkpoint("transforming pixels")?;
|
||||
let locks = transform_locks(l, group);
|
||||
// Photoshop turns the Background into a normal layer before transforming it.
|
||||
if l.locks.position && l.name == "Background" {
|
||||
l.locks.position = false;
|
||||
l.locks.transparency = false;
|
||||
l.name = "Layer 0".into();
|
||||
}
|
||||
let locks = transform_locks(l, group, false);
|
||||
if locks.position || locks.all {
|
||||
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
|
||||
}
|
||||
@@ -493,6 +488,8 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let is_group = doc.layer(id).is_some_and(Layer::is_group);
|
||||
let group = group_locks(doc, id);
|
||||
// Photoshop turns the Background into a normal layer before transforming it.
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
transform_layer(if is_group { None } else { sel.as_ref() }, group, l, &h, affine, interp)?;
|
||||
if !h.is_identity() {
|
||||
|
||||
@@ -121,10 +121,23 @@ pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The PostScript name of the installed face of `family` at exactly `weight`/`italic`. A PSD names
|
||||
/// a type layer by PostScript name, and Photoshop treats a name no font has as missing (#3556).
|
||||
/// Only an exact face is named: layout picks a named face before weight and italic, so naming a
|
||||
/// nearby face would override the requested weight (a variable font's axis, a weight between two
|
||||
/// faces) or italic. Without an exact face the style keeps no name and layout matches as before.
|
||||
fn installed_postscript(family: &str, weight: u16, italic: bool) -> Option<String> {
|
||||
let mut shared = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let faces = shared.fonts.faces(family);
|
||||
let face = faces.iter().find(|f| f.italic == italic && f.weight.round() as u16 == weight)?;
|
||||
face.postscript_name.clone()
|
||||
}
|
||||
|
||||
/// Applies character-style keys from JSON. Returns true if any key was present.
|
||||
pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
|
||||
let mut any = false;
|
||||
let mut hit = |b: bool| any |= b;
|
||||
let font_key = ["font", "family", "fontStyle", "weight", "italic"].iter().any(|k| p.get(k).is_some());
|
||||
if let Some(v) = p.get("font").or_else(|| p.get("family")).and_then(Value::as_str) {
|
||||
s.font_family = v.to_string();
|
||||
s.postscript_name = None;
|
||||
@@ -169,6 +182,11 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
|
||||
s.postscript_name = Some(v.to_string());
|
||||
hit(true);
|
||||
}
|
||||
// A font named by family (or a style that matched no face) has no PostScript name yet: fill in
|
||||
// the installed face's real name, so the PSD does not invent "<Family>-Regular" (#3556).
|
||||
if font_key && s.postscript_name.is_none() {
|
||||
s.postscript_name = installed_postscript(&s.font_family, s.weight, s.italic);
|
||||
}
|
||||
if let Some(v) = f32p(p, "size") {
|
||||
s.size_pt = v.max(0.01);
|
||||
hit(true);
|
||||
@@ -989,14 +1007,36 @@ mod tests {
|
||||
assert_eq!(style.weight, 600);
|
||||
assert_eq!(style.postscript_name.as_deref(), Some("Inter-SemiBold"));
|
||||
apply_char_props(&mut style, &json!({"weight": 400}));
|
||||
assert!(style.postscript_name.is_none());
|
||||
// #3556: a style change re-resolves to the installed face instead of leaving it nameless.
|
||||
assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular"));
|
||||
assert!(style.font_style.is_empty());
|
||||
apply_char_props(&mut style, &json!({"fontStyle": "unknown-style"}));
|
||||
assert!(style.postscript_name.is_none());
|
||||
assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular"));
|
||||
apply_char_props(&mut style, &json!({"fontStyle": "Regular", "postscriptName": "ExplicitFace"}));
|
||||
assert_eq!(style.postscript_name.as_deref(), Some("ExplicitFace"));
|
||||
}
|
||||
|
||||
/// #3556: a font named by family stores the installed face's PostScript name, so PSD export
|
||||
/// names a real font instead of inventing "<Family>-Regular" that Photoshop calls missing.
|
||||
#[test]
|
||||
fn a_family_font_gets_the_installed_postscript_name() {
|
||||
let mut style = CharStyle { font_family: "Inter".into(), ..Default::default() };
|
||||
apply_char_props(&mut style, &json!({"font": "Inter"}));
|
||||
assert_eq!(style.font_family, "Inter");
|
||||
assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular"), "resolved from the font database");
|
||||
// A weight picks that face's name.
|
||||
apply_char_props(&mut style, &json!({"font": "Inter", "weight": 600}));
|
||||
assert_eq!(style.postscript_name.as_deref(), Some("Inter-SemiBold"));
|
||||
// A weight no face has (a variable font's axis, or between two faces) names no face, so
|
||||
// layout still matches the requested weight instead of a nearby face's.
|
||||
apply_char_props(&mut style, &json!({"weight": 650}));
|
||||
assert!(style.postscript_name.is_none(), "{:?}", style.postscript_name);
|
||||
// A family PhotoCraft does not have keeps no name; the export falls back as before.
|
||||
let mut absent = CharStyle { font_family: "No Such Family 123".into(), ..Default::default() };
|
||||
apply_char_props(&mut absent, &json!({"font": "No Such Family 123"}));
|
||||
assert!(absent.postscript_name.is_none());
|
||||
}
|
||||
|
||||
/// Picking a style or weight resets the weight axis to it; other axes keep their values (#2985).
|
||||
#[test]
|
||||
fn a_style_or_weight_resets_the_weight_axis() {
|
||||
|
||||
@@ -110,12 +110,9 @@ pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result<Warp> {
|
||||
Ok(w)
|
||||
}
|
||||
|
||||
/// A Background is turned into a normal layer by the caller first
|
||||
/// ([`crate::extra_cmds::background_to_layer`]).
|
||||
fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp) -> Result<()> {
|
||||
if l.locks.position && l.name == "Background" {
|
||||
l.locks.position = false;
|
||||
l.locks.transparency = false;
|
||||
l.name = "Layer 0".into();
|
||||
}
|
||||
let locks = l.locks.union(group);
|
||||
if locks.position || locks.all {
|
||||
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
|
||||
@@ -270,6 +267,7 @@ fn apply(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let sel = doc.selection.clone();
|
||||
let is_group = doc.layer(id).is_some_and(Layer::is_group);
|
||||
let group = crate::transform_cmds::group_locks(doc, id);
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
warp_layer(if is_group { None } else { sel.as_ref() }, group, l, &w, rect, interp)?;
|
||||
let snapshot = doc.clone();
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! #2399: a save writes a JPEG or WebP back the way it was opened. A JPEG keeps the quality
|
||||
//! estimated from its quantisation tables, a lossy WebP stays lossy and a lossless one lossless,
|
||||
//! unless the save asks for a quality; the remembered settings survive a .pcraft round trip.
|
||||
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType, WebpEncoding, jpeg_quality, webp_encoding};
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_engine::file_cmds::open_bytes_as;
|
||||
use serde_json::json;
|
||||
|
||||
fn photo() -> Image {
|
||||
let (w, h) = (48u32, 32u32);
|
||||
let px = (0..w * h).flat_map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
[(x * 5) as u8, (y * 7) as u8, ((x * y) % 251) as u8]
|
||||
});
|
||||
Image::from_raw(w, h, ChannelLayout::Rgb, SampleType::U8, px.collect()).unwrap()
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-2399-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
/// Opens `bytes` as `name`, saves a copy to `out` with `params`, and returns the saved file.
|
||||
fn open_and_save(name: &str, bytes: &[u8], out: &str, params: serde_json::Value) -> (Session, Vec<u8>) {
|
||||
let mut s = Session::new();
|
||||
open_bytes_as(&mut s, name, bytes, None, None).unwrap();
|
||||
let path = tmp(out);
|
||||
let mut p = params;
|
||||
p["path"] = json!(path.to_string_lossy());
|
||||
s.execute("file.saveACopy", p).unwrap();
|
||||
let saved = std::fs::read(&path).unwrap();
|
||||
(s, saved)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpeg_saves_at_its_own_quality() {
|
||||
for q in [35u8, 60, 77, 95] {
|
||||
let src = photocraft_codecs::encode(&photo(), Format::Jpeg, &EncodeOptions { jpeg_quality: q, ..Default::default() }).unwrap();
|
||||
let (s, saved) = open_and_save("in.jpg", &src, &format!("q{q}.jpg"), json!({}));
|
||||
let remembered = s.active().unwrap().doc.file_encoding.jpeg_quality.unwrap();
|
||||
assert!(remembered.abs_diff(q) <= 2, "opened at {q}, remembered {remembered}");
|
||||
let est = jpeg_quality(&saved).unwrap();
|
||||
assert!(est.abs_diff(q) <= 2, "opened at {q}, saved at {est}");
|
||||
}
|
||||
// A quality the save asks for still wins (Photoshop's 0–12 scale: 12 is the top).
|
||||
let src = photocraft_codecs::encode(&photo(), Format::Jpeg, &EncodeOptions { jpeg_quality: 40, ..Default::default() }).unwrap();
|
||||
let (_, saved) = open_and_save("in.jpg", &src, "explicit.jpg", json!({"quality": 12}));
|
||||
assert_eq!(jpeg_quality(&saved), Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_webp_stays_lossy_or_lossless() {
|
||||
let lossy = photocraft_codecs::encode(&photo(), Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: 60, ..Default::default() }).unwrap();
|
||||
let (s, saved) = open_and_save("in.webp", &lossy, "lossy.webp", json!({}));
|
||||
let e = s.active().unwrap().doc.file_encoding;
|
||||
assert_eq!(e.webp_lossless, Some(false));
|
||||
let Some(WebpEncoding::Lossy { quality: Some(q) }) = webp_encoding(&saved) else { panic!("saved {:?}", webp_encoding(&saved)) };
|
||||
assert!(q.abs_diff(60) <= 2, "lossy WebP at 60 saved at {q}");
|
||||
|
||||
let lossless = photocraft_codecs::encode(&photo(), Format::WebP, &EncodeOptions::default()).unwrap();
|
||||
let (s, saved) = open_and_save("in.webp", &lossless, "lossless.webp", json!({}));
|
||||
assert_eq!(s.active().unwrap().doc.file_encoding.webp_lossless, Some(true));
|
||||
assert_eq!(webp_encoding(&saved), Some(WebpEncoding::Lossless));
|
||||
assert_eq!(photocraft_codecs::decode(&saved).unwrap().data(), photocraft_codecs::decode(&lossless).unwrap().data(), "lossless round trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_document_and_a_png_save_at_the_defaults() {
|
||||
let png = photocraft_codecs::encode(&photo(), Format::Png, &EncodeOptions::default()).unwrap();
|
||||
let (s, saved) = open_and_save("in.png", &png, "from-png.jpg", json!({}));
|
||||
assert!(s.active().unwrap().doc.file_encoding.is_empty());
|
||||
assert_eq!(jpeg_quality(&saved), Some(EncodeOptions::default().jpeg_quality));
|
||||
let (_, saved) = open_and_save("in.png", &png, "from-png.webp", json!({}));
|
||||
assert_eq!(webp_encoding(&saved), Some(WebpEncoding::Lossless));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_remembered_encoding_survives_pcraft() {
|
||||
let src = photocraft_codecs::encode(&photo(), Format::Jpeg, &EncodeOptions { jpeg_quality: 70, ..Default::default() }).unwrap();
|
||||
let mut s = Session::new();
|
||||
open_bytes_as(&mut s, "in.jpg", &src, None, None).unwrap();
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let bytes = photocraft_format::save_to_bytes(doc, &photocraft_format::SaveOptions::default()).unwrap();
|
||||
let back = photocraft_format::load_from_bytes(&bytes).unwrap();
|
||||
assert_eq!(back.file_encoding, doc.file_encoding);
|
||||
assert!(back.file_encoding.jpeg_quality.is_some());
|
||||
}
|
||||
@@ -253,6 +253,7 @@ pub(crate) fn doc_m(d: &Document, sink: &mut dyn Sink) -> DocM {
|
||||
duotone: d.duotone.clone(),
|
||||
variables: d.variables.clone(),
|
||||
timeline: d.timeline.clone(),
|
||||
file_encoding: d.file_encoding,
|
||||
layer_comps: d.layer_comps.iter().map(|c| comp_m(c, sink)).collect(),
|
||||
last_applied_comp: d.last_applied_comp,
|
||||
last_document_state: d.last_document_state.as_ref().map(|c| comp_m(c, sink)),
|
||||
@@ -631,6 +632,7 @@ impl Loader<'_> {
|
||||
duotone: m.duotone.clone(),
|
||||
variables: m.variables.clone(),
|
||||
timeline: m.timeline.clone(),
|
||||
file_encoding: m.file_encoding,
|
||||
layer_comps,
|
||||
last_applied_comp: m.last_applied_comp,
|
||||
last_document_state,
|
||||
|
||||
@@ -84,6 +84,9 @@ pub struct DocM {
|
||||
/// Window › Timeline.
|
||||
#[serde(default)]
|
||||
pub timeline: Option<photocraft_doc::Timeline>,
|
||||
/// The JPEG quality / WebP encoding File › Save reuses.
|
||||
#[serde(default, skip_serializing_if = "photocraft_doc::FileEncoding::is_empty")]
|
||||
pub file_encoding: photocraft_doc::FileEncoding,
|
||||
/// Image › Analysis: measurement scale, count groups, ruler.
|
||||
#[serde(default)]
|
||||
pub measurement: photocraft_doc::Measurement,
|
||||
|
||||
@@ -1178,7 +1178,7 @@ fn fs_mfinish(in: VOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
|
||||
// Chiselled bevel height from A = dist_inside, B = dist_outside (`effects::bevel_chisel_h`):
|
||||
// p0 = (paint (0 outer, 1 both / emboss, 2 inner), size).
|
||||
// p0 = (paint (0 outer, 1 both / emboss, 2 inner), size, Chisel Soft).
|
||||
@fragment
|
||||
fn fs_mbevelh(in: VOut) -> @location(0) vec4<f32> {
|
||||
let p = local(in.pos);
|
||||
@@ -1186,16 +1186,33 @@ fn fs_mbevelh(in: VOut) -> @location(0) vec4<f32> {
|
||||
let dout = textureLoad(tex_b, p, 0).r;
|
||||
let size = op.p0.y;
|
||||
var h: f32;
|
||||
// The ramp spans size + 1 px, from half a pixel beyond the edge (`effects::bevel_chisel_h`).
|
||||
let ramp = size + 1.0;
|
||||
let soft = op.p0.z > 0.5;
|
||||
switch i32(op.p0.x) {
|
||||
case 0: { h = 1.0 - clamp(dout / size, 0.0, 1.0); }
|
||||
case 1: {
|
||||
if (din > 0.0) {
|
||||
h = 0.5 + 0.5 * clamp(din / (size / 2.0), 0.0, 1.0);
|
||||
case 0: {
|
||||
if (soft) {
|
||||
h = select((size + 2.0) / ramp, 1.0 - clamp((dout + 0.5) / ramp, 0.0, 1.0), dout > 0.0);
|
||||
} else {
|
||||
h = 0.5 - 0.5 * clamp(dout / (size / 2.0), 0.0, 1.0);
|
||||
let top = (size + 2.0) / ramp;
|
||||
h = select(top, clamp((size + 1.5 - dout) / ramp, 0.0, top), dout > 0.0);
|
||||
}
|
||||
}
|
||||
case 1: {
|
||||
let half = size / 2.0 + 0.5;
|
||||
if (din > 0.0) {
|
||||
h = 0.5 + 0.5 * clamp(din / half, 0.0, 1.0);
|
||||
} else {
|
||||
h = 0.5 - 0.5 * clamp(dout / half, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
default: {
|
||||
if (soft) {
|
||||
h = select(0.0, clamp((din + 1.5) / ramp, 0.0, (size + 2.0) / ramp), din > 0.0);
|
||||
} else {
|
||||
h = select(0.0, clamp((din + 0.5) / ramp, 0.0, 1.0), din > 0.0);
|
||||
}
|
||||
}
|
||||
default: { h = clamp(din / size, 0.0, 1.0); }
|
||||
}
|
||||
return mout(h);
|
||||
}
|
||||
@@ -1209,7 +1226,8 @@ fn fs_mbevelshade(in: VOut) -> @location(0) vec4<f32> {
|
||||
// effects::bevel_maps' on_bevel: the height above BEVEL_H_EPS (emboss styles: or a
|
||||
// 4-neighbour's).
|
||||
var hmax = ra(p);
|
||||
if (op.p1.y > 1.5) {
|
||||
// p3.x: an outer chisel bevel, which also reaches one pixel past its ramp.
|
||||
if (op.p1.y > 1.5 || op.p3.x > 0.5) {
|
||||
hmax = max(max(hmax, max(ra(p + vec2(1, 0)), ra(p - vec2(1, 0)))), max(ra(p + vec2(0, 1)), ra(p - vec2(0, 1))));
|
||||
}
|
||||
let outside = select(0.0, 1.0, s < 1.0 - INSIDE_EPS && hmax > 1e-5);
|
||||
|
||||
@@ -376,8 +376,10 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
|
||||
} else {
|
||||
let din = b.field(FieldKind::BevelInside, size);
|
||||
let dout = b.field(FieldKind::BevelOutside, size);
|
||||
let h = b.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint(g.paint), size, 0.0, 0.0], 0));
|
||||
if g.chisel_soft > 0.0 { b.conv(h, photocraft_compose::effects::tent_kernel(g.chisel_soft)) } else { h }
|
||||
let soft = g.chisel_soft > 0.0;
|
||||
let h = b.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint(g.paint), size, f32::from(u8::from(soft)), 0.0], 0));
|
||||
// As `effects::bevel_height`: only emboss styles blur a Chisel Soft ramp.
|
||||
if soft && g.paint == BevelPaint::Both { b.conv(h, photocraft_compose::effects::tent_kernel(g.chisel_soft)) } else { h }
|
||||
};
|
||||
if let Some(c) = &bv.contour {
|
||||
// Contour element: the height through the contour (`bevel_contour_lut`).
|
||||
@@ -418,6 +420,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
|
||||
for which in [0.0, 1.0] {
|
||||
let mut s = stage(Kernel::MBevelShade, Some(h), None, [ca * ce, -sa * ce, se, se], 1);
|
||||
s.p1 = [*depth, *region, which, f32::from(u8::from(lut.is_some()))];
|
||||
s.p3 = [f32::from(u8::from(g.paint == BevelPaint::Outer && bv.technique != BevelTechnique::Smooth)), 0.0, 0.0, 0.0];
|
||||
s.lut = lut.clone();
|
||||
s.s = Some(In::Shape);
|
||||
s.out = Some(out);
|
||||
|
||||
+107
-6
@@ -267,7 +267,10 @@ fn be32(d: &[u8], at: usize) -> Option<u32> {
|
||||
/// key), reverse, dither, [method], name, colour stops (location /4096, midpoint %, colour
|
||||
/// space + four u16 components, 2 bytes), transparency stops, then smoothness (/4096) among
|
||||
/// noise-gradient fields. Smoothness, midpoints and the method are baked into dense stops like
|
||||
/// gradient fills. Noise gradients (no colour stops) and non-RGB stops stay unsupported.
|
||||
/// gradient fills. Stops may be in any plain colour space Photoshop writes (RGB, HSB, CMYK, Lab,
|
||||
/// Grayscale: a CMYK or Lab document stores them in its own space); they are converted to RGB
|
||||
/// through the colour-managed conversions (the document's CMYK profile when one is in scope).
|
||||
/// Noise gradients (no colour stops) and stops in other spaces (book colours) stay unsupported.
|
||||
fn parse_gradient_map(d: &[u8]) -> Option<Adjustment> {
|
||||
let version = be16(d, 0)?;
|
||||
let reverse = *d.get(2)? != 0;
|
||||
@@ -285,11 +288,9 @@ fn parse_gradient_map(d: &[u8]) -> Option<Adjustment> {
|
||||
for _ in 0..n {
|
||||
let loc = (be32(d, at)? as f32 / 4096.0).clamp(0.0, 1.0);
|
||||
let mid = be32(d, at + 4)? as f32 / 100.0;
|
||||
if be16(d, at + 8)? != 0 {
|
||||
return None;
|
||||
}
|
||||
let c = |k: usize| be16(d, at + 10 + 2 * k).map(|v| f32::from(v) / 65535.0);
|
||||
stops.push((loc, photocraft_color::Color::rgb(c(0)?, c(1)?, c(2)?)));
|
||||
let space = be16(d, at + 8)?;
|
||||
let c = |k: usize| be16(d, at + 10 + 2 * k);
|
||||
stops.push((loc, crate::channel_map::decode_known_color(space, [c(0)?, c(1)?, c(2)?, c(3)?])?));
|
||||
mids.push(mid);
|
||||
at += 20;
|
||||
}
|
||||
@@ -309,6 +310,23 @@ fn parse_gradient_map(d: &[u8]) -> Option<Adjustment> {
|
||||
Some(Adjustment::GradientMap { stops, reverse, dither })
|
||||
}
|
||||
|
||||
/// Points an imported `grdm` block at `target` when only Reverse and/or Dither differ (the
|
||||
/// stops parse unchanged): patches those two bytes and returns `true`, so the file's own stops
|
||||
/// (in whatever colour space it stored them), smoothness and method survive the save. `false`
|
||||
/// leaves `data` untouched.
|
||||
pub(crate) fn retarget_gradient_map(data: &mut [u8], target: &Adjustment, channels: Channels) -> bool {
|
||||
let Adjustment::GradientMap { stops, reverse, dither } = target else { return false };
|
||||
let same_stops = matches!(parse(b"grdm", data, None, channels), Adjustment::GradientMap { stops: s, .. } if s == *stops);
|
||||
match (same_stops, data.get_mut(2..4)) {
|
||||
(true, Some(flags)) => {
|
||||
flags[0] = u8::from(*reverse);
|
||||
flags[1] = u8::from(*dither);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `grdm` version 1 for [`Adjustment::GradientMap`] (Classic interpolation, Smoothness 0, so
|
||||
/// the stops are reproduced exactly).
|
||||
fn write_gradient_map(stops: &[(f32, [f32; 3])], reverse: bool, dither: bool) -> Vec<u8> {
|
||||
@@ -824,6 +842,89 @@ mod tests {
|
||||
assert_eq!(write(&a)[0].1, block);
|
||||
}
|
||||
|
||||
/// A two-stop `grdm` (version 1) whose stops are the colour structures `a` and `b`.
|
||||
fn grdm_with(a: (u16, [u16; 4]), b: (u16, [u16; 4])) -> Vec<u8> {
|
||||
let mut v = write_gradient_map(&[(0.0, [0.0; 3]), (1.0, [1.0; 3])], false, false);
|
||||
// Header 4 + name length 4 + "Custom\0" 14 + stop count 2; 20 bytes per stop.
|
||||
for (i, (space, c)) in [a, b].into_iter().enumerate() {
|
||||
let at = 24 + 20 * i + 8;
|
||||
v[at..at + 2].copy_from_slice(&space.to_be_bytes());
|
||||
for (k, x) in c.iter().enumerate() {
|
||||
v[at + 2 + 2 * k..at + 4 + 2 * k].copy_from_slice(&x.to_be_bytes());
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn ends(a: &Adjustment) -> ([f32; 3], [f32; 3]) {
|
||||
match a {
|
||||
Adjustment::GradientMap { stops, .. } => (stops[0].1, stops[stops.len() - 1].1),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_map_stops_in_every_colour_space() {
|
||||
use photocraft_color::{Color, ColorMode};
|
||||
let white_rgb = (0, [65535, 65535, 65535, 0]);
|
||||
// CMYK, stored inverted: 65535 = no ink. Full K only.
|
||||
let k_only = grdm_with((2, [65535, 65535, 65535, 0]), white_rgb);
|
||||
let expected = Color { mode: ColorMode::Cmyk, c: [0.0, 0.0, 0.0, 1.0], alpha: 1.0 }.to_rgb();
|
||||
for channels in [Channels::Cmyk, Channels::Rgb, Channels::Lab] {
|
||||
let a = parse(b"grdm", &k_only, None, channels);
|
||||
let (first, last) = ends(&a);
|
||||
assert!(first.iter().zip(expected).all(|(x, y)| (x - y).abs() < 1e-5), "{first:?} vs {expected:?}");
|
||||
assert!(first[0] < 0.4 && (first[0] - first[2]).abs() < 0.05, "K-only black is a dark neutral: {first:?}");
|
||||
assert_eq!(last, [1.0; 3]);
|
||||
}
|
||||
// Lab: L 50, a 0, b 0 is a neutral mid grey; L 100 white.
|
||||
let lab = parse(b"grdm", &grdm_with((7, [5000, 0, 0, 0]), (7, [10000, 0, 0, 0])), None, Channels::Lab);
|
||||
let (first, last) = ends(&lab);
|
||||
assert!(first.iter().all(|v| (v - 0.466).abs() < 0.01), "{first:?}");
|
||||
assert!(last.iter().all(|v| *v > 0.99), "{last:?}");
|
||||
// Lab with chroma: a = +60 is reddish (a and b are signed hundredths).
|
||||
let red = parse(b"grdm", &grdm_with((7, [5000, 6000, 0, 0]), white_rgb), None, Channels::Lab);
|
||||
let (first, _) = ends(&red);
|
||||
assert!(first[0] > first[1] + 0.2, "{first:?}");
|
||||
// Grayscale: 0..10000 is ink coverage (10000 = black).
|
||||
let gray = parse(b"grdm", &grdm_with((8, [10000, 0, 0, 0]), (8, [0, 0, 0, 0])), None, Channels::Gray);
|
||||
assert_eq!(ends(&gray), ([0.0; 3], [1.0; 3]));
|
||||
// HSB: hue 240 degrees (2/3 of 65535), full saturation and brightness = blue.
|
||||
let hsb = parse(b"grdm", &grdm_with((1, [43690, 65535, 65535, 0]), white_rgb), None, Channels::Rgb);
|
||||
let (first, _) = ends(&hsb);
|
||||
assert!(first[0] < 1e-3 && first[1] < 1e-3 && (first[2] - 1.0).abs() < 1e-3, "{first:?}");
|
||||
// Book colours and unknown ids are not guessed: the block stays raw.
|
||||
for space in [3u16, 9, 0xFFFF] {
|
||||
let d = grdm_with((space, [1, 2, 3, 4]), white_rgb);
|
||||
assert!(matches!(parse(b"grdm", &d, None, Channels::Rgb), Adjustment::Unsupported { .. }), "space {space}");
|
||||
}
|
||||
// Truncated stops never panic.
|
||||
for n in 0..k_only.len() {
|
||||
let _ = parse(b"grdm", &k_only[..n], None, Channels::Cmyk);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_map_reverse_and_dither_keep_the_stops_block() {
|
||||
let original = grdm_with((2, [65535, 65535, 65535, 0]), (7, [10000, 0, 0, 0]));
|
||||
let Adjustment::GradientMap { stops, .. } = parse(b"grdm", &original, None, Channels::Cmyk) else { panic!() };
|
||||
let mut data = original.clone();
|
||||
let target = Adjustment::GradientMap { stops: stops.clone(), reverse: true, dither: true };
|
||||
assert!(retarget_gradient_map(&mut data, &target, Channels::Cmyk));
|
||||
assert_eq!(&data[2..4], &[1, 1]);
|
||||
assert_eq!(data[4..], original[4..], "the stops keep their colour spaces");
|
||||
assert_eq!(parse(b"grdm", &data, None, Channels::Cmyk), target);
|
||||
// Edited stops are not patched in.
|
||||
let mut edited = stops;
|
||||
edited[0].1 = [0.5; 3];
|
||||
let mut data2 = original.clone();
|
||||
let target = Adjustment::GradientMap { stops: edited, reverse: false, dither: false };
|
||||
assert!(!retarget_gradient_map(&mut data2, &target, Channels::Cmyk));
|
||||
assert_eq!(data2, original);
|
||||
assert!(!retarget_gradient_map(&mut [], &target, Channels::Cmyk));
|
||||
assert!(!retarget_gradient_map(&mut data2, &Adjustment::Invert, Channels::Cmyk));
|
||||
}
|
||||
|
||||
fn rt(a: Adjustment) {
|
||||
let blocks = write(&a);
|
||||
assert!(!blocks.is_empty(), "{a:?}");
|
||||
|
||||
@@ -151,6 +151,7 @@ impl Builder {
|
||||
bg.prune();
|
||||
let mut l = Layer::new("Background", LayerContent::Raster(bg));
|
||||
l.locks.transparency = true;
|
||||
l.locks.position = true;
|
||||
layers.insert(0, l);
|
||||
}
|
||||
self.doc.layers = layers;
|
||||
|
||||
@@ -15,6 +15,7 @@ pub(crate) const DISPLAY_INFO_OLD: u16 = 1007;
|
||||
pub(crate) const QUICK_MASK_INFO: u16 = 1022;
|
||||
|
||||
const SPACE_RGB: u16 = 0;
|
||||
const SPACE_HSB: u16 = 1;
|
||||
const SPACE_CMYK: u16 = 2;
|
||||
const SPACE_LAB: u16 = 7;
|
||||
const SPACE_GRAY: u16 = 8;
|
||||
@@ -66,9 +67,25 @@ pub(crate) fn apply_display_info(data: &[u8], versioned: bool, skip: usize, chan
|
||||
}
|
||||
}
|
||||
|
||||
/// A PSD colour structure (space id + four u16 components) as an RGB [`Color`]; unknown spaces
|
||||
/// (book colours…) read the first three components as RGB. See [`decode_known_color`].
|
||||
pub(crate) fn decode_color(space: u16, c: [u16; 4]) -> Color {
|
||||
let n = |v: u16| f32::from(v) / 65535.0;
|
||||
match space {
|
||||
decode_known_color(space, c).unwrap_or_else(|| Color::rgb(n(c[0]), n(c[1]), n(c[2])))
|
||||
}
|
||||
|
||||
/// A PSD colour structure in one of the spaces Photoshop writes for plain colours (0 RGB,
|
||||
/// 1 HSB, 2 CMYK, 7 Lab, 8 Grayscale) as an RGB [`Color`], or `None` for any other space id.
|
||||
/// CMYK goes through the colour-managed conversion (the active document CMYK profile, see
|
||||
/// `photocraft_color::convert::with_cmyk_space`), Lab through the D50 Lab → sRGB conversion.
|
||||
pub(crate) fn decode_known_color(space: u16, c: [u16; 4]) -> Option<Color> {
|
||||
let n = |v: u16| f32::from(v) / 65535.0;
|
||||
Some(match space {
|
||||
SPACE_RGB => Color::rgb(n(c[0]), n(c[1]), n(c[2])),
|
||||
SPACE_HSB => {
|
||||
let rgb = hsb_to_rgb(n(c[0]) * 360.0, n(c[1]), n(c[2]));
|
||||
Color::rgb(rgb[0], rgb[1], rgb[2])
|
||||
}
|
||||
// Photoshop colour structures store CMYK inverted (65535 = no ink).
|
||||
SPACE_CMYK => {
|
||||
let cmyk = Color { mode: ColorMode::Cmyk, c: [1.0 - n(c[0]), 1.0 - n(c[1]), 1.0 - n(c[2]), 1.0 - n(c[3])], alpha: 1.0 };
|
||||
@@ -87,7 +104,23 @@ pub(crate) fn decode_color(space: u16, c: [u16; 4]) -> Color {
|
||||
let v = 1.0 - f32::from(c[0].min(10000)) / 10000.0;
|
||||
Color::rgb(v, v, v)
|
||||
}
|
||||
_ => Color::rgb(n(c[0]), n(c[1]), n(c[2])),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// HSB (hue in degrees, saturation and brightness 0..1) → RGB.
|
||||
fn hsb_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] {
|
||||
let h = if h.is_finite() { h.rem_euclid(360.0) / 60.0 } else { 0.0 };
|
||||
let (s, v) = (s.clamp(0.0, 1.0), v.clamp(0.0, 1.0));
|
||||
let f = h - h.floor();
|
||||
let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f)));
|
||||
match h.floor() as u8 {
|
||||
0 => [v, t, p],
|
||||
1 => [q, v, p],
|
||||
2 => [p, v, t],
|
||||
3 => [p, q, v],
|
||||
4 => [t, p, v],
|
||||
_ => [v, p, q],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,5 +166,13 @@ mod tests {
|
||||
assert_eq!(g.to_rgb(), [0.0; 3]);
|
||||
let lab = decode_color(SPACE_LAB, [10000, 0, 0, 0]).to_rgb();
|
||||
assert!(lab.iter().all(|v| *v > 0.95), "{lab:?}");
|
||||
// HSB: hue 120 degrees (65535 / 3), full saturation and brightness is pure green.
|
||||
let g = decode_color(SPACE_HSB, [21845, 65535, 65535, 0]).to_rgb();
|
||||
assert!((g[0]).abs() < 1e-3 && (g[1] - 1.0).abs() < 1e-3 && g[2].abs() < 1e-3, "{g:?}");
|
||||
let half = decode_color(SPACE_HSB, [0, 0, 32768, 0]).to_rgb();
|
||||
assert!(half.iter().all(|v| (v - 0.5).abs() < 1e-3), "{half:?}");
|
||||
// Unknown spaces (book colours) are not guessed.
|
||||
assert_eq!(decode_known_color(3, [1, 2, 3, 4]), None);
|
||||
assert_eq!(decode_known_color(SPACE_RGB, [65535, 0, 0, 0]), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
}
|
||||
}
|
||||
@@ -376,6 +376,9 @@ mod tests {
|
||||
});
|
||||
d.last_applied_comp = Some(7);
|
||||
let res = write_comps_resource(&d).unwrap();
|
||||
// `list` is a length-4 string id, as Photoshop writes it, never a code.
|
||||
assert!(res.windows(8).any(|w| w == b"\0\0\0\x04list"));
|
||||
assert!(!res.windows(8).any(|w| w == b"\0\0\0\0list"));
|
||||
let cm = write_cmls(&d.layer_comps, None, d.layer(lid).unwrap(), 3).unwrap();
|
||||
// Attach as Photoshop would: shmd with cmls plus another item.
|
||||
let mut raw = vec![(*b"shmd", write_shmd(&[MetadataItem::new(*b"cust", vec![1, 2])]))];
|
||||
|
||||
@@ -21,6 +21,7 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
|
||||
}
|
||||
let img = codecs::decode(bytes)?;
|
||||
let mut r = image_to_document(name, &img)?;
|
||||
r.document.file_encoding = file_encoding(bytes);
|
||||
if codecs::detect(bytes) == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba {
|
||||
tga_alpha_to_channel(&mut r.document)?;
|
||||
}
|
||||
@@ -50,6 +51,45 @@ pub fn import_gif_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// The lossy settings `bytes` were encoded with, for File › Save to keep: a JPEG's quality
|
||||
/// (estimated from its quantisation tables) and a WebP's lossless or lossy encoding with its
|
||||
/// quality. A lossy WebP whose quality can't be read gets [`DEFAULT_LOSSY_WEBP_QUALITY`].
|
||||
pub fn file_encoding(bytes: &[u8]) -> photocraft_doc::FileEncoding {
|
||||
let mut e = photocraft_doc::FileEncoding::default();
|
||||
match codecs::detect(bytes) {
|
||||
Some(Format::Jpeg) => e.jpeg_quality = codecs::jpeg_quality(bytes),
|
||||
Some(Format::WebP) => match codecs::webp_encoding(bytes) {
|
||||
Some(codecs::WebpEncoding::Lossless) => e.webp_lossless = Some(true),
|
||||
Some(codecs::WebpEncoding::Lossy { quality }) => {
|
||||
e.webp_lossless = Some(false);
|
||||
e.webp_quality = Some(quality.unwrap_or(DEFAULT_LOSSY_WEBP_QUALITY));
|
||||
}
|
||||
None => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// The quality a lossy WebP is saved at when its own can't be read.
|
||||
pub const DEFAULT_LOSSY_WEBP_QUALITY: u8 = 75;
|
||||
|
||||
/// Applies the settings `doc`'s file was encoded with (see [`file_encoding`]) to `opts`: what a
|
||||
/// save that chose no quality writes, so a JPEG keeps its quality and a WebP stays lossy or
|
||||
/// lossless.
|
||||
pub fn apply_file_encoding(doc: &Document, opts: &mut codecs::EncodeOptions) {
|
||||
let e = &doc.file_encoding;
|
||||
if let Some(q) = e.jpeg_quality {
|
||||
opts.jpeg_quality = q.clamp(1, 100);
|
||||
}
|
||||
if let Some(lossless) = e.webp_lossless {
|
||||
opts.webp_lossless = lossless;
|
||||
}
|
||||
if let Some(q) = e.webp_quality {
|
||||
opts.webp_quality = q.min(100);
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" channel holding the
|
||||
/// fourth byte, even when the header declares no alpha bits, and keeps the stored colours (#2225;
|
||||
/// measured on Photoshop 27.11). Layer transparency would hide those colours and lose the channel
|
||||
|
||||
@@ -64,7 +64,10 @@ use photocraft_doc::Document;
|
||||
use photocraft_psd::{PsdError, PsdFile};
|
||||
|
||||
pub use adjust_map::ADJUSTMENT_KEYS;
|
||||
pub use flat::{document_to_image, import_gif_frames, import_tiff_page, tga_alpha_channel_to_transparency, tga_default_bits};
|
||||
pub use flat::{
|
||||
DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, tga_alpha_channel_to_transparency,
|
||||
tga_default_bits,
|
||||
};
|
||||
pub use psd_export::{PsdExportOptions, document_to_psd, document_to_psd_with};
|
||||
pub use psd_import::{psd_to_document, psd_to_document_with};
|
||||
|
||||
|
||||
@@ -529,6 +529,12 @@ impl Ex {
|
||||
let legacy_phfl = k == b"phfl" && d.starts_with(&[0, 2]) && d.len() % 4 != 0;
|
||||
!legacy_phfl && adjust_map::ADJUSTMENT_KEYS.contains(&k) && adjust_map::parse(k, d, cged.as_deref(), channels) == *a
|
||||
});
|
||||
// A Gradient Map whose stops are unchanged keeps the file's own block (stops in
|
||||
// the document's CMYK/Lab/Gray space, smoothness, method) with Reverse and Dither
|
||||
// updated in place.
|
||||
let keep = keep
|
||||
|| (matches!(a, photocraft_doc::Adjustment::GradientMap { .. })
|
||||
&& raw.iter_mut().any(|(k, d)| k == b"grdm" && adjust_map::retarget_gradient_map(d, a, channels)));
|
||||
if !keep {
|
||||
raw.retain(|(k, _)| !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd");
|
||||
let w = adjust_map::write(a);
|
||||
|
||||
@@ -702,7 +702,20 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
|
||||
// Capped like `build` (#2940): a group past the document's nesting cap keeps no children,
|
||||
// so no file can make this tree, or anything walking or dropping it, recurse deeper.
|
||||
let tree = file.layer_tree_max_depth(photocraft_doc::MAX_GROUP_DEPTH);
|
||||
doc.layers = cx.build(&tree, 0);
|
||||
// Colours stored in the document's CMYK space (Gradient Map stops…) convert through
|
||||
// its own profile, as the compositor and the PSD writer read them.
|
||||
let space = photocraft_compose::cmyk_space(&doc);
|
||||
doc.layers = photocraft_color::convert::with_cmyk_space(space.as_ref(), || cx.build(&tree, 0));
|
||||
// Photoshop's Background: the bottom record, a transparency-protected pixel layer stored
|
||||
// without a transparency channel (-1). It is also position-locked, whatever its
|
||||
// (localized) name and whether or not the file carries an `lspf` block.
|
||||
if let (Some(rec), Some(bottom)) = (file.layers().first(), doc.layers.first_mut())
|
||||
&& rec.flags.transparency_protected()
|
||||
&& !rec.channels.iter().any(|c| c.id == -1)
|
||||
&& matches!(bottom.content, LayerContent::Raster(_))
|
||||
{
|
||||
bottom.locks.position = true;
|
||||
}
|
||||
// Layer › Link Layers: resource 1026 holds one group id per layer record (0 = unlinked).
|
||||
if let Some(Ok(photocraft_psd::resources::ResourceData::LayerGroupInfo(groups))) =
|
||||
file.resources.iter().find(|r| r.id == ids::LAYER_GROUP_INFO).and_then(photocraft_psd::resources::ImageResource::parsed)
|
||||
|
||||
@@ -817,6 +817,28 @@ pub fn uuid_from(seed: &[u8]) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn count(hay: &[u8], needle: &[u8]) -> usize {
|
||||
hay.windows(needle.len()).filter(|w| *w == needle).count()
|
||||
}
|
||||
|
||||
/// A freshly built placed layer writes `warp` (key and class) as a length-4 string, as
|
||||
/// Photoshop does; the code form is a different key to Photoshop.
|
||||
#[test]
|
||||
fn fresh_placed_warp_is_a_string_id() {
|
||||
let spec =
|
||||
PlacedSpec { idnt: "id", placed: "id", transform: Affine::IDENTITY, perspective: None, size: (10.0, 10.0), dpi: 72.0, warp: None, filter_fx: None };
|
||||
let mut warnings = Vec::new();
|
||||
let sold = sold_bytes(None, &spec, &mut warnings);
|
||||
let plld = plld_bytes(&spec, &mut warnings);
|
||||
// `SoLd`: the `warp` key and the warp descriptor's class; `PlLd`: the descriptor's class.
|
||||
for (bytes, n) in [(&sold, 2), (&plld, 1)] {
|
||||
assert_eq!(count(bytes, b"\0\0\0\x04warp"), n);
|
||||
assert_eq!(count(bytes, b"\0\0\0\0warp"), 0);
|
||||
}
|
||||
let d = sold_descriptor(&sold).unwrap();
|
||||
assert!(matches!(d.get("warp"), Some(Value::Descriptor(w)) if w.class_id == Id::Str(b"warp".to_vec())));
|
||||
}
|
||||
|
||||
fn sf(command: &str, params: J) -> SmartFilter {
|
||||
SmartFilter { command: command.into(), params, blend: BlendMode::Normal, opacity: 1.0, visible: true }
|
||||
}
|
||||
|
||||
@@ -179,3 +179,103 @@ fn rgb_fill_colours_separate_through_the_document_cmyk_profile() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A two-stop version 1 `grdm` block whose stops are the PSD colour structures `a` and `b`.
|
||||
fn grdm_block(a: (u16, [u16; 4]), b: (u16, [u16; 4])) -> Vec<u8> {
|
||||
let mut v = vec![0, 1, 0, 0];
|
||||
v.extend_from_slice(&1u32.to_be_bytes()); // name: just the terminator
|
||||
v.extend_from_slice(&[0, 0]);
|
||||
v.extend_from_slice(&2u16.to_be_bytes());
|
||||
for (t, (space, c)) in [(0u32, a), (4096, b)] {
|
||||
v.extend_from_slice(&t.to_be_bytes());
|
||||
v.extend_from_slice(&50u32.to_be_bytes());
|
||||
v.extend_from_slice(&space.to_be_bytes());
|
||||
for x in c {
|
||||
v.extend_from_slice(&x.to_be_bytes());
|
||||
}
|
||||
v.extend_from_slice(&[0, 0]);
|
||||
}
|
||||
v.extend_from_slice(&2u16.to_be_bytes());
|
||||
for t in [0u32, 4096] {
|
||||
v.extend_from_slice(&t.to_be_bytes());
|
||||
v.extend_from_slice(&50u32.to_be_bytes());
|
||||
v.extend_from_slice(&255u16.to_be_bytes());
|
||||
}
|
||||
v.extend_from_slice(&2u16.to_be_bytes()); // expansion count
|
||||
v.extend_from_slice(&0u16.to_be_bytes()); // smoothness
|
||||
v
|
||||
}
|
||||
|
||||
fn saved_grdm(bytes: &[u8]) -> Vec<u8> {
|
||||
let f = photocraft_psd::PsdFile::from_bytes(bytes).unwrap();
|
||||
f.layers().iter().find_map(|l| l.block(b"grdm")).map(|b| b.data.clone()).expect("grdm block")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_maps_with_cmyk_lab_gray_stops_import_and_keep_their_space() {
|
||||
use photocraft_cms::{Intent, Transform, synth};
|
||||
use photocraft_doc::{Adjustment, Document, Layer, LayerContent};
|
||||
let profile = synth::cmyk_profile(&synth::CmykParams { tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 5, grid_b2a: 9, ..Default::default() });
|
||||
let profile = Profile::parse(&profile.to_bytes()).unwrap();
|
||||
let to_srgb = Transform::new(&profile, Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).unwrap();
|
||||
// First stop 60 % cyan + 40 % black (stored inverted), second paper white.
|
||||
let cmyk_stop = (2u16, [65535 - 39321, 65535, 65535, 65535 - 26214]);
|
||||
let paper = (2u16, [65535; 4]);
|
||||
let lab_dark = (7u16, [2000, 0, 0, 0]);
|
||||
let lab_red = (7u16, [5000, 6000u16, 2000, 0]);
|
||||
let gray_black = (8u16, [10000, 0, 0, 0]);
|
||||
let gray_white = (8u16, [0, 0, 0, 0]);
|
||||
for depth in SampleType::ALL {
|
||||
for (mode, stops) in [(ColorMode::Cmyk, (cmyk_stop, paper)), (ColorMode::Lab, (lab_dark, lab_red)), (ColorMode::Grayscale, (gray_white, gray_black))] {
|
||||
let block = grdm_block(stops.0, stops.1);
|
||||
let mut doc = Document::new("map", photocraft_geom::Size::new(4, 2), mode, depth);
|
||||
if mode == ColorMode::Cmyk {
|
||||
doc.icc_profile = Some(profile.to_bytes());
|
||||
}
|
||||
let fmt = doc.pixel_format();
|
||||
doc.layers.push(raster("Background", fmt, doc.bounds(), 3, false));
|
||||
doc.layers.push(Layer::new("Map", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "grdm".into(), raw: block.clone() })));
|
||||
let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes;
|
||||
let mut back = import("x.psd", &bytes).unwrap().document;
|
||||
let LayerContent::Adjustment(Adjustment::GradientMap { stops: got, .. }) = &back.layers[1].content else {
|
||||
panic!("{mode:?} {depth:?}: {:?}", back.layers[1].content);
|
||||
};
|
||||
let (first, last) = (got[0].1, got[got.len() - 1].1);
|
||||
match mode {
|
||||
ColorMode::Cmyk => {
|
||||
// Through the document's own profile, not the built-in coated CMYK.
|
||||
let mut expected = [0.0f32; 3];
|
||||
to_srgb.eval_fast(&[0.6, 0.0, 0.0, 0.4], &mut expected);
|
||||
assert!(first.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-4), "{depth:?}: {first:?} vs {expected:?}");
|
||||
let builtin = photocraft_color::convert::cmyk_to_rgb([0.6, 0.0, 0.0, 0.4]);
|
||||
assert!(first.iter().zip(builtin).any(|(a, b)| (a - b).abs() > 1e-3), "the document profile is used");
|
||||
}
|
||||
ColorMode::Lab => {
|
||||
assert!(first.iter().all(|v| (v - first[0]).abs() < 1e-3) && first[0] < 0.3, "{first:?}");
|
||||
assert!(last[0] > last[1] + 0.2, "a = +60 is reddish: {last:?}");
|
||||
}
|
||||
// An inverting map, white to black.
|
||||
_ => assert_eq!((first, last), ([1.0; 3], [0.0; 3])),
|
||||
}
|
||||
// The layer now renders: the composite differs from the background alone.
|
||||
let with_map = photocraft_compose::flatten(&back).px;
|
||||
let mut hidden = back.clone();
|
||||
hidden.layers[1].visible = false;
|
||||
assert!(max_diff(&with_map, &photocraft_compose::flatten(&hidden).px) > 0.01, "{mode:?} {depth:?}");
|
||||
// Saving again keeps the block (and so the stops' colour space) byte for byte...
|
||||
let again = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes;
|
||||
assert_eq!(saved_grdm(&again), block, "{mode:?} {depth:?}");
|
||||
// ...and so does toggling Reverse and Dither, which only flip their flags.
|
||||
if let LayerContent::Adjustment(Adjustment::GradientMap { reverse, dither, .. }) = &mut back.layers[1].content {
|
||||
*reverse = true;
|
||||
*dither = true;
|
||||
}
|
||||
let flipped_file = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes;
|
||||
let flipped = saved_grdm(&flipped_file);
|
||||
assert_eq!(&flipped[2..4], &[1, 1]);
|
||||
assert_eq!(flipped[4..], block[4..], "{mode:?} {depth:?}: stops keep their colour space");
|
||||
let reopened = import("x.psd", &flipped_file).unwrap().document;
|
||||
assert_eq!(reopened.layers[1].content, back.layers[1].content);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! A PSD's Background is recognised by its structure (the bottom record, transparency-protected,
|
||||
//! stored without a transparency channel), whatever its name in the Photoshop language that
|
||||
//! saved the file.
|
||||
|
||||
use photocraft_io::psd_to_document;
|
||||
use photocraft_psd::{ColorMode, Compression, PsdFile, TaggedBlock, Version, testgen};
|
||||
|
||||
/// `testgen::layered` with its bottom layer turned into a Photoshop Background named `name`.
|
||||
fn file(name: &str, transparency_channel: bool) -> PsdFile {
|
||||
let mut f = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Rle);
|
||||
let bottom = f.layers_mut().first_mut().unwrap();
|
||||
bottom.name = name.bytes().filter(u8::is_ascii).collect();
|
||||
bottom.blocks.retain(|b| &b.key != b"luni" && &b.key != b"lspf");
|
||||
bottom.blocks.push(TaggedBlock::unicode_name(name));
|
||||
bottom.flags.set_transparency_protected(true);
|
||||
if !transparency_channel {
|
||||
bottom.channels.retain(|c| c.id != -1);
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_localized_background_is_the_background() {
|
||||
for name in ["Hintergrund", "背景", "Arrière-plan"] {
|
||||
let (d, _) = psd_to_document(&file(name, false));
|
||||
let bg = d.background().unwrap_or_else(|| panic!("{name}: no Background"));
|
||||
assert_eq!(bg.name, name);
|
||||
assert!(bg.locks.transparency && bg.locks.position, "{name}");
|
||||
assert_eq!(bg.id, d.layers[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_protected_layer_with_transparency_is_not_the_background() {
|
||||
// Lock Transparent Pixels on an ordinary bottom layer: it keeps its transparency channel.
|
||||
let (d, _) = psd_to_document(&file("Background", true));
|
||||
assert!(d.layers[0].locks.transparency);
|
||||
assert!(d.background().is_none());
|
||||
}
|
||||
@@ -857,6 +857,91 @@ fn tail_preview_shows_the_stroke_as_finishing_it_would() {
|
||||
assert!(r.tail_preview().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_union_of_passes_matches_merging_the_finished_passes_after_every_chunk() {
|
||||
// Symmetric painting previews the stroke and its mirrors with `composite_union_live`, which
|
||||
// redraws only changed tiles; after every chunk the target must be exactly what finishing
|
||||
// every pass and merging them with `composite_union_many` gives (the commit takes these pixels).
|
||||
let base = BrushSettings { size: 14.0, hardness: 0.6, opacity: 0.6, ..brush() };
|
||||
let mut brushes = vec![base.clone()];
|
||||
brushes.push(BrushSettings { smoothing: Smoothing { amount: 0.7, ..Default::default() }, ..base.clone() });
|
||||
let mut b = base.clone();
|
||||
b.dual_brush.enabled = true;
|
||||
b.dual_brush.size = 6.0;
|
||||
b.dual_brush.scatter = 1.5;
|
||||
brushes.push(b);
|
||||
let mut b = base.clone();
|
||||
b.color_dynamics.enabled = true;
|
||||
b.color_dynamics.hue_jitter = 0.5;
|
||||
brushes.push(b);
|
||||
brushes.push(BrushSettings { wet_edges: true, ..base.clone() });
|
||||
brushes.push(BrushSettings { mode: BlendMode::Dissolve, ..base.clone() });
|
||||
brushes.push(BrushSettings { erase: true, ..base.clone() });
|
||||
let mut b = base.clone();
|
||||
b.texture.enabled = true;
|
||||
b.texture.each_tip = false;
|
||||
brushes.push(b);
|
||||
let stroke: Vec<StrokePoint> = (0..40).map(|i| StrokePoint::new(30.0 + i as f64 * 3.7, 40.0 + 25.0 * (i as f64 / 5.0).sin(), 1.0)).collect();
|
||||
// Mirrors about x = 100 and y = 75; the first point sits near the vertical axis so passes overlap.
|
||||
let mirror = |fx: bool, fy: bool| -> Vec<StrokePoint> {
|
||||
stroke.iter().map(|p| StrokePoint::new(if fx { 200.0 - p.x } else { p.x }, if fy { 150.0 - p.y } else { p.y }, p.pressure)).collect()
|
||||
};
|
||||
let paths = [stroke.clone(), mirror(true, false), mirror(false, true), mirror(true, true)];
|
||||
let area = Rect::new(-20, -20, 240, 190);
|
||||
let mut sel = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false));
|
||||
let sel_rect = Rect::new(0, 0, 160, 120);
|
||||
sel.write_region(sel_rect, &(0..160 * 120).map(|i| ((i % 160) as f32 / 120.0).min(1.0)).collect::<Vec<_>>());
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, sample, true);
|
||||
let mut pre = Surface::new(fmt);
|
||||
let fill = Rect::new(10, 10, 190, 140);
|
||||
let n = fmt.channels();
|
||||
let px: Vec<f32> =
|
||||
(0..fill.width() * fill.height()).flat_map(|i| [(i % 7) as f32 / 7.0, 0.5, (i % 13) as f32 / 13.0, 0.8].into_iter().take(n)).collect();
|
||||
pre.write_region(fill, &px);
|
||||
for (bi, b) in brushes.iter().enumerate() {
|
||||
// (passes included at each chunk, selection): two passes from the start; four, of
|
||||
// which two join part-way.
|
||||
for (four, selection) in [(false, None), (true, None), (true, Some(&sel))] {
|
||||
let mut renderers: Vec<StrokeRenderer> = (0..4).map(|_| StrokeRenderer::new(b, Some(fmt), 1.0)).collect();
|
||||
let mut live = crate::render::UnionLive::default();
|
||||
let mut target = pre.clone();
|
||||
let (mut at, mut chunk, mut merged) = (0, 1, 0);
|
||||
while at < stroke.len() {
|
||||
let end = (at + chunk).min(stroke.len());
|
||||
for (r, path) in renderers.iter_mut().zip(&paths) {
|
||||
r.push(&path[at..end]);
|
||||
}
|
||||
let count = if four && at >= 12 { 4 } else { 2 };
|
||||
let full = merged != count;
|
||||
merged = count;
|
||||
let mut passes: Vec<&mut StrokeRenderer> = renderers.iter_mut().take(count).collect();
|
||||
StrokeRenderer::composite_union_live(&mut passes, &mut live, full, &pre, &mut target, selection, false);
|
||||
let finished: Vec<StrokeRenderer> = renderers
|
||||
.iter()
|
||||
.take(count)
|
||||
.map(|r| {
|
||||
let mut r = r.clone();
|
||||
r.finish();
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
let mut expected = pre.clone();
|
||||
finished[0].composite_union_many(&finished[1..], &pre, &mut expected, selection, false);
|
||||
assert!(
|
||||
target.read_region(area) == expected.read_region(area),
|
||||
"{sample:?}, brush {bi}, four {four}, selection {}, points ..{end}",
|
||||
selection.is_some()
|
||||
);
|
||||
assert_eq!(live.bounds(), finished.iter().fold(Rect::EMPTY, |r, f| r.union(&f.bounds())));
|
||||
at = end;
|
||||
chunk = chunk % 3 + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Tilt Scale, Brush Projection, speed spacing, locks, mixer transfer ----------
|
||||
|
||||
/// Coverage extent (pixels above half coverage) of a rasterised dab along x and y through its centre.
|
||||
|
||||
@@ -37,7 +37,7 @@ pub use brush::{
|
||||
SectionLocks, ShapeDynamics, Smoothing, Texture, TipShape, Transfer,
|
||||
};
|
||||
pub use mixer::MixerSettings;
|
||||
pub use render::{BrushContext, StrokeRenderer, grid_center, grid_square, render_stroke};
|
||||
pub use render::{BrushContext, StrokeRenderer, UnionLive, grid_center, grid_square, render_stroke};
|
||||
pub use tile::{GrayTile, StoredTile};
|
||||
|
||||
/// One input sample. Missing stylus data defaults to "mouse": full pressure, no tilt/rotation.
|
||||
|
||||
@@ -491,6 +491,13 @@ impl CoverageMap {
|
||||
v
|
||||
}
|
||||
|
||||
/// The finished coverage over the tiles `keys`: `tail`'s copy of a tile where finishing the
|
||||
/// stroke changes it (see [`StrokeRenderer::tail_preview`]), else this map's own.
|
||||
fn finished_subset(&self, tail: Option<&Self>, keys: &HashSet<(i32, i32)>) -> Self {
|
||||
let tile = |k: &(i32, i32)| tail.and_then(|t| t.tiles.get(k)).or_else(|| self.tiles.get(k)).map(|t| (*k, t.clone()));
|
||||
Self { tiles: keys.iter().filter_map(tile).collect(), nc: self.nc, bounds: tail.map_or(self.bounds, |t| t.bounds), dirty: HashSet::new() }
|
||||
}
|
||||
|
||||
/// Union two passes of the same brush stroke without applying opacity twice where they
|
||||
/// overlap. The stronger coverage wins, including its per-dab colour when present.
|
||||
fn union_max(&mut self, other: &Self) {
|
||||
@@ -527,6 +534,21 @@ impl CoverageMap {
|
||||
// Stroke renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// What [`StrokeRenderer::composite_union_live`] drew last: the coverage tiles of the passes'
|
||||
/// smoothing tails (redrawn without them next time) and the merged bounds.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct UnionLive {
|
||||
tail_keys: HashSet<(i32, i32)>,
|
||||
bounds: Rect,
|
||||
}
|
||||
|
||||
impl UnionLive {
|
||||
/// The merged bounds of the finished passes, as last drawn.
|
||||
pub fn bounds(&self) -> Rect {
|
||||
self.bounds
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental stroke rendering: feed points, composite dirty tiles from the pre-stroke pixels.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StrokeRenderer {
|
||||
@@ -570,6 +592,105 @@ impl StrokeRenderer {
|
||||
}
|
||||
merged.composite(pre, target, selection, lock_transparency, true)
|
||||
}
|
||||
|
||||
/// Coverage tiles (stroke and Dual Brush) changed since the last call or composite, into
|
||||
/// `keys`; forgets them.
|
||||
fn drain_dirty_into(&mut self, keys: &mut HashSet<(i32, i32)>) {
|
||||
keys.extend(self.cov.dirty.drain());
|
||||
if let Some(d) = self.dual.as_mut() {
|
||||
keys.extend(d.dirty.drain());
|
||||
}
|
||||
}
|
||||
|
||||
/// Every coverage tile (stroke and Dual Brush), into `keys`.
|
||||
fn keys_into(&self, keys: &mut HashSet<(i32, i32)>) {
|
||||
keys.extend(self.cov.tiles.keys());
|
||||
if let Some(d) = &self.dual {
|
||||
keys.extend(d.tiles.keys());
|
||||
}
|
||||
}
|
||||
|
||||
/// Live preview of a stroke painted as several passes (the stroke, then its symmetry
|
||||
/// mirrors): leaves `target` as finishing every pass now and compositing them with
|
||||
/// [`composite_union_many`](Self::composite_union_many) over `pre` would, but redraws only the
|
||||
/// coverage tiles that changed since the previous call (new dabs, and the smoothing tail now
|
||||
/// and before). `full` redraws every tile: on the first call, or when a pass joins. Returns
|
||||
/// the damaged rectangle.
|
||||
pub fn composite_union_live(
|
||||
passes: &mut [&mut Self],
|
||||
live: &mut UnionLive,
|
||||
full: bool,
|
||||
pre: &Surface,
|
||||
target: &mut Surface,
|
||||
selection: Option<&Surface>,
|
||||
lock_transparency: bool,
|
||||
) -> Rect {
|
||||
let mut keys = std::mem::take(&mut live.tail_keys);
|
||||
for p in passes.iter_mut() {
|
||||
p.drain_dirty_into(&mut keys);
|
||||
if full {
|
||||
p.keys_into(&mut keys);
|
||||
}
|
||||
}
|
||||
let tails: Vec<_> = passes.iter().map(|p| p.tail_preview()).collect();
|
||||
let mut tail_keys = HashSet::new();
|
||||
for t in tails.iter().flatten() {
|
||||
t.keys_into(&mut tail_keys);
|
||||
}
|
||||
keys.extend(&tail_keys);
|
||||
let mut merged: Option<Self> = None;
|
||||
for (p, t) in passes.iter().zip(&tails) {
|
||||
let cov = p.cov.finished_subset(t.as_ref().map(|t| &t.cov), &keys);
|
||||
let dual = p.dual.as_ref().map(|d| d.finished_subset(t.as_ref().and_then(|t| t.dual.as_ref()), &keys));
|
||||
match merged.as_mut() {
|
||||
None => {
|
||||
merged = Some(Self {
|
||||
ctx: p.ctx.clone(),
|
||||
generator: p.generator.clone(),
|
||||
cov,
|
||||
dual,
|
||||
fmt: p.fmt,
|
||||
per_dab_color: p.per_dab_color,
|
||||
dabs_done: p.dabs_done,
|
||||
scratch: Vec::new(),
|
||||
depth_scratch: Vec::new(),
|
||||
dab_buf: Vec::new(),
|
||||
dual_buf: Vec::new(),
|
||||
all_dabs: None,
|
||||
})
|
||||
}
|
||||
Some(m) => {
|
||||
m.cov.union_max(&cov);
|
||||
if let (Some(to), Some(from)) = (&mut m.dual, &dual) {
|
||||
to.union_max(from);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(mut merged) = merged else { return Rect::EMPTY };
|
||||
let (before, after) = (live.bounds, merged.bounds());
|
||||
// Back to the pre-stroke pixels where the previous call drew and nothing covers now.
|
||||
let mut keys: Vec<_> = keys.into_iter().collect();
|
||||
keys.sort_unstable();
|
||||
let mut dmg = Rect::EMPTY;
|
||||
let mut region = Vec::new();
|
||||
for &(tx, ty) in &keys {
|
||||
let tile = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE);
|
||||
let old = tile.intersect(&before);
|
||||
if !old.is_empty() {
|
||||
pre.read_region_into(old, &mut region);
|
||||
target.write_region(old, ®ion);
|
||||
dmg = dmg.union(&old);
|
||||
}
|
||||
if merged.cov.tiles.contains_key(&(tx, ty)) {
|
||||
dmg = dmg.union(&tile.intersect(&after));
|
||||
}
|
||||
}
|
||||
merged.composite(pre, target, selection, lock_transparency, true);
|
||||
live.tail_keys = tail_keys;
|
||||
live.bounds = after;
|
||||
dmg
|
||||
}
|
||||
/// `fmt` = target pixel format (needed for per-dab colour); `zoom` for smoothing.
|
||||
pub fn new(brush: &BrushSettings, fmt: Option<PixelFormat>, zoom: f32) -> Self {
|
||||
let per_dab_color = brush.color_dynamics.enabled && !brush.erase && fmt.is_some();
|
||||
|
||||
@@ -44,6 +44,10 @@ impl UnicodeString {
|
||||
}
|
||||
}
|
||||
|
||||
/// Four-character class and key ids Photoshop writes as length-4 strings, never as codes
|
||||
/// (`00000004 'warp'`, not `00000000 'warp'`).
|
||||
pub const STRING_IDS: [&str; 3] = ["warp", "list", "time"];
|
||||
|
||||
/// A class / key / type identifier: a length-prefixed string, where a stored
|
||||
/// length of 0 means a 4-character code follows.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
@@ -57,10 +61,15 @@ pub enum Id {
|
||||
|
||||
impl Id {
|
||||
/// Convenience constructor: 4-byte strings become [`Id::Code`], others
|
||||
/// [`Id::Str`].
|
||||
/// [`Id::Str`]. The four-character names Photoshop stores as length-4 strings
|
||||
/// ([`STRING_IDS`]: `warp`, `list`, `time`) are [`Id::Str`] too: Photoshop treats the
|
||||
/// string and the code as different keys and doesn't recognise the code form.
|
||||
pub fn new(s: &str) -> Self {
|
||||
let b = s.as_bytes();
|
||||
if b.len() == 4 { Id::Code([b[0], b[1], b[2], b[3]]) } else { Id::Str(b.to_vec()) }
|
||||
match b {
|
||||
[a, b, c, d] if !STRING_IDS.contains(&s) => Id::Code([*a, *b, *c, *d]),
|
||||
_ => Id::Str(b.to_vec()),
|
||||
}
|
||||
}
|
||||
/// The identifier's bytes.
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
@@ -609,6 +618,34 @@ mod tests {
|
||||
assert_eq!(p.to_bytes(), b);
|
||||
}
|
||||
|
||||
/// `warp`, `list` and `time` are written as length-4 strings, as Photoshop writes them, both
|
||||
/// as keys and as class ids; other four-character names stay codes. Reading accepts both
|
||||
/// forms, and a code read from a file is written back as a code.
|
||||
#[test]
|
||||
fn photoshop_string_ids_are_written_as_strings() {
|
||||
let str_id = |s: &str| [&[0, 0, 0, 4][..], s.as_bytes()].concat();
|
||||
let code_id = |s: &str| [&[0, 0, 0, 0][..], s.as_bytes()].concat();
|
||||
for name in STRING_IDS {
|
||||
assert_eq!(Id::new(name), Id::Str(name.as_bytes().to_vec()));
|
||||
let d = Descriptor::new(name).with(name, Value::Integer(1));
|
||||
let b = d.to_bytes();
|
||||
// Name (empty unicode string), then the class id; then the item count and the key.
|
||||
assert_eq!(&b[4..12], &str_id(name)[..], "class {name}");
|
||||
assert_eq!(&b[16..24], &str_id(name)[..], "key {name}");
|
||||
let back = Descriptor::from_bytes(&b).unwrap();
|
||||
assert_eq!(back, d);
|
||||
assert!(back.class_id.is(name) && back.get(name).is_some());
|
||||
// A file that wrote the code form keeps it, and lookups still find the key.
|
||||
let mut coded = b.clone();
|
||||
coded.splice(16..24, code_id(name));
|
||||
let back = Descriptor::from_bytes(&coded).unwrap();
|
||||
assert_eq!(back.get(name), Some(&Value::Integer(1)));
|
||||
assert_eq!(back.to_bytes(), coded);
|
||||
}
|
||||
assert_eq!(Id::new("view"), Id::Code(*b"view"));
|
||||
assert_eq!(Id::new("Txt "), Id::Code(*b"Txt "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioned_roundtrip() {
|
||||
let v = VersionedDescriptor::new(sample());
|
||||
|
||||
+157
-30
@@ -1762,6 +1762,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
|
||||
let mut close = None;
|
||||
let mut tab_action = None;
|
||||
let mut tab_move = None;
|
||||
let mut tear_off = None;
|
||||
let (mut focus_open, mut cancel_open) = (None, None);
|
||||
let focused_open = app.jobs.focus.is_some();
|
||||
// Layers dragged over a tab show its document (`layer_transfer`).
|
||||
@@ -1851,18 +1852,10 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
|
||||
} else if resp.clicked() {
|
||||
activate = Some(i);
|
||||
}
|
||||
// Drag a tab sideways to reorder it (#217); a line shows where it will land.
|
||||
if resp.dragged()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
tab_drop_line(ui, &placed, p.x, &t);
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
let x = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos())).map_or(r.center().x, |p| p.x);
|
||||
let to = tab_move_target(i, tab_drop_index(&placed, x));
|
||||
if to != i {
|
||||
tab_move = Some((i, to));
|
||||
}
|
||||
match tab_drag(ui, &resp, i, r, row, &placed, &t) {
|
||||
Some(TabDrop::Move(to)) => tab_move = Some((i, to)),
|
||||
Some(TabDrop::Float(at)) => tear_off = Some((i, at)),
|
||||
None => {}
|
||||
}
|
||||
let has_path = st.path.is_some();
|
||||
resp.context_menu(|ui| {
|
||||
@@ -1925,6 +1918,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
float_torn_off(app, ui.ctx(), tear_off);
|
||||
if let Some((id, params)) = tab_action
|
||||
&& let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params)
|
||||
{
|
||||
@@ -1968,6 +1962,70 @@ fn tab_context_menu(ui: &mut egui::Ui, index: usize, count: usize, has_path: boo
|
||||
|
||||
/// Where a tab dragged to `x` would land: before the first shown tab whose middle is right of
|
||||
/// `x`, else after the last shown tab (its document index + 1), like [`TabStrip::slot`].
|
||||
/// How far (points) above or below the tab strip a dragged tab must be to tear off (#3733).
|
||||
const TEAR_OFF: f32 = 24.0;
|
||||
|
||||
/// Whether a tab dragged to `p` has left the strip and floats into its own window, as in
|
||||
/// Photoshop; sideways drags along the strip still reorder (#217).
|
||||
fn torn_off(strip: Rect, p: egui::Pos2) -> bool {
|
||||
p.y > strip.bottom() + TEAR_OFF || p.y < strip.top() - TEAR_OFF
|
||||
}
|
||||
|
||||
/// What a finished document-tab drag asks for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum TabDrop {
|
||||
/// Reorder the tab to this index.
|
||||
Move(usize),
|
||||
/// Float the document in its own OS window at this screen position (points), when known.
|
||||
Float(Option<[f32; 2]>),
|
||||
}
|
||||
|
||||
/// Drag a document tab: along the strip it reorders (a line shows where it will land); off the
|
||||
/// strip it tears off into a floating window that can go anywhere, other screens included
|
||||
/// (an outline under the pointer shows it). Returns the drop once the drag ends.
|
||||
fn tab_drag(ui: &egui::Ui, resp: &egui::Response, i: usize, r: Rect, strip: Rect, placed: &[(usize, Rect)], t: &crate::theme::Tokens) -> Option<TabDrop> {
|
||||
if resp.dragged()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
if torn_off(strip, p) {
|
||||
let ghost = Rect::from_min_size(p - egui::vec2(40.0, 12.0), egui::vec2(240.0, 160.0));
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, ui.id().with("tab-tear-off")));
|
||||
painter.rect_filled(ghost, t.radius_sm, t.accent.gamma_multiply(0.12));
|
||||
painter.rect_stroke(ghost, t.radius_sm, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
|
||||
} else {
|
||||
tab_drop_line(ui, placed, p.x, t);
|
||||
}
|
||||
}
|
||||
if !resp.drag_stopped() {
|
||||
return None;
|
||||
}
|
||||
let p = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos()));
|
||||
if let Some(p) = p.filter(|&p| torn_off(strip, p)) {
|
||||
// The pointer is in this window's points; the OS wants screen coordinates. Keep the new
|
||||
// window's title bar under the pointer, as the ghost showed.
|
||||
let at = ui.ctx().input(|i| i.viewport().inner_rect).map(|inner| {
|
||||
let s = inner.min + p.to_vec2() - egui::vec2(40.0, 12.0);
|
||||
[s.x, s.y]
|
||||
});
|
||||
return Some(TabDrop::Float(at));
|
||||
}
|
||||
let to = tab_move_target(i, tab_drop_index(placed, p.map_or(r.center().x, |p| p.x)));
|
||||
(to != i).then_some(TabDrop::Move(to))
|
||||
}
|
||||
|
||||
/// Float a torn-off tab's document in its own window (`window.arrange.floatInWindow`).
|
||||
fn float_torn_off(app: &mut PhotocraftApp, ctx: &egui::Context, tear_off: Option<(usize, Option<[f32; 2]>)>) {
|
||||
let Some((doc, at)) = tear_off else { return };
|
||||
let params = match at {
|
||||
Some(at) => json!({"document": doc, "position": at}),
|
||||
None => json!({"document": doc}),
|
||||
};
|
||||
if let Err(e) = crate::menus::invoke(app, ctx, "window.arrange.floatInWindow", params) {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn tab_drop_index(placed: &[(usize, Rect)], x: f32) -> usize {
|
||||
match placed.iter().find(|(_, r)| r.center().x >= x) {
|
||||
Some(&(i, _)) => i,
|
||||
@@ -2027,7 +2085,7 @@ const STUDIO_TAB_GAP: f32 = 4.0;
|
||||
fn pro_tab_title(app: &PhotocraftApp, i: usize) -> String {
|
||||
let Some(st) = app.session.documents().get(i) else { return String::new() };
|
||||
let zoom = app.ui.views.get(i).map_or(100.0, |v| v.zoom * 100.0);
|
||||
let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !(l.name == "Background" && l.locks.transparency));
|
||||
let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !st.doc.is_background(l.id));
|
||||
let mask = app.session.active_index() == Some(i) && app.ui.mask_target && active_layer.is_some_and(|l| l.mask.is_some());
|
||||
let model = if mask { tl!("Layer Mask").to_string() } else { mode_label(&st.doc).to_string() };
|
||||
let layer = active_layer.map(|l| format!("{}, ", l.name)).unwrap_or_default();
|
||||
@@ -2051,6 +2109,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
|
||||
let (mut activate, mut close) = (None, None);
|
||||
let mut tab_action = None;
|
||||
let mut tab_move = None;
|
||||
let mut tear_off = None;
|
||||
let tab_count = app.session.documents().len();
|
||||
let dragging = crate::layer_transfer::pointer_if_armed(app, ui.ctx());
|
||||
let mut drag_over = None;
|
||||
@@ -2111,18 +2170,10 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
|
||||
} else if resp.clicked() {
|
||||
activate = Some(i);
|
||||
}
|
||||
// Drag a tab sideways to reorder it (#217); a line shows where it will land.
|
||||
if resp.dragged()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
tab_drop_line(ui, &placed, p.x, &t);
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
let x = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos())).map_or(r.center().x, |p| p.x);
|
||||
let to = tab_move_target(i, tab_drop_index(&placed, x));
|
||||
if to != i {
|
||||
tab_move = Some((i, to));
|
||||
}
|
||||
match tab_drag(ui, &resp, i, r, strip, &placed, &t) {
|
||||
Some(TabDrop::Move(to)) => tab_move = Some((i, to)),
|
||||
Some(TabDrop::Float(at)) => tear_off = Some((i, at)),
|
||||
None => {}
|
||||
}
|
||||
let has_path = app.session.documents().get(i).is_some_and(|d| d.path.is_some());
|
||||
resp.context_menu(|ui| {
|
||||
@@ -2179,6 +2230,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
float_torn_off(app, ui.ctx(), tear_off);
|
||||
if let Some((id, params)) = tab_action
|
||||
&& let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params)
|
||||
{
|
||||
@@ -3517,6 +3569,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
crate::stroke_constraint::draw_line_preview(app, &painter, &xf, p, tool, held.shift);
|
||||
}
|
||||
}
|
||||
if primary && hand_pan && response.dragged() {
|
||||
crate::transform_tool::draw_overlay(app, &painter, &xf);
|
||||
}
|
||||
app.tool_override = None;
|
||||
if let Some(b) = layer_edges_rect(app, idx) {
|
||||
painter.set(layer_edges, egui::Shape::rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, LAYER_EDGES), egui::StrokeKind::Outside));
|
||||
@@ -3679,9 +3734,21 @@ fn marching_ants_segments(painter: &egui::Painter, xf: &ViewXform, segs: &[crate
|
||||
pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outline::Segment], time: f64, to_screen: impl Fn([i32; 2]) -> Pos2) {
|
||||
let dash = 4.0f32;
|
||||
let phase = ((time * 10.0) % (dash as f64 * 2.0)) as f32;
|
||||
let white = Stroke::new(1.0, Color32::WHITE);
|
||||
let black = Stroke::new(1.0, Color32::BLACK);
|
||||
let clip = painter.clip_rect();
|
||||
// Horizontal and vertical segments go in one mesh of 1 px quads, which egui draws as it is: a
|
||||
// line shape per dash took egui ~35 ms a frame to tessellate for a speckled selection seen
|
||||
// whole (about 10⁵ segments), ten frames a second while the selection is shown. A slanted
|
||||
// segment (a transformed outline) stays a line shape, which egui antialiases.
|
||||
let mut mesh = egui::Mesh::default();
|
||||
// A 1 px wide quad from `a` to `b` (`side` is half a pixel across the line).
|
||||
let mut quad = |a: egui::Pos2, b: egui::Pos2, side: egui::Vec2, color: Color32| {
|
||||
let i = mesh.vertices.len() as u32;
|
||||
for p in [a + side, a - side, b - side, b + side] {
|
||||
mesh.colored_vertex(p, color);
|
||||
}
|
||||
mesh.add_triangle(i, i + 1, i + 2);
|
||||
mesh.add_triangle(i, i + 2, i + 3);
|
||||
};
|
||||
for (a, b) in segs {
|
||||
let pa = to_screen(*a);
|
||||
let pb = to_screen(*b);
|
||||
@@ -3690,12 +3757,19 @@ pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outli
|
||||
}
|
||||
let pa = pos2(pa.x.round() + 0.5, pa.y.round() + 0.5);
|
||||
let pb = pos2(pb.x.round() + 0.5, pb.y.round() + 0.5);
|
||||
painter.line_segment([pa, pb], white);
|
||||
let len = pa.distance(pb);
|
||||
if len < 0.5 {
|
||||
continue;
|
||||
}
|
||||
let dir = (pb - pa) / len;
|
||||
let side = dir.rot90() * 0.5;
|
||||
let straight = pa.x == pb.x || pa.y == pb.y;
|
||||
if straight {
|
||||
// The white base runs half a pixel past both ends, so joined segments meet at corners.
|
||||
quad(pa - dir * 0.5, pb + dir * 0.5, side, Color32::WHITE);
|
||||
} else {
|
||||
painter.line_segment([pa, pb], Stroke::new(1.0, Color32::WHITE));
|
||||
}
|
||||
// Phase by screen position so dashes line up across joined segments.
|
||||
let start = (pa.x + pa.y + phase).rem_euclid(dash * 2.0);
|
||||
let mut t = -start;
|
||||
@@ -3703,11 +3777,18 @@ pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outli
|
||||
let s0 = t.max(0.0);
|
||||
let s1 = (t + dash).min(len);
|
||||
if s1 > s0 {
|
||||
painter.line_segment([pa + dir * s0, pa + dir * s1], black);
|
||||
if straight {
|
||||
quad(pa + dir * s0, pa + dir * s1, side, Color32::BLACK);
|
||||
} else {
|
||||
painter.line_segment([pa + dir * s0, pa + dir * s1], Stroke::new(1.0, Color32::BLACK));
|
||||
}
|
||||
}
|
||||
t += dash * 2.0;
|
||||
}
|
||||
}
|
||||
if !mesh.is_empty() {
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -5047,7 +5128,10 @@ pub fn extra_windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let Some(st) = app.session.documents().get(w.document) else { continue };
|
||||
let title = format!("{} — window {}", st.doc.name, w.id);
|
||||
let vid = egui::ViewportId::from_hash_of(("docwin", w.id));
|
||||
let builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([800.0, 600.0]);
|
||||
let mut builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([800.0, 600.0]);
|
||||
if let Some([x, y]) = w.position {
|
||||
builder = builder.with_position([x, y]);
|
||||
}
|
||||
let mut view = w.view.clone();
|
||||
let mut close = false;
|
||||
ctx.show_viewport_immediate(vid, builder, |ui, _class| {
|
||||
@@ -5704,6 +5788,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dragging a tab off the strip floats its document in its own OS window instead of
|
||||
/// reordering (#3733), in both strips; a short wobble below the strip still reorders.
|
||||
#[test]
|
||||
fn dragging_a_tab_off_the_strip_floats_it_in_a_window() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let strip = Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(700.0, 26.0));
|
||||
assert!(!torn_off(strip, egui::pos2(50.0, 13.0)));
|
||||
assert!(!torn_off(strip, egui::pos2(50.0, 26.0 + TEAR_OFF - 1.0)), "a wobble below the strip still reorders");
|
||||
assert!(torn_off(strip, egui::pos2(50.0, 26.0 + TEAR_OFF + 1.0)));
|
||||
assert!(torn_off(strip, egui::pos2(50.0, -TEAR_OFF - 1.0)), "dragged up, off the window's top");
|
||||
for kind in [crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::Pro] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
for name in ["a", "b", "c"] {
|
||||
app.run("file.new", json!({"width": 8, "height": 8, "name": name})).unwrap();
|
||||
}
|
||||
app.sync_views();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(700.0, 300.0)).build_ui_state(
|
||||
|ui, (app, ready): &mut (PhotocraftApp, bool)| {
|
||||
if *ready {
|
||||
tabs(app, ui);
|
||||
}
|
||||
},
|
||||
(app, false),
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, kind);
|
||||
h.state_mut().1 = true;
|
||||
h.run_steps(2);
|
||||
let b = h.get_by_label_contains("b").rect();
|
||||
let target = egui::pos2(b.center().x, b.bottom() + 150.0);
|
||||
h.drag_at(b.center());
|
||||
h.run_steps(1);
|
||||
h.hover_at(target);
|
||||
h.run_steps(1);
|
||||
h.drop_at(target);
|
||||
h.run_steps(2);
|
||||
let app = &h.state().0;
|
||||
let names: Vec<&str> = app.session.documents().iter().map(|d| d.doc.name.as_str()).collect();
|
||||
assert_eq!(names, ["a", "b", "c"], "{kind:?}: tearing off doesn't reorder");
|
||||
assert_eq!(app.ui.windows.iter().map(|w| w.document).collect::<Vec<_>>(), [1], "{kind:?}: b floats in its own window");
|
||||
assert_eq!(app.session.active_index(), Some(1), "{kind:?}: the floated document is active");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_overlay_is_circular_and_respects_visibility() {
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
//! Rulers & Grids toggles. Every edit dispatches an engine or view command, so the panel stays thin.
|
||||
|
||||
use egui::{Align2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use photocraft_doc::{ColorMode, Document, Layer, LayerContent, LayerId, SampleType};
|
||||
use photocraft_doc::{ColorMode, Document, Layer, LayerId, SampleType};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{PhotocraftApp, icons, widgets};
|
||||
|
||||
/// Photoshop's Background layer: the locked, opaque raster layer at the bottom of the stack.
|
||||
/// Photoshop's Background layer: the locked, opaque raster layer at the bottom of the stack,
|
||||
/// whatever its (localized) name ([`Document::background`]).
|
||||
pub fn is_background(doc: &Document, l: &Layer) -> bool {
|
||||
l.name == "Background" && l.locks.transparency && matches!(l.content, LayerContent::Raster(_)) && doc.layers.first().is_some_and(|b| b.id == l.id)
|
||||
doc.is_background(l.id)
|
||||
}
|
||||
|
||||
/// Does the Properties panel show the Document view? (No layer selected, or the Background layer.)
|
||||
|
||||
@@ -616,7 +616,8 @@ fn views_and_windows_stay_with_their_documents() {
|
||||
for (i, v) in app.ui.views.iter_mut().enumerate() {
|
||||
v.zoom = i as f32 + 1.0;
|
||||
}
|
||||
app.ui.windows = (0..4).map(|document| crate::state::DocWindow { id: document as u64, document, view: Default::default(), open: true }).collect();
|
||||
app.ui.windows =
|
||||
(0..4).map(|document| crate::state::DocWindow { id: document as u64, document, view: Default::default(), open: true, position: None }).collect();
|
||||
let zooms = |app: &PhotocraftApp| app.ui.views.iter().map(|v| v.zoom).collect::<Vec<_>>();
|
||||
let windows = |app: &PhotocraftApp| app.ui.windows.iter().map(|w| (w.id, w.document)).collect::<Vec<_>>();
|
||||
assert_eq!(app.run("document.move", json!({"document": 0, "to": 3})).unwrap(), json!({"document": 3}));
|
||||
|
||||
@@ -1093,12 +1093,12 @@ mod tests {
|
||||
assert!(!has_dialog("filter.stylize.findEdges"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_covers_generated_filter_options_and_gallery_names() {
|
||||
let ko = crate::i18n::lang_from_tag("ko-KR").unwrap();
|
||||
/// Generated filter and adjustment labels (param names, symbolic choices) and gallery
|
||||
/// names that `lang` has no catalog row for.
|
||||
fn missing_generated_filter_labels(lang: crate::i18n::Lang) -> std::collections::BTreeSet<String> {
|
||||
let mut missing = std::collections::BTreeSet::new();
|
||||
let mut check = |s: String| {
|
||||
if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(ko, &s) {
|
||||
if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(lang, &s) {
|
||||
missing.insert(s);
|
||||
}
|
||||
};
|
||||
@@ -1125,9 +1125,21 @@ mod tests {
|
||||
for cat in photocraft_algo::GALLERY_CATEGORIES {
|
||||
check(cat.to_string());
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_covers_generated_filter_options_and_gallery_names() {
|
||||
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ko-KR").unwrap());
|
||||
assert!(missing.is_empty(), "missing Korean dynamic labels: {missing:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italian_covers_generated_filter_options_and_gallery_names() {
|
||||
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("it-IT").unwrap());
|
||||
assert!(missing.is_empty(), "missing Italian dynamic labels: {missing:#?}");
|
||||
}
|
||||
|
||||
/// The Select menu's generated dialogs (Select and Mask, Modify ▸ …) show their
|
||||
/// params, choices and title in every UI language, not just Korean.
|
||||
#[test]
|
||||
|
||||
+1359
-67
File diff suppressed because it is too large.
Load diff
@@ -1284,10 +1284,19 @@ impl PhotocraftApp {
|
||||
|
||||
/// What a save writes by default under the preferences: File Handling › Image Previews
|
||||
/// decides whether a PSD/PSB gets its thumbnail. Ask When Saving has no Save As checkbox
|
||||
/// here yet, so it saves the preview, as Photoshop's checkbox starts on.
|
||||
/// here yet, so it saves the preview, as Photoshop's checkbox starts on. A JPEG or WebP
|
||||
/// is written the way the active document's file was (its JPEG quality, a WebP lossy at
|
||||
/// its quality or lossless), as Photoshop's Save and Save As options start there.
|
||||
pub(crate) fn save_settings(&self) -> ExportSettings {
|
||||
let previews = self.session.prefs().file_handling.image_previews != photocraft_engine::prefs::Ask::Never;
|
||||
ExportSettings { image_previews: previews, ..ExportSettings::default() }
|
||||
let mut settings = ExportSettings { image_previews: previews, ..ExportSettings::default() };
|
||||
if let Some(st) = self.session.active() {
|
||||
let e = &st.doc.file_encoding;
|
||||
settings.jpeg_quality = e.jpeg_quality.map(|q| q.clamp(1, 100));
|
||||
settings.webp_lossless = e.webp_lossless.unwrap_or(settings.webp_lossless);
|
||||
settings.webp_quality = e.webp_quality.map(|q| q.min(100));
|
||||
}
|
||||
settings
|
||||
}
|
||||
|
||||
/// Whether saving the active document to `path` writes a copy. `tiff_layers` is the TIFF
|
||||
|
||||
@@ -343,7 +343,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
|
||||
let doc = app.session.active_index().ok_or("no document")?;
|
||||
let wid = app.ui.alloc_id();
|
||||
let view = app.ui.views[doc].clone();
|
||||
app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true });
|
||||
app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true, position: None });
|
||||
Ok(json!({"window": wid}))
|
||||
}
|
||||
"window.theme.toggle" => {
|
||||
|
||||
@@ -114,6 +114,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Do
|
||||
app.move_preview = None;
|
||||
return None;
|
||||
};
|
||||
let mask_target = app.layer_mask_targeted();
|
||||
let st = app.session.documents().get(idx)?;
|
||||
let (doc_id, revision, doc) = (st.doc.id, st.revision, st.doc.clone());
|
||||
let fresh = app.move_preview.as_ref().is_some_and(|p| p.doc == doc_id && p.revision == revision);
|
||||
@@ -134,7 +135,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Do
|
||||
}
|
||||
if p.offsets.last() != Some(&offset) {
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
match photocraft_engine::layer_multi_cmds::moved(&doc, &p.ids, offset.0, offset.1) {
|
||||
match photocraft_engine::layer_multi_cmds::moved_target(&doc, &p.ids, offset.0, offset.1, mask_target) {
|
||||
Ok(d) => {
|
||||
// Duotone documents display through their inks.
|
||||
let d = photocraft_engine::mode_cmds::display_document(&d).unwrap_or(d);
|
||||
@@ -298,6 +299,38 @@ mod tests {
|
||||
assert_eq!(r, Rect::new(0, 0, 64, 64));
|
||||
}
|
||||
|
||||
/// Dragging with an unlinked mask targeted moves the mask alone, shown live and committed;
|
||||
/// with the mask linked (or the pixels targeted) the layer moves.
|
||||
#[test]
|
||||
fn dragging_with_an_unlinked_mask_targeted_moves_the_mask_alone() {
|
||||
for (linked, mask_target) in [(false, true), (false, false), (true, true)] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", json!({"width": 64, "height": 64, "background": "transparent"})).unwrap();
|
||||
app.sync_views();
|
||||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||
app.session.execute("select.rect", json!({"x": 8, "y": 8, "width": 16, "height": 16})).unwrap();
|
||||
app.session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
app.session.execute("select.deselect", json!({})).unwrap();
|
||||
app.session.execute("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
app.session.execute("layer.layerMask.linked", json!({"linked": linked})).unwrap();
|
||||
app.ui.mask_target = mask_target;
|
||||
app.ui.tool = Tool::Move;
|
||||
app.ui.extras.snap = false;
|
||||
app.ui.view.show.smart_guides = false;
|
||||
let m = egui::Modifiers::NONE;
|
||||
crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 16.0, y: 16.0, pressure: 1.0 }, m);
|
||||
crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 36.0, y: 16.0, pressure: 1.0 }, m);
|
||||
let shown = super::display_doc(&mut app, 0).unwrap().0;
|
||||
let id = app.session.active().unwrap().active_layer.unwrap();
|
||||
let layer_moved = |d: &photocraft_doc::Document| d.layer(id).and_then(|l| l.surface()).is_some_and(|s| s.pixel(28, 16)[3] > 0.5);
|
||||
let alone = !linked && mask_target;
|
||||
assert_eq!(layer_moved(&shown), !alone, "preview, linked {linked} mask target {mask_target}");
|
||||
crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 36.0, y: 16.0 }, m);
|
||||
let d = &app.session.active().unwrap().doc;
|
||||
assert_eq!(layer_moved(d), !alone, "commit, linked {linked} mask target {mask_target}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Mid-drag on a painted layer: is the layer shown at the pointer?
|
||||
fn live(outline: bool) -> bool {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
@@ -2685,7 +2685,7 @@ fn layer_row(
|
||||
let name_color = if l.visible { t.text } else { t.text_faint };
|
||||
let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) };
|
||||
// Photoshop before 2026 set the Background layer's name in italics; 2026 sets it upright.
|
||||
let italic = !t.pro && l.name == "Background" && l.locks.transparency;
|
||||
let italic = !t.pro && doc.is_background(l.id);
|
||||
// Photoshop rows show only the name; the kind sub-label is a Studio-theme addition.
|
||||
let is_pixel = t.pro || matches!(l.content, LayerContent::Raster(_));
|
||||
let name_rect = crate::layer_row_ui::truncated(&painter, &l.name, font, name_color, italic, name_right - x).map(|galley| {
|
||||
|
||||
@@ -37,15 +37,71 @@ pub struct PresetUi {
|
||||
pub current_tool_only: bool,
|
||||
/// Custom Shape tool: the shape it draws.
|
||||
pub custom_shape: String,
|
||||
/// Folder/group order per panel, by name (#3658), like the dock's `tabOrder` (#2272). A panel
|
||||
/// with no entry keeps the library's order; unknown names are ignored and groups missing from
|
||||
/// a list follow it in library order. Saved with the UI state.
|
||||
#[serde(rename = "groupOrder", alias = "group_order", deserialize_with = "lenient_group_order", skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub group_order: BTreeMap<String, Vec<String>>,
|
||||
/// In-place rename: (panel, preset or "group:<name>", text).
|
||||
#[serde(skip)]
|
||||
pub renaming: Option<(String, String, String)>,
|
||||
}
|
||||
|
||||
/// Read `groupOrder` keeping what makes sense: non-object values, non-list entries and non-string
|
||||
/// names are dropped instead of failing the whole saved UI state.
|
||||
const MAX_GROUP_ORDER: usize = 10_000;
|
||||
fn lenient_group_order<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BTreeMap<String, Vec<String>>, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let mut out = BTreeMap::new();
|
||||
for (panel, names) in v.as_object().into_iter().flatten() {
|
||||
let Some(names) = names.as_array() else { continue };
|
||||
out.insert(panel.clone(), names.iter().filter_map(Value::as_str).take(MAX_GROUP_ORDER).map(str::to_owned).collect());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl PresetUi {
|
||||
pub fn shape(&self) -> &str {
|
||||
if self.custom_shape.is_empty() { tl!("Heart") } else { &self.custom_shape }
|
||||
}
|
||||
|
||||
/// Display order of the folders for `panel`, as library indices: the stored order first
|
||||
/// (names that no longer exist are dropped), then groups missing from it in library order.
|
||||
/// Mirrors the dock's per-group tab order (#2272, #3658).
|
||||
pub(crate) fn display_order(&self, panel: &str, names: &[String]) -> Vec<usize> {
|
||||
let mut out: Vec<usize> = Vec::with_capacity(names.len());
|
||||
for name in self.group_order.get(panel).into_iter().flatten() {
|
||||
if let Some(i) = names.iter().position(|n| n == name)
|
||||
&& !out.contains(&i)
|
||||
{
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
for i in 0..names.len() {
|
||||
if !out.contains(&i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Move folder `from` so it is drawn before `before` (`None` = last) for `panel`. The
|
||||
/// library's own order is stored as no entry, as the dock does for its tabs.
|
||||
pub(crate) fn move_group(&mut self, panel: &str, names: &[String], from: &str, before: Option<&str>) {
|
||||
if before == Some(from) {
|
||||
return;
|
||||
}
|
||||
let mut order: Vec<String> = self.display_order(panel, names).into_iter().filter_map(|i| names.get(i).cloned()).collect();
|
||||
let Some(at) = order.iter().position(|n| n == from) else { return };
|
||||
let moved = order.remove(at);
|
||||
let to = before.and_then(|b| order.iter().position(|n| n == b)).unwrap_or(order.len());
|
||||
order.insert(to, moved);
|
||||
if order.as_slice() == names {
|
||||
self.group_order.remove(panel);
|
||||
} else {
|
||||
self.group_order.insert(panel.to_string(), order);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PANELS: [&str; 6] =
|
||||
@@ -319,15 +375,21 @@ pub(crate) fn browser(
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut headers: Vec<(Rect, String)> = Vec::new();
|
||||
let mut headers: Vec<(usize, Rect, String)> = Vec::new();
|
||||
let mut dropped: Option<(String, Pos2)> = None;
|
||||
// Folder drag (#3658): the order the folders are drawn in, and what the pointer is doing this
|
||||
// frame (the insertion line and the drop are resolved once every header is known).
|
||||
let mut drag: Option<(usize, Pos2)> = None;
|
||||
let mut released: Option<(usize, Pos2)> = None;
|
||||
let group_names: Vec<String> = groups.iter().map(|g| g.name.clone()).collect();
|
||||
let display = st.display_order(panel, &group_names);
|
||||
egui::ScrollArea::vertical().id_salt(("preset-scroll", panel)).max_height(max_h).auto_shrink([false, true]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 2.0);
|
||||
for (gi, g) in groups.iter().enumerate() {
|
||||
for &gi in &display {
|
||||
let Some(g) = groups.get(gi) else { continue };
|
||||
let open = group_open(st, panel, gi, &g.name);
|
||||
let (hr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
if hresp.hovered() {
|
||||
let (hr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag());
|
||||
if hresp.hovered() || hresp.dragged() {
|
||||
ui.painter().rect_filled(hr, 2.0, t.hover.gamma_multiply(0.5));
|
||||
}
|
||||
let tri = Rect::from_center_size(pos2(hr.left() + 9.0, hr.center().y), vec2(12.0, 12.0));
|
||||
@@ -351,7 +413,18 @@ pub(crate) fn browser(
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
headers.push((hr, g.name.clone()));
|
||||
if hresp.dragged()
|
||||
&& let Some(p) = ui.ctx().pointer_interact_pos()
|
||||
{
|
||||
drag = Some((gi, p));
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
}
|
||||
if hresp.drag_stopped()
|
||||
&& let Some(p) = ui.ctx().pointer_interact_pos()
|
||||
{
|
||||
released = Some((gi, p));
|
||||
}
|
||||
headers.push((gi, hr, g.name.clone()));
|
||||
if !open {
|
||||
continue;
|
||||
}
|
||||
@@ -434,9 +507,32 @@ pub(crate) fn browser(
|
||||
});
|
||||
}
|
||||
}
|
||||
// Insertion line while a folder is dragged by its header.
|
||||
if let Some((from, p)) = drag {
|
||||
let before = group_drop_before(&headers, from, p.y);
|
||||
let anchor = match before {
|
||||
Some(i) => headers.iter().find(|(x, _, _)| *x == i).map(|(_, r, _)| *r),
|
||||
None => headers.last().map(|(_, r, _)| *r),
|
||||
};
|
||||
if let Some(r) = anchor {
|
||||
let y = if before.is_some() { r.top() - 1.0 } else { r.bottom() + 1.0 };
|
||||
ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent));
|
||||
}
|
||||
}
|
||||
});
|
||||
// Apply the drop now every header rect is known; the next frame draws the new order.
|
||||
if let Some((from, p)) = released {
|
||||
let before = group_drop_before(&headers, from, p.y);
|
||||
let from_name = groups.get(from).map(|g| g.name.clone());
|
||||
let before_name = before.and_then(|i| groups.get(i).map(|g| g.name.clone()));
|
||||
if let Some(from_name) = from_name {
|
||||
st.move_group(panel, &group_names, &from_name, before_name.as_deref());
|
||||
}
|
||||
}
|
||||
let recorded: Vec<(String, Rect)> = headers.iter().map(|(_, r, n)| (n.clone(), *r)).collect();
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(("preset-group-headers", panel)), recorded));
|
||||
if let Some((key, pos)) = dropped {
|
||||
if let Some((_, g)) = headers.iter().find(|(r, _)| r.contains(pos)) {
|
||||
if let Some((_, _, g)) = headers.iter().find(|(_, r, _)| r.contains(pos)) {
|
||||
ev.push(Ev::Move(key, g.clone()));
|
||||
} else if canvas.contains(pos) {
|
||||
ev.push(Ev::Drop(key, pos));
|
||||
@@ -468,6 +564,25 @@ pub(crate) fn browser(
|
||||
ev
|
||||
}
|
||||
|
||||
/// The folder (by library index) a header dragged to `y` lands before (`None` = last). `headers`
|
||||
/// is in display order; mirrors `dock::drop_before`.
|
||||
fn group_drop_before(headers: &[(usize, Rect, String)], from: usize, y: f32) -> Option<usize> {
|
||||
let hit = headers.iter().find(|(_, r, _)| y < r.center().y).map(|(i, _, _)| *i);
|
||||
match hit {
|
||||
Some(i) if i == from => Some(from),
|
||||
Some(i) => {
|
||||
let after_self = headers.iter().position(|(x, _, _)| *x == from).zip(headers.iter().position(|(x, _, _)| *x == i)).is_some_and(|(a, b)| b == a + 1);
|
||||
if after_self { Some(from) } else { Some(i) }
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Folder header rects drawn last frame (name → rect), in display order; for tests and automation.
|
||||
pub fn last_group_headers(ctx: &egui::Context, panel: &str) -> Vec<(String, Rect)> {
|
||||
ctx.data(|d| d.get_temp(egui::Id::new(("preset-group-headers", panel)))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Shared handling of the generic events through `<prefix>.edit` / `.new`.
|
||||
fn generic(app: &mut PhotocraftApp, panel: &str, prefix: &str, e: &Ev, new_params: Value) -> bool {
|
||||
let edit = format!("{prefix}.edit");
|
||||
@@ -1169,6 +1284,50 @@ mod tests {
|
||||
assert!(matches!(l.content, photocraft_doc::LayerContent::Shape(_)));
|
||||
}
|
||||
|
||||
/// #3658: a group/folder drag reorders by name, stores only a real reorder, and falls back to
|
||||
/// the library's order when the saved order matches it or names a group that's gone.
|
||||
#[test]
|
||||
fn moving_a_folder_reorders_like_the_dock_tabs() {
|
||||
let names: Vec<String> = ["Grays", "Pastel", "Pure", "Dark"].iter().map(|s| s.to_string()).collect();
|
||||
let mut st = PresetUi::default();
|
||||
assert_eq!(st.display_order("swatches", &names), vec![0, 1, 2, 3]);
|
||||
st.move_group("swatches", &names, "Pastel", None);
|
||||
assert_eq!(st.group_order.get("swatches").unwrap(), &["Grays", "Pure", "Dark", "Pastel"]);
|
||||
st.move_group("swatches", &names, "Pastel", Some("Grays"));
|
||||
assert_eq!(st.group_order.get("swatches").unwrap(), &["Pastel", "Grays", "Pure", "Dark"]);
|
||||
assert_eq!(st.display_order("swatches", &names), vec![1, 0, 2, 3]);
|
||||
// Back to the library's own order: no entry, as the dock stores its default tab order.
|
||||
st.move_group("swatches", &names, "Pastel", Some("Pure"));
|
||||
assert!(!st.group_order.contains_key("swatches"), "a no-op order is not stored");
|
||||
// Out-of-range names, moves onto themselves and unknown `before` names change nothing bad.
|
||||
for (from, before) in [("Nope", None), ("Grays", Some("Grays"))] {
|
||||
let mut m = PresetUi::default();
|
||||
m.move_group("swatches", &names, from, before);
|
||||
assert!(m.group_order.is_empty(), "{from} {before:?}");
|
||||
assert_eq!(m.display_order("swatches", &names), vec![0, 1, 2, 3]);
|
||||
}
|
||||
// A missing group in a saved order follows the rest in library order.
|
||||
let mut m = PresetUi::default();
|
||||
m.group_order.insert("swatches".into(), vec!["Dark".into(), "Grays".into()]);
|
||||
assert_eq!(m.display_order("swatches", &names), vec![3, 0, 1, 2]);
|
||||
}
|
||||
|
||||
/// #3658: the folder order persists with the UI state and reads leniently (an old state has no
|
||||
/// field, and garbage in one never fails the whole state).
|
||||
#[test]
|
||||
fn folder_order_persists_and_reads_leniently() {
|
||||
let old: PresetUi = serde_json::from_value(json!({"sizes": {"swatches": 22.0}})).unwrap();
|
||||
assert!(old.group_order.is_empty(), "an older state without the field still loads");
|
||||
let mut st = PresetUi::default();
|
||||
st.group_order.insert("swatches".into(), vec!["Dark".into(), "Grays".into()]);
|
||||
let back: PresetUi = serde_json::from_value(serde_json::to_value(&st).unwrap()).unwrap();
|
||||
assert_eq!(back.group_order.get("swatches").unwrap(), &["Dark", "Grays"]);
|
||||
for bad in [json!("x"), json!(7), json!({"swatches": "paths"}), json!({"swatches": [1, null, {"a": 1}]})] {
|
||||
let back: PresetUi = serde_json::from_value(json!({"groupOrder": bad})).unwrap();
|
||||
assert!(back.group_order.get("swatches").is_none_or(Vec::is_empty), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: preset drops mapped the pointer through the raw widget rect, ignoring the
|
||||
/// rulers' 16 px inset — a dropped shape landed 8 screen px from the pointer, and a drop on
|
||||
/// the ruler strip counted as a canvas drop. Drops now map through, and target, the same
|
||||
|
||||
@@ -268,6 +268,29 @@ fn ruler_pos(xf: &ViewXform, vertical: bool, u: f64, px_per_unit: f64) -> f32 {
|
||||
if vertical { rxf.to_screen(0.0, px).y } else { rxf.to_screen(px, 0.0).x }
|
||||
}
|
||||
|
||||
/// Document pixels per ruler unit and the labelled tick step (in ruler units) of the top ruler
|
||||
/// (`y_axis` false) or the left one, from Preferences › Units & Rulers and the zoom. Minor ticks
|
||||
/// sit at tenths of the step.
|
||||
fn ruler_scale(app: &PhotocraftApp, xf: &ViewXform, y_axis: bool) -> (f64, f64) {
|
||||
let ur = &app.session.prefs().units_and_rulers;
|
||||
let (unit, ppi) = (ur.rulers, ur.point_size.per_inch());
|
||||
let (dpi, size) =
|
||||
app.session.active().map_or((72.0, [1.0, 1.0]), |d| (d.doc.resolution_dpi.max(1.0) as f64, [d.doc.size.width as f64, d.doc.size.height as f64]));
|
||||
let px_per_unit = unit.to_px(1.0, dpi, if y_axis { size[1] } else { size[0] }, ppi).max(1e-9);
|
||||
// The top ruler runs along document x, which pixel aspect correction stretches.
|
||||
let zoom = if y_axis { xf.zoom } else { xf.zoom_x() };
|
||||
(px_per_unit, nice_step(60.0 / (zoom as f64 * px_per_unit).max(1e-6), unit == Unit::Pixels))
|
||||
}
|
||||
|
||||
/// `pos` (document px) moved to the nearest tick mark the ruler draws: where a guide lands when
|
||||
/// Shift is held while dragging it out of a ruler, as in Photoshop (#3770).
|
||||
fn snap_to_tick(app: &PhotocraftApp, xf: &ViewXform, vertical: bool, pos: f64) -> f64 {
|
||||
// A vertical guide sits at an x position, which the top ruler measures.
|
||||
let (px_per_unit, step) = ruler_scale(app, xf, !vertical);
|
||||
let tick = step / 10.0 * px_per_unit;
|
||||
if tick.is_finite() && tick > 0.0 { (pos / tick).round() * tick } else { pos }
|
||||
}
|
||||
|
||||
/// The number a ruler prints at the tick `v` (in ruler units). Like Photoshop, rulers show the
|
||||
/// distance from the origin, so values left of / above it read without a minus sign.
|
||||
fn ruler_label(v: f64, step: f64, whole: bool) -> String {
|
||||
@@ -292,17 +315,11 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: &
|
||||
let font = egui::FontId::proportional(9.0);
|
||||
let tick = Stroke::new(1.0, t.text_faint);
|
||||
// Preferences › Units & Rulers: labels in the ruler unit (percent of the document's side).
|
||||
let ur = &app.session.prefs().units_and_rulers;
|
||||
let (unit, ppi) = (ur.rulers, ur.point_size.per_inch());
|
||||
let (dpi, size) =
|
||||
app.session.active().map_or((72.0, [1.0, 1.0]), |d| (d.doc.resolution_dpi.max(1.0) as f64, [d.doc.size.width as f64, d.doc.size.height as f64]));
|
||||
let whole = unit == photocraft_engine::prefs::Unit::Pixels;
|
||||
for (vertical, extent) in [(false, size[0]), (true, size[1])] {
|
||||
let unit = app.session.prefs().units_and_rulers.rulers;
|
||||
let whole = unit == Unit::Pixels;
|
||||
for vertical in [false, true] {
|
||||
let p = p.with_clip_rect(if vertical { left } else { top });
|
||||
let px_per_unit = unit.to_px(1.0, dpi, extent, ppi).max(1e-9);
|
||||
// The top ruler runs along document x, which pixel aspect correction stretches.
|
||||
let zoom = if vertical { xf.zoom } else { xf.zoom_x() };
|
||||
let step = nice_step(60.0 / (zoom as f64 * px_per_unit).max(1e-6), whole);
|
||||
let (px_per_unit, step) = ruler_scale(app, xf, vertical);
|
||||
let (u0, u1) = if vertical {
|
||||
ruler_range(xf, left.left_top(), left.left_bottom(), 1, px_per_unit)
|
||||
} else {
|
||||
@@ -352,7 +369,8 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: &
|
||||
}
|
||||
if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.drag_started()) {
|
||||
let d = xf.to_doc(pos);
|
||||
let pos = crate::snap_ui::snap_guide(app, vertical, if vertical { d[0] } else { d[1] });
|
||||
let along = if vertical { d[0] } else { d[1] };
|
||||
let pos = if ui.input(|i| i.modifiers.shift) { snap_to_tick(app, xf, vertical, along) } else { crate::snap_ui::snap_guide(app, vertical, along) };
|
||||
app.guide_drag = Some(GuideDrag { vertical, index: None, pos });
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
@@ -661,4 +679,43 @@ mod tests {
|
||||
assert!(u1 - u0 > 0.0, "flip={flip}: range is empty");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3770: a guide Shift-dragged out of a ruler lands on the nearest tick the ruler draws
|
||||
/// (tenths of the labelled step, in the ruler unit); without Shift it lands where it's dropped.
|
||||
#[test]
|
||||
fn shift_dragged_guides_land_on_ruler_ticks() {
|
||||
let full = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0));
|
||||
let rect = Rect::from_min_max(full.min + vec2(RULER, RULER), full.max);
|
||||
let xf = ViewXform { rect, zoom: 1.0, center: [128.0, 64.0], flip: false, rotation: 0.0, aspect: 1.0 };
|
||||
let new_app = || {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 256, "height": 128})).unwrap();
|
||||
app
|
||||
};
|
||||
let drag_guide = |modifiers: egui::Modifiers| {
|
||||
let builder = egui_kittest::Harness::builder().with_size(vec2(400.0, 300.0));
|
||||
let mut h = builder.build_ui_state(move |ui, app| draw_rulers(app, ui, full, &xf), new_app());
|
||||
// Out of the left ruler: a vertical guide, dropped at document x = 37.
|
||||
let (from, to) = (pos2(RULER / 2.0, 100.0), xf.to_screen(37.0, 64.0));
|
||||
h.event_modifiers(egui::Event::PointerMoved(from), modifiers);
|
||||
h.event_modifiers(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }, modifiers);
|
||||
h.run_steps(1);
|
||||
h.event_modifiers(egui::Event::PointerMoved(to), modifiers);
|
||||
h.run_steps(1);
|
||||
h.event_modifiers(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }, modifiers);
|
||||
h.run_steps(2);
|
||||
h.state().session.active().unwrap().doc.guides.vertical.clone()
|
||||
};
|
||||
assert_eq!(drag_guide(egui::Modifiers::NONE), vec![37.0]);
|
||||
// Pixels at 100%: labels every 100 px, ticks every 10 px.
|
||||
assert_eq!(drag_guide(egui::Modifiers::SHIFT), vec![40.0]);
|
||||
|
||||
let mut app = new_app();
|
||||
// At 200% the labels are every 50 px, so the ticks every 5 px.
|
||||
assert_eq!(snap_to_tick(&app, &ViewXform { zoom: 2.0, ..xf }, true, 37.0), 35.0);
|
||||
// Percent of each side: ticks every 2.5% of 256 px across, every 5% of 128 px down.
|
||||
app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "percent"})).unwrap();
|
||||
assert!((snap_to_tick(&app, &xf, true, 37.0) - 38.4).abs() < 1e-9);
|
||||
assert!((snap_to_tick(&app, &xf, false, 37.0) - 38.4).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -204,3 +204,30 @@ fn image_previews_preference_reaches_the_exporter() {
|
||||
assert_eq!(*seen.borrow(), [expect; 3], "{value}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2399: Save and Save As start at the opened file's own encoding: a JPEG's estimated quality,
|
||||
/// a WebP lossy (at its quality) or lossless; a new document keeps the codec defaults.
|
||||
#[test]
|
||||
fn save_settings_start_at_the_opened_files_encoding() {
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType};
|
||||
let px: Vec<u8> = (0..32u32 * 24).flat_map(|i| [(i * 3) as u8, (i / 32 * 9) as u8, (i % 97) as u8]).collect();
|
||||
let img = Image::from_raw(32, 24, ChannelLayout::Rgb, SampleType::U8, px).unwrap();
|
||||
let mut app = app();
|
||||
assert_eq!(app.save_settings().jpeg_quality, None);
|
||||
assert!(app.save_settings().webp_lossless);
|
||||
|
||||
let jpeg = photocraft_codecs::encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 65, ..Default::default() }).unwrap();
|
||||
app.open_bytes("photo.jpg", &jpeg).unwrap();
|
||||
let q = app.save_settings().jpeg_quality.unwrap();
|
||||
assert!(q.abs_diff(65) <= 2, "estimated {q}");
|
||||
|
||||
let lossy = photocraft_codecs::encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: 80, ..Default::default() }).unwrap();
|
||||
app.open_bytes("photo.webp", &lossy).unwrap();
|
||||
let s = app.save_settings();
|
||||
assert!(!s.webp_lossless);
|
||||
assert!(s.webp_quality.unwrap().abs_diff(80) <= 2, "{:?}", s.webp_quality);
|
||||
|
||||
let lossless = photocraft_codecs::encode(&img, Format::WebP, &EncodeOptions::default()).unwrap();
|
||||
app.open_bytes("art.webp", &lossless).unwrap();
|
||||
assert!(app.save_settings().webp_lossless);
|
||||
}
|
||||
@@ -494,6 +494,10 @@ pub struct DocWindow {
|
||||
pub document: usize,
|
||||
pub view: View,
|
||||
pub open: bool,
|
||||
/// Where the window opens on screen (outer top-left, in points); `None` lets the OS place it.
|
||||
/// A tab dragged off the tab strip opens its window where it was dropped (#3733).
|
||||
#[serde(default)]
|
||||
pub position: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
/// Options-bar state for tools (Photoshop keeps these per tool).
|
||||
|
||||
@@ -286,6 +286,20 @@ mod tests {
|
||||
h.run_steps(1);
|
||||
}
|
||||
|
||||
/// Drag from `from` to `to` in steps, as when a user drags a group header.
|
||||
fn drag(h: &mut Harness<'_, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2) {
|
||||
h.event(egui::Event::PointerMoved(from));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
for i in 1..=6 {
|
||||
h.event(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 6.0)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// The first swatch: below the first group header (22 px), after the 8 px grid inset.
|
||||
fn first_cell(h: &Harness<'_, PhotocraftApp>) -> egui::Pos2 {
|
||||
h.ctx.input(|i| i.viewport_rect()).min + vec2(8.0 + 12.0 + 8.0, 22.0 + 2.0 + 12.0 + 8.0)
|
||||
@@ -397,4 +411,26 @@ mod tests {
|
||||
assert_eq!(out[0].0, "out/Swatches.ase");
|
||||
assert!(out[0].1.starts_with(b"ASEF"));
|
||||
}
|
||||
|
||||
/// #3658: dragging a group header reorders the folders, and the new order sticks in the UI
|
||||
/// state (UI state is serde, so it saves and reloads with the rest of the panel state).
|
||||
#[test]
|
||||
fn dragging_a_group_header_reorders_and_persists() {
|
||||
let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let mut h = Harness::builder().with_size(vec2(300.0, 4000.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel(app, ui), app);
|
||||
h.run_steps(2);
|
||||
let headers = crate::preset_panels::last_group_headers(&h.ctx, PANEL);
|
||||
assert!(headers.len() >= 2, "the default library has folders: {headers:?}");
|
||||
let names: Vec<String> = headers.iter().map(|(n, _)| n.clone()).collect();
|
||||
let (first, first_r) = &headers[0];
|
||||
let (last, last_r) = headers.last().unwrap();
|
||||
// Drag the last folder's header above the first; the insertion lands before it.
|
||||
drag(&mut h, last_r.center(), egui::pos2(first_r.center().x, first_r.top() + 1.0));
|
||||
let order = h.state().ui.presets_ui.display_order(PANEL, &names);
|
||||
assert_eq!(order.len(), names.len());
|
||||
assert_eq!(names.get(order[0]).map(String::as_str), Some(last.as_str()), "the dragged folder moved to the front: {order:?}");
|
||||
assert_eq!(names.get(order[1]).map(String::as_str), Some(first.as_str()));
|
||||
// The reorder is remembered by name (not by the positions it happened to have).
|
||||
assert_eq!(h.state().ui.presets_ui.group_order.get(PANEL).and_then(|o| o.first()), Some(last));
|
||||
}
|
||||
}
|
||||
@@ -279,6 +279,32 @@ fn holding_space_still_pans_while_transforming() {
|
||||
assert_ne!(h.state().ui.views[0].center, center0, "the view panned instead");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_preview_stays_visible_while_panning() {
|
||||
let mut h = harness();
|
||||
begin(&mut h);
|
||||
let texture = h.state().transform_preview.as_ref().unwrap().texture.pick(&h.ctx, 1.0, 1.0, false).0;
|
||||
let has_preview = |h: &Harness<'static, PhotocraftApp>| {
|
||||
h.output().shapes.iter().any(|clipped| matches!(&clipped.shape, egui::epaint::Shape::Mesh(mesh) if mesh.texture_id == texture))
|
||||
};
|
||||
assert!(has_preview(&h), "the transformed pixels are visible before panning");
|
||||
|
||||
let center0 = h.state().ui.views[0].center;
|
||||
let from = screen(&h, [200.0, 160.0]);
|
||||
let to = from + vec2(50.0, 40.0);
|
||||
h.event(egui::Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerMoved(from));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerMoved(to));
|
||||
h.run_steps(1);
|
||||
|
||||
assert_ne!(h.state().ui.views[0].center, center0, "the held Hand panned the view");
|
||||
assert!(has_preview(&h), "the transformed layer stays visible during the pan");
|
||||
}
|
||||
|
||||
/// A transform started by dragging a Move-tool control: that first drag is undoable too.
|
||||
#[test]
|
||||
fn the_first_drag_from_the_move_tools_controls_undoes() {
|
||||
|
||||
@@ -1783,6 +1783,102 @@ fn toggle_cell(ui: &mut egui::Ui, w: f32, on: bool, tip: &str, paint: impl FnOnc
|
||||
resp.on_hover_text(tl!(&tip)).clicked()
|
||||
}
|
||||
|
||||
/// The Character panel's OpenType buttons, in Photoshop's order: (feature tag, command id,
|
||||
/// tooltip). The tooltips are the Type › OpenType menu labels.
|
||||
pub(crate) const OPENTYPE_BUTTONS: [(&str, &str, &str); 8] = [
|
||||
("liga", "type.openType.standardLigatures", "Standard Ligatures"),
|
||||
("calt", "type.openType.contextualAlternates", "Contextual Alternates"),
|
||||
("dlig", "type.openType.discretionaryLigatures", "Discretionary Ligatures"),
|
||||
("swsh", "type.openType.swash", "Swash"),
|
||||
("salt", "type.openType.stylisticAlternates", "Stylistic Alternates"),
|
||||
("titl", "type.openType.titlingAlternates", "Titling Alternates"),
|
||||
("ordn", "type.openType.ordinals", "Ordinals"),
|
||||
("frac", "type.openType.fractions", "Fractions"),
|
||||
];
|
||||
|
||||
/// Photoshop's OpenType button row: each shows pressed while its feature is on at the type
|
||||
/// target (the selection start, else the layer) and toggles it through the Type › OpenType
|
||||
/// command, on the same scope as the other Character controls.
|
||||
fn opentype_row(app: &mut PhotocraftApp, ui: &mut egui::Ui, c: &photocraft_doc::text::CharStyle, full: f32) {
|
||||
let cw = cell_width(full, OPENTYPE_BUTTONS.len());
|
||||
let mut clicked = None;
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
for (tag, id, tip) in OPENTYPE_BUTTONS {
|
||||
let on = photocraft_engine::type_extra_cmds::feature_on(c, tag);
|
||||
if toggle_cell(ui, cw, on, tip, |ui, r, col| opentype_glyph(ui.painter(), r, tag, col)) {
|
||||
clicked = Some((id, on));
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some((id, on)) = clicked {
|
||||
toggle_opentype(app, id, on);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs an OpenType toggle command on the Character panel's scope: the edited layer's selected
|
||||
/// characters (or its whole text) while typing, else every selected type layer. The new state
|
||||
/// is explicit, so it follows the button the user saw rather than the layer's first run.
|
||||
pub(crate) fn toggle_opentype(app: &mut PhotocraftApp, id: &str, shown_on: bool) {
|
||||
let Some(mut p) = formatting_params(app) else { return };
|
||||
p["on"] = json!(!shown_on);
|
||||
let _ = app.run(id, p);
|
||||
}
|
||||
|
||||
/// Small glyph labels for the OpenType buttons (drawn here, no Adobe artwork): fi, st, ct with
|
||||
/// a ligature arc, a swash A, aa, T, 1st and ½.
|
||||
fn opentype_glyph(p: &egui::Painter, r: egui::Rect, tag: &str, col: Color32) {
|
||||
let font = egui::FontId::proportional(13.0);
|
||||
let centred = |s: &str, font: egui::FontId| {
|
||||
let g = p.layout_no_wrap(s.to_string(), font, col);
|
||||
let pos = r.center() - g.size() / 2.0;
|
||||
let gr = egui::Rect::from_min_size(pos, g.size());
|
||||
p.galley(pos, g, col);
|
||||
gr
|
||||
};
|
||||
match tag {
|
||||
"liga" => {
|
||||
centred("fi", font);
|
||||
}
|
||||
"calt" => {
|
||||
centred("st", font);
|
||||
}
|
||||
"dlig" => {
|
||||
let gr = centred("ct", font);
|
||||
// The ligature's connecting arc from the c over to the t.
|
||||
let a = egui::pos2(gr.left() + gr.width() * 0.3, gr.top() + gr.height() * 0.42);
|
||||
let b = egui::pos2(gr.right() - gr.width() * 0.2, gr.top() + 4.0);
|
||||
let mid = egui::pos2((a.x + b.x) / 2.0, gr.top() + 1.5);
|
||||
p.line_segment([a, mid], Stroke::new(1.0, col));
|
||||
p.line_segment([mid, b], Stroke::new(1.0, col));
|
||||
}
|
||||
"swsh" => {
|
||||
let gr = centred("A", crate::theme::semibold(13.0));
|
||||
// A swash tail sweeping out under the letter.
|
||||
let y = gr.bottom() - 3.0;
|
||||
p.line_segment([egui::pos2(gr.left() - 3.0, y + 1.5), egui::pos2(gr.right() + 2.0, y - 0.5)], Stroke::new(1.0, col));
|
||||
}
|
||||
"salt" => {
|
||||
centred("aa", font);
|
||||
}
|
||||
"titl" => {
|
||||
centred("T", crate::theme::semibold(13.0));
|
||||
}
|
||||
"ordn" => {
|
||||
let one = p.layout_no_wrap("1".into(), font, col);
|
||||
let st = p.layout_no_wrap("st".into(), egui::FontId::proportional(9.0), col);
|
||||
let left = r.center().x - (one.size().x + st.size().x) / 2.0;
|
||||
let top = r.center().y - one.size().y / 2.0;
|
||||
let st_x = left + one.size().x;
|
||||
p.galley(egui::pos2(left, top), one, col);
|
||||
p.galley(egui::pos2(st_x, top - 1.0), st, col);
|
||||
}
|
||||
_ => {
|
||||
centred("½", font);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of each of `n` equal cells filling `avail` with 2 pt gaps.
|
||||
fn cell_width(avail: f32, n: usize) -> f32 {
|
||||
let n = n.max(1) as f32;
|
||||
@@ -1957,6 +2053,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
|
||||
}
|
||||
});
|
||||
ui.add_space(ROW_GAP);
|
||||
opentype_row(app, ui, &c, full);
|
||||
ui.add_space(ROW_GAP);
|
||||
}
|
||||
if paragraph && section(ui, "paragraph", tl!("Paragraph")) {
|
||||
use photocraft_doc::text::TextAlign as A;
|
||||
|
||||
@@ -1371,7 +1371,16 @@ mod multi_layer_formatting {
|
||||
for i in 0..2 {
|
||||
let mut expected = before[i].clone();
|
||||
expected.font_family = "JetBrains Mono".into();
|
||||
expected.postscript_name = None;
|
||||
// #3556: the new family's installed face at the peer's weight and italic is named (no
|
||||
// name without such a face), so a PSD names a real font.
|
||||
expected.postscript_name = photocraft_text::shared()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.fonts
|
||||
.faces("JetBrains Mono")
|
||||
.into_iter()
|
||||
.find(|f| f.italic == expected.italic && f.weight.round() as u16 == expected.weight)
|
||||
.and_then(|f| f.postscript_name);
|
||||
assert_eq!(style(h.state(), ids[i], 0), expected, "family changes preserve each peer's face and metrics");
|
||||
}
|
||||
assert_eq!(style(h.state(), ids[2], 0), before[2]);
|
||||
@@ -1455,6 +1464,51 @@ mod multi_layer_formatting {
|
||||
assert_eq!(text(&app, ids[0]).antialias, AntiAlias::Sharp);
|
||||
}
|
||||
|
||||
/// The Character panel's OpenType buttons (TYPE-218-5, #3752): Photoshop's eight, in its
|
||||
/// order; each shows pressed while its feature is on and toggles it on the selected type
|
||||
/// layers, or on the selected characters while typing.
|
||||
#[test]
|
||||
fn character_panel_opentype_buttons_toggle_and_show_the_feature() {
|
||||
use egui::accesskit::Toggled;
|
||||
use egui_kittest::kittest::NodeT;
|
||||
use photocraft_engine::type_extra_cmds::feature_on;
|
||||
let (app, ids) = fixture();
|
||||
let untouched = style(&app, ids[2], 0);
|
||||
let mut h = controls(app, true);
|
||||
let shown = |h: &Harness<'static, PhotocraftApp>, label: &str| h.get_by_label(label).accesskit_node().toggled();
|
||||
let order: Vec<String> = h
|
||||
.get_all_by_role(Role::Button)
|
||||
.filter_map(|n| n.accesskit_node().label())
|
||||
.filter(|l| super::super::OPENTYPE_BUTTONS.iter().any(|(_, _, tip)| tip == l))
|
||||
.collect();
|
||||
let expected: Vec<&str> = super::super::OPENTYPE_BUTTONS.iter().map(|(_, _, tip)| *tip).collect();
|
||||
assert_eq!(order, expected, "every OpenType button, in Photoshop's order");
|
||||
assert_eq!(shown(&h, "Standard Ligatures"), Some(Toggled::True), "ligatures are on by default");
|
||||
assert_eq!(shown(&h, "Fractions"), Some(Toggled::False));
|
||||
h.get_by_label("Fractions").click();
|
||||
h.run();
|
||||
for id in &ids[..2] {
|
||||
assert!(feature_on(&style(h.state(), *id, 0), "frac"), "selected layers get the feature");
|
||||
}
|
||||
assert_eq!(style(h.state(), ids[2], 0), untouched, "an unselected layer is untouched");
|
||||
h.run();
|
||||
assert_eq!(shown(&h, "Fractions"), Some(Toggled::True), "the button shows the feature on");
|
||||
h.get_by_label("Standard Ligatures").click();
|
||||
h.run();
|
||||
h.run();
|
||||
assert!(!style(h.state(), ids[0], 0).ligatures && !style(h.state(), ids[1], 0).ligatures);
|
||||
assert_eq!(shown(&h, "Standard Ligatures"), Some(Toggled::False));
|
||||
// While typing, only the selected characters (1..3) change.
|
||||
editing(h.state_mut(), ids[0]);
|
||||
h.run();
|
||||
h.get_by_label("Swash").click();
|
||||
h.run();
|
||||
let swash = |h: &Harness<'static, PhotocraftApp>, ci| feature_on(&style(h.state(), ids[0], ci), "swsh");
|
||||
assert!(!swash(&h, 0) && swash(&h, 1) && swash(&h, 2) && !swash(&h, 3));
|
||||
h.run();
|
||||
assert_eq!(shown(&h, "Swash"), Some(Toggled::True), "shown at the selection start");
|
||||
}
|
||||
|
||||
/// Type › Anti-Alias and Type › Orientation check the edited (else selected) layer's value.
|
||||
#[test]
|
||||
fn type_menu_checks_the_current_anti_alias_and_orientation() {
|
||||
|
||||
@@ -577,7 +577,7 @@ fn run(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Res
|
||||
return Ok(json!(k));
|
||||
}
|
||||
if let Some(k) = id.strip_prefix("window.arrange.") {
|
||||
return arrange(app, k);
|
||||
return arrange(app, k, p);
|
||||
}
|
||||
match id {
|
||||
"view.extras" => {
|
||||
@@ -643,15 +643,23 @@ fn fit_layers(app: &mut PhotocraftApp) -> Result<Value, String> {
|
||||
Ok(json!({"zoom": v.zoom, "bounds": [b.x0, b.y0, b.x1, b.y1]}))
|
||||
}
|
||||
|
||||
/// A finite `[x, y]` screen position within a sane range (window systems reject wild values).
|
||||
fn screen_position(v: &Value) -> Option<[f32; 2]> {
|
||||
const LIMIT: f64 = 1.0e6;
|
||||
let a = v.as_array().filter(|a| a.len() == 2)?;
|
||||
let n = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite() && x.abs() <= LIMIT).map(|x| x as f32);
|
||||
Some([n(0)?, n(1)?])
|
||||
}
|
||||
|
||||
pub(crate) fn float_window(app: &mut PhotocraftApp, doc: usize, offset: usize) -> u64 {
|
||||
let wid = app.ui.alloc_id();
|
||||
let mut view = app.ui.views.get(doc).cloned().unwrap_or_default();
|
||||
view.fit_pending = offset > 0 || view.fit_pending;
|
||||
app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true });
|
||||
app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true, position: None });
|
||||
wid
|
||||
}
|
||||
|
||||
fn arrange(app: &mut PhotocraftApp, k: &str) -> Result<Value, String> {
|
||||
fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result<Value, String> {
|
||||
if LAYOUTS.contains(&k) {
|
||||
app.ui.view.arrange = k.to_string();
|
||||
// Each document fits its new cell.
|
||||
@@ -664,10 +672,31 @@ fn arrange(app: &mut PhotocraftApp, k: &str) -> Result<Value, String> {
|
||||
app.ui.windows.clear();
|
||||
Ok(json!({"arrange": "tabs"}))
|
||||
}
|
||||
"newWindowForDocument" | "floatInWindow" => {
|
||||
"newWindowForDocument" => {
|
||||
let doc = app.session.active_index().ok_or("no document")?;
|
||||
Ok(json!({"window": float_window(app, doc, 0)}))
|
||||
}
|
||||
// Optional `document` (index; default the active one) and `position` (`[x, y]`, the
|
||||
// window's screen position in points): dragging a tab off the tab strip uses both (#3733).
|
||||
"floatInWindow" => {
|
||||
let doc = match p.get("document") {
|
||||
None | Some(Value::Null) => app.session.active_index().ok_or("no document")?,
|
||||
Some(v) => {
|
||||
let i = v.as_u64().ok_or("document must be a document index")?;
|
||||
usize::try_from(i).ok().filter(|&i| i < app.session.documents().len()).ok_or(format!("no document {i}"))?
|
||||
}
|
||||
};
|
||||
let position = match p.get("position") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(screen_position(v).ok_or("position must be [x, y] in points")?),
|
||||
};
|
||||
let wid = float_window(app, doc, 0);
|
||||
if let Some(w) = app.ui.windows.iter_mut().find(|w| w.id == wid) {
|
||||
w.position = position;
|
||||
}
|
||||
app.session.set_active(doc);
|
||||
Ok(json!({"window": wid}))
|
||||
}
|
||||
"floatAllInWindows" | "cascade" => {
|
||||
let n = app.session.documents().len();
|
||||
let ids: Vec<u64> = (0..n).map(|d| float_window(app, d, d)).collect();
|
||||
|
||||
@@ -292,6 +292,32 @@ fn lookup_export_dialog_uses_the_current_adjustment_selection() {
|
||||
assert!(direct["cube"].as_str().unwrap().contains("LUT_3D_SIZE 3"));
|
||||
}
|
||||
|
||||
/// Float in Window takes the document and the screen position a dragged-off tab was dropped at
|
||||
/// (#3733); bad params are errors, not panics.
|
||||
#[test]
|
||||
fn float_in_window_takes_a_document_and_position() {
|
||||
let (mut app, ctx) = app_with(3);
|
||||
let r = menu(&mut app, &ctx, "window.arrange.floatInWindow", json!({"document": 1, "position": [1950.0, 40.5]})).unwrap();
|
||||
let w = app.ui.windows.last().unwrap();
|
||||
assert_eq!(r, json!({"window": w.id}));
|
||||
assert_eq!((w.document, w.position), (1, Some([1950.0, 40.5])));
|
||||
assert_eq!(app.session.active_index(), Some(1));
|
||||
menu(&mut app, &ctx, "window.arrange.floatInWindow", json!({})).unwrap();
|
||||
assert_eq!(app.ui.windows.last().map(|w| (w.document, w.position)), Some((1, None)), "defaults: the active document, placed by the OS");
|
||||
for bad in [
|
||||
json!({"document": 3}),
|
||||
json!({"document": -1}),
|
||||
json!({"document": "b"}),
|
||||
json!({"position": [1.0]}),
|
||||
json!({"position": [1.0, "x"]}),
|
||||
json!({"position": [1.0e30, 0.0]}),
|
||||
json!({"position": 5}),
|
||||
] {
|
||||
assert!(menu(&mut app, &ctx, "window.arrange.floatInWindow", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(app.ui.windows.len(), 2, "rejected params open no window");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_selection_opens_its_dialog_and_saves_on_confirm() {
|
||||
// #3713: Select › Save Selection… asks for the channel, name and operation first.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Save keeps a JPEG's quality and a WebP's lossy/lossless encoding) · **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
|
||||
@@ -25,11 +25,11 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity.
|
||||
| PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) |
|
||||
| Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) |
|
||||
| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) |
|
||||
| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) |
|
||||
| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) |
|
||||
| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225) |
|
||||
| GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done |
|
||||
| BMP | R/W | yes | yes | 8-bit |
|
||||
| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP |
|
||||
| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399) |
|
||||
| AVIF | R/W | no (#463) | optional feature, off by default | |
|
||||
| JPEG XL | R/W | inside DNG only | no | |
|
||||
| HEIF/HEIC | R/W(HEIF) | yes (pure-Rust heic-rs, `heif` feature, on in releases) | no | 8/16-bit |
|
||||
@@ -140,5 +140,6 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) |
|
||||
| 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning |
|
||||
| 2026-10-10 | major | First version: Photoshop 2026 27.11.0's formats from its bundle, against our codecs; Affinity notes moved from `roadmap.md` |
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
# Where PhotoCraft falls short of Photoshop
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (G8: triple-, quadruple- and five-click type selection landed, TYPE-218-8 done; Czech UI localization evidence added to G13; G14: D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (G8: Character panel OpenType buttons landed, TYPE-218-5 stays partial; G8: triple-, quadruple- and five-click type selection landed, TYPE-218-8 done; Czech UI localization evidence added to G13; G14: D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact
|
||||
on the way to beta. This is the work list agents pick from: take the highest-ranked entry that
|
||||
@@ -76,8 +76,9 @@ what an agent can't finish alone.
|
||||
|
||||
### G8. Type depth
|
||||
- **Missing:** type on a path (TYPE-218-1), paragraph direction UI (TYPE-218-2), complex-script
|
||||
fallback fonts and golden tests (TYPE-218-3), OpenType panel UI (TYPE-218-5), live Warp Text
|
||||
preview (TYPE-218-7, #2689), "rasterize and continue" for distort/perspective on type
|
||||
fallback fonts and golden tests (TYPE-218-3), OpenType Numerals and Stylistic Sets submenus
|
||||
(TYPE-218-5; the Character panel buttons landed 2026-10-11, #3752), live Warp Text preview
|
||||
(TYPE-218-7, #2689), "rasterize and continue" for distort/perspective on type
|
||||
(TYPE-218-9), text free transform (#2630). Done since the re-measure: manual and optical kerning
|
||||
with PSD round trip (TYPE-218-6, #206), variable-font axes (TYPE-218-4, #2985).
|
||||
- **Evidence:** scorecard Type: 3 done, 2 partial, 4 missing. 2026-10-11: 2/3/4/5 clicks select
|
||||
@@ -227,6 +228,7 @@ what an agent can't finish alone.
|
||||
| 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open |
|
||||
| 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open |
|
||||
| 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted |
|
||||
| 2026-10-11 | minor | G8: Character panel OpenType buttons landed (TYPE-218-5, #3752); Numerals and Stylistic Sets submenus still missing |
|
||||
| 2026-10-10 | minor | G2: recorded Tilt-Shift computation/filtered-proxy measurements and limits; gap and estimate remain open |
|
||||
| 2026-10-10 | minor | Checked against the alpha gate (no alpha blockers); #2374 marked closed |
|
||||
| 2026-10-10 | major | First version, from the full re-measure against Photoshop 2026 27.11.0 and the open issues |
|
||||
@@ -0,0 +1,62 @@
|
||||
# Italian terminology and dynamic UI labels
|
||||
|
||||
This note goes with the completion of the Italian catalog (`crates/ui-egui/src/i18n/it.tsv`).
|
||||
The catalog already covered every key `cargo xtask i18n-coverage` measures, but it lacked 1,287
|
||||
labels that only exist at runtime and that other catalogs (Brazilian Portuguese, Dutch, Czech)
|
||||
already translate: filter and adjustment parameter names, Camera Raw panels, lighting effects,
|
||||
History, Layers, path and channel menu labels, metadata fields, status messages, System Info and
|
||||
dialog text. In Italian those showed in English.
|
||||
They are now translated, and a review pass fixed mistranslations and inconsistent terms in the
|
||||
existing entries. Translations are written from the meaning of PhotoCraft's English labels; no
|
||||
proprietary translation resources, fonts, screenshots or user documents are included.
|
||||
|
||||
## Register and style
|
||||
|
||||
Commands are imperatives in the second person singular (*Elimina*, *Applica*, *Crea
|
||||
pacchetto*); text addresses the user as *tu*. Only the first word is capitalised, except in
|
||||
command names joined by a slash, which keep both capitals (*Luminosità/Contrasto*,
|
||||
*HSL/Scala di grigio*). Decimals use a comma.
|
||||
|
||||
## Terminology
|
||||
|
||||
| English source | Italian | Note |
|
||||
|---|---|---|
|
||||
| Layer | Livello | |
|
||||
| Canvas | Quadro | also in *Come il quadro*, *Applica ai limiti del quadro* |
|
||||
| Preset | Predefinito | *Salva come predefinito* |
|
||||
| Default(s) | Predefinito / Predefiniti | never *default* or *valori predefiniti* |
|
||||
| Stroke | Traccia | contour and painterly strokes alike (*Tracce angolate*, *Direzione traccia*); a dash stays *Tratto* |
|
||||
| Sharpen | Contrasta / Contrasto | also in Camera Raw (*Quantità contrasto*) |
|
||||
| Noise | Disturbo | *Riduzione disturbo di luminanza*, *Riduzione disturbo cromatico* |
|
||||
| Highlights | Luci | pairs with *Ombre* |
|
||||
| Snapshot | Istantanea | |
|
||||
| Smart Filters | Filtri avanzati | |
|
||||
| Pattern | Pattern | *Timbro pattern* |
|
||||
| Gradient stops | Punti della sfumatura | |
|
||||
| Spot color | Tinta piatta | *Canale tinta piatta* |
|
||||
| Click | clic | *Fai clic*, *{key}-clic*, *doppio clic* |
|
||||
| Keyboard shortcut | Scorciatoia da tastiera | |
|
||||
| Footage | Filmato | *Interpreta filmato*, *Sostituisci filmato* |
|
||||
| Facet | Sfaccetta | |
|
||||
| SemiBold / ExtraBold / ExtraLight | Semigrassetto / Extragrassetto / Extraleggero | one spelling for every weight |
|
||||
|
||||
## Not covered
|
||||
|
||||
Engine error and status messages, user document, layer and preset names, and automation
|
||||
command IDs stay in English, as in every language. A few dynamic labels the other catalogs carry
|
||||
(`Ask Before Saving Saved Embedded`, `Clear Selection`, the `Heal` family) are not used by the
|
||||
current code; they are translated to stay aligned with the other catalogs.
|
||||
|
||||
## Validation
|
||||
|
||||
- `italian_covers_generated_filter_options_and_gallery_names` (`crates/ui-egui/src/filter_dialog.rs`)
|
||||
enumerates every filter and adjustment parameter, symbolic choice and gallery name, like the
|
||||
Korean test, and requires an Italian row for each. With the catalog on `main` before this change
|
||||
it lists 388 missing labels; now none.
|
||||
- `cargo test -p photocraft-ui-egui --lib`: the catalog tests pass. Two layout tests,
|
||||
`type_controls_fill_the_panel_at_every_width_and_scale` and
|
||||
`properties_edit_shown_metrics_and_paragraphs_with_a_non_type_primary`, fail on `main` as well
|
||||
without this change.
|
||||
- `cargo xtask i18n-coverage`: 1,726 / 1,726 for `it`.
|
||||
- The offscreen `snapshot` example with `PHOTOCRAFT_LOCALE=it` rendered Lighting Effects, the
|
||||
Camera Raw filter and Wind with Italian labels, without missing glyphs or clipped text.
|
||||
@@ -1,10 +1,10 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Czech review follow-ups) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Italian dynamic labels; previously minor: Czech review follow-ups) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
Per-language status of PhotoCraft's UI. How catalogs work and how to add one:
|
||||
[`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md),
|
||||
[Simplified Chinese](localization-zh-hans.md).
|
||||
[Simplified Chinese](localization-zh-hans.md), [Italian](localization-it.md).
|
||||
|
||||
**How measured.** `cargo xtask i18n-coverage` (`xtask/src/i18n_coverage.rs`) defines the key
|
||||
universe: `tl!("…")` literals and `trn(` plurals in `ui-egui` (tests excluded), menu-catalog
|
||||
@@ -47,7 +47,8 @@ catalog).
|
||||
|
||||
8 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional
|
||||
Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522),
|
||||
Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (2,411).
|
||||
Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (3,928; also every dynamic label the
|
||||
other catalogs translate, [notes](localization-it.md)).
|
||||
|
||||
Czech UI follow-up (2026-10-11): status and notice wrappers, hardware labels, brush and
|
||||
transform accessibility names, rasterization prompts, profile mismatch messages and preset
|
||||
@@ -77,3 +78,4 @@ engine localization or native review is complete.
|
||||
| 2026-10-10 | minor | Right-to-left shell layout (menu bar, title, options and status bars, toolbox and dock sides) |
|
||||
| 2026-10-11 | minor | Czech dynamic status/notice and hardware text, render labels and Navigator zoom context; engine messages remain English |
|
||||
| 2026-10-11 | minor | Czech review follow-ups: English System Info diagnostics, id-aware shortcut labels, display-only auto translation, warning punctuation and wording; catalog row count refreshed |
|
||||
| 2026-10-11 | minor | Italian: 1,287 dynamic labels added (filter parameters, Camera Raw, lighting, panels, dialogs) and terminology made consistent |
|
||||
+3
-3
@@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
| [Tools](#tools) | 28 | 14 | 1 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 30 of 155 (target 0) |
|
||||
| [Type](#type) | 4 | 2 | 3 | |
|
||||
| [Automation](#automation) | 3 | 2 | 6 | 631 of 631 menu items live (100.0%) |
|
||||
| [Automation](#automation) | 4 | 2 | 5 | 631 of 631 menu items live (100.0%) |
|
||||
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present |
|
||||
| [Distribution](#distribution) | 5 | 4 | 2 | |
|
||||
| [Open bugs](#open-bugs) | 29 | 2 | 1 | |
|
||||
@@ -214,7 +214,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w
|
||||
| TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Engine has TextDirection (text.rs:155); no UI |
|
||||
| TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Thai is covered: THAI_FAMILIES (fonts.rs) lists the Windows, macOS and Linux families and thai_in_latin_font_falls_back_to_installed_thai_font checks a sample with above and below marks (#1909); Arabic and Hebrew fall back too. Khmer and Indic have no fallback list, and there are no per-script golden shaping tests (only a bidi test) |
|
||||
| TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet |
|
||||
| TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512); no panel buttons or submenus |
|
||||
| TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Still missing: the Numerals and Stylistic Sets submenus, and greying out features the font lacks |
|
||||
| TYPE-218-6 | Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning) |
|
||||
| TYPE-218-7 | Warp Text dialog previews live and opens with the layer's current warp | missing | [#218](https://github.com/storytold/photocraft/issues/218) | view_cmds.rs:834 always seeds arc, bend 50 |
|
||||
| TYPE-218-8 | Text editing: select all, double/triple/quadruple-click selection, paste, with tests | done | [#218](https://github.com/storytold/photocraft/issues/218) | Like Photoshop, 2 clicks select the word, 3 the laid-out line (a column in vertical type), 4 the paragraph up to the hard return, 5 all text, and the next quick click starts over (#3493; type_tool.rs next_click_count, click_selection, multi_click). Tested through real canvas clicks in point, paragraph and vertical type, with paste replacing the selection and Cmd+A selecting all (type_tool_tests.rs multi_click_selects_word_line_paragraph_and_all_in_point_text, multi_click_separates_wrapped_lines_from_paragraphs, click_counts_cycle_and_restart, click_selection_without_layout_and_on_hostile_input, multi_click_without_a_session_does_nothing) |
|
||||
@@ -230,7 +230,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T
|
||||
| AUTO-219-1 | Record actions in a document-independent form (implicit layer, merged type keystrokes) | missing | [#219](https://github.com/storytold/photocraft/issues/219) | actions.rs:47 copies raw journal entries with explicit layer ids |
|
||||
| AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | missing | [#219](https://github.com/storytold/photocraft/issues/219) | Panel deletes whole actions only (actions.rs:146) |
|
||||
| AUTO-219-3 | actions.record/stop/play/list commands for agents and the CLI | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions.list/get/record/stop/play/delete in actions_cmds.rs; the panel, CLI and MCP share them. Nested steps pass the session authorize hook. |
|
||||
| AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | missing | [#219](https://github.com/storytold/photocraft/issues/219) | One replay test (actions.rs:171: new layer, rect select, fill) |
|
||||
| AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions_cmds.rs: a_recorded_edit_run_replays_on_a_second_document records a crop, a Gaussian Blur, a selection fill and faux-bold type on one document and replays all of them onto a second (plus the original layer/select/fill replay) |
|
||||
| AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel |
|
||||
| AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only |
|
||||
| AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency |
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI parity: tools, handles, modifiers and feel
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (History snapshots) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (⇧-dragged guides land on ruler ticks) · **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,
|
||||
@@ -149,6 +149,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
|
||||
| Smart guides (magenta alignment lines) | done | |
|
||||
| Smart guide distance and spacing labels | missing | |
|
||||
| Ruler units px/in/cm/mm/pt/pica/% | done | |
|
||||
| ⇧-drag a guide out of a ruler: it lands on the nearest ruler tick | done | `rulers.rs` `snap_to_tick` (#3770) |
|
||||
| Status bar follows the ruler unit | partial | #2602 |
|
||||
| Ruler corner and gaps drawn as Photoshop | partial | #2600, #2601, #2551 |
|
||||
| Guides stored as f64 | missing | `Vec<f32>` (UI-217-20) |
|
||||
@@ -213,3 +214,4 @@ Paragraph; the rest open as in-app floating windows.
|
||||
| 2026-10-10 | minor | Fixed Crop live resize snapping feedback and outdated-camera rendering, applied ratios immediately to centred inscribed previews, and removed two-point options-bar overflow; added pointer, rendering, dropdown/Enter, Swap/X and scrolling regressions |
|
||||
| 2026-10-11 | minor | Flick panning done: releasing a moving Hand drag (tool, Space or middle button) glides and decays, gated by Enable Flick Panning |
|
||||
| 2026-10-11 | minor | History snapshots: the camera button takes one, snapshot rows return to it as a new state, rename and delete it (#2966) |
|
||||
| 2026-10-11 | minor | ⇧-drag a guide out of a ruler lands on the nearest ruler tick (#3770) |
|
||||
@@ -35,9 +35,9 @@ note = "actions.list/get/record/stop/play/delete in actions_cmds.rs; the panel,
|
||||
[[item]]
|
||||
id = "AUTO-219-4"
|
||||
target = "Replay tests for crop, filters, selections and styled text on a second document"
|
||||
status = "missing"
|
||||
status = "done"
|
||||
issue = 219
|
||||
note = "One replay test (actions.rs:171: new layer, rect select, fill)"
|
||||
note = "actions_cmds.rs: a_recorded_edit_run_replays_on_a_second_document records a crop, a Gaussian Blur, a selection fill and faux-bold type on one document and replays all of them onto a second (plus the original layer/select/fill replay)"
|
||||
|
||||
[[item]]
|
||||
id = "AUTO-219-5"
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ id = "TYPE-218-5"
|
||||
target = "OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus"
|
||||
status = "partial"
|
||||
issue = 218
|
||||
note = "Type > OpenType toggles (type_extra_cmds.rs:512); no panel buttons or submenus"
|
||||
note = "Type > OpenType toggles (type_extra_cmds.rs:512). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Still missing: the Numerals and Stylistic Sets submenus, and greying out features the font lacks"
|
||||
|
||||
[[item]]
|
||||
id = "TYPE-218-6"
|
||||
|
||||
Reference in new issue
Block a user