Merge pull request #9386 from davidmann4/integration-batch-8

Integration batch 8: 37 reviewed community PRs on main
This commit is contained in:
aiongg
2026-10-25 20:04:41 -04:00
committed by GitHub
217 changed files with 22542 additions and 1763 deletions
+1 -1
View File
@@ -116,7 +116,7 @@ next to the files:
| Path (fetched) | Title | Author | Source | License |
|---|---|---|---|---|
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/photoshop/` (258 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/gimp/` (28 files, 251 KB) | GIMP XCF oracles: 40×30 procedural layers written by GIMP 3.2.6 through the corpus generator `tools/gimp-xcf/xcf_fixtures.py`, each with GIMP's merged rendering as a PNG | PhotoCraft contributors (authored with GIMP 3.2.6 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`gimp/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
Generated
+7
View File
@@ -5082,8 +5082,10 @@ dependencies = [
name = "photocraft-gpu"
version = "0.16.0"
dependencies = [
"bytemuck",
"half",
"log",
"photocraft-algo",
"photocraft-cms",
"photocraft-color",
"photocraft-compose",
@@ -5132,6 +5134,9 @@ dependencies = [
"roxmltree 0.21.1",
"serde_json",
"thiserror 2.0.21",
"tiff",
"zune-core 0.5.3",
"zune-jpegxl",
]
[[package]]
@@ -5149,9 +5154,11 @@ dependencies = [
name = "photocraft-paint"
version = "0.16.0"
dependencies = [
"photocraft-cms",
"photocraft-color",
"photocraft-geom",
"photocraft-raster",
"rayon",
"serde",
"serde_json",
]
+2 -2
View File
@@ -185,8 +185,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<table>
<tr>
<td width="33%" valign="top">
<h4>🧰 67 tools</h4>
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
<h4>🧰 68 tools</h4>
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Art History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
</td>
<td width="33%" valign="top">
<h4>🖌️ A real brush engine</h4>
+8 -3
View File
@@ -125,7 +125,8 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() {
let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft"));
ok(bin().arg("convert").arg(&jpg).arg(&a));
ok(bin().arg("convert").arg(&a).arg(&b));
// Everything but the file's own path and saved document name must match.
// Everything but the file's own path and saved document name (which the first snapshot is
// named after) must match.
let info = |p: &Path| {
let mut v = serde_json::from_str::<Value>(&ok(bin().arg("info").arg(p))).unwrap();
v.as_object_mut().unwrap().remove("file");
@@ -136,8 +137,12 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() {
assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32)));
assert_eq!(ia["name"], "a.pcraft");
assert_eq!(ib["name"], "b.pcraft");
ia.as_object_mut().unwrap().remove("name");
ib.as_object_mut().unwrap().remove("name");
assert_eq!(ia["snapshots"][0]["name"], "a.pcraft");
assert_eq!(ib["snapshots"][0]["name"], "b.pcraft");
for v in [&mut ia, &mut ib] {
v.as_object_mut().unwrap().remove("name");
v["snapshots"][0].as_object_mut().unwrap().remove("name");
}
assert_eq!(ia, ib);
let (pa, pb) = (d.join("a.png"), d.join("b.png"));
ok(bin().arg("convert").arg(&a).arg(&pa));
+136
View File
@@ -92,6 +92,53 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
CUSTOM_TITLEBAR && !prefs.interface.system_title_bar
}
/// Preferences › Integrations, read before the window opens the way [`custom_titlebar`] is:
/// whether agents may drive this launch at all, and the port to listen on when no `--control`
/// was given (0 = only with `--control`). A missing or unreadable file keeps the defaults.
///
/// The section is read on its own, and when it doesn't read as a whole each value on its own,
/// as the app's lenient load reads them (#6611): a wrong-typed value elsewhere in the file, or
/// in the other field, must not turn Allow Agent Control back on while the dialog shows it off.
fn integrations(prefs_file: Option<&std::path::Path>) -> photocraft_engine::prefs::Integrations {
use serde_json::Value;
let mut out = photocraft_engine::prefs::Preferences::default().integrations;
let file: Option<Value> = prefs_file.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok());
let Some(section) = file.as_ref().and_then(|v| v.get("integrations")) else {
return out;
};
if let Ok(whole) = serde_json::from_value(section.clone()) {
return whole;
}
if let Some(allow) = section.get("allowAgentControl").and_then(Value::as_bool) {
out.allow_agent_control = allow;
}
if let Some(port) = section.get("controlPort").and_then(Value::as_u64).and_then(|p| u32::try_from(p).ok()) {
out.control_port = port;
}
out
}
/// The control port this launch listens on, and the warning to print when a request was refused.
/// `--control` (or `PHOTOCRAFT_CONTROL_PORT`) wins over the preference; Allow Agent Control off
/// refuses both, and says so rather than ignoring the flag silently. A preferred port outside
/// 1..=65535 is refused the same way.
fn control_port_for(cli: Option<u16>, prefs: &photocraft_engine::prefs::Integrations) -> (Option<u16>, Option<String>) {
if !prefs.allow_agent_control {
let warning = cli.map(|p| format!("photocraft: Preferences › Integrations › Allow Agent Control is off: not listening on port {p}\n"));
return (None, warning);
}
if let Some(port) = cli {
return (Some(port), None);
}
match prefs.control_port {
0 => (None, None),
p => match u16::try_from(p).ok().filter(|p| *p != 0) {
Some(port) => (Some(port), None),
None => (None, Some(format!("photocraft: Preferences › Integrations › Control Port {p} is not a usable port: not listening\n"))),
},
}
}
/// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame
/// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without
/// `centered`, Windows cascades each new window from the top-left corner, so it opened at a
@@ -274,6 +321,10 @@ fn main() -> eframe::Result {
std::process::exit(1);
}
let (control_port, control_pref_warning) = control_port_for(control_port, &integrations(services::prefs_file().as_deref()));
if let Some(warning) = &control_pref_warning {
eprint!("{warning}");
}
let control = if let Some(port) = control_port {
let (supplied, token_file) = (auth.control_token, auth.control_token_file);
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
@@ -844,6 +895,91 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn integrations_preferences_decide_the_control_port() {
use photocraft_engine::prefs::Integrations;
let off = Integrations { allow_agent_control: false, control_port: 0 };
let on = Integrations { allow_agent_control: true, control_port: 0 };
let preferred = Integrations { allow_agent_control: true, control_port: 7878 };
// Nothing asked for, nothing listening.
assert_eq!(super::control_port_for(None, &on), (None, None));
// --control wins over the preference.
assert_eq!(super::control_port_for(Some(9000), &preferred), (Some(9000), None));
// The preference opens the port when --control is absent.
assert_eq!(super::control_port_for(None, &preferred), (Some(7878), None));
// Allow Agent Control off refuses both, and says so when --control asked for one
// rather than ignoring the flag.
assert_eq!(super::control_port_for(None, &off), (None, None));
let (port, warning) = super::control_port_for(Some(9000), &off);
assert_eq!(port, None);
assert!(warning.is_some_and(|w| w.contains("Allow Agent Control") && w.contains("9000")), "the refusal names the port");
let off_with_port = Integrations { allow_agent_control: false, control_port: 7878 };
assert_eq!(super::control_port_for(None, &off_with_port), (None, None), "off beats a preferred port");
// A port a hand-edited file could hold, outside 1..=65535, is refused and named.
let huge = Integrations { allow_agent_control: true, control_port: 70_000 };
let (port, warning) = super::control_port_for(None, &huge);
assert_eq!(port, None);
assert!(warning.is_some_and(|w| w.contains("70000")), "the refusal names the value");
}
#[test]
fn integrations_fall_back_to_the_defaults_without_a_readable_file() {
let dir = std::env::temp_dir().join(format!("pc-integrations-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("prefs.json");
let default = photocraft_engine::prefs::Integrations::default();
assert_eq!(super::integrations(None), default, "no preferences file");
std::fs::write(&file, "not json").unwrap();
assert_eq!(super::integrations(Some(&file)), default, "an unreadable file");
std::fs::write(&file, r#"{"integrations":{"allowAgentControl":false,"controlPort":7878}}"#).unwrap();
let read = super::integrations(Some(&file));
assert!(!read.allow_agent_control);
assert_eq!(read.control_port, 7878);
let _ = std::fs::remove_dir_all(&dir);
}
/// The app loads preferences leniently (#6611): one wrong-typed value resets only itself. So
/// a bad value elsewhere, or in the port, must not turn Allow Agent Control back on.
#[test]
fn a_bad_value_elsewhere_keeps_agent_control_off() {
let dir = std::env::temp_dir().join(format!("pc-integrations-lenient-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("prefs.json");
// What the Preferences dialog shows for the same file.
let shown = |text: &str| {
let mut session = photocraft_engine::Session::new();
session.load_prefs_json(text).unwrap();
session.prefs().integrations.clone()
};
let read = |text: &str| {
std::fs::write(&file, text).unwrap();
let read = super::integrations(Some(&file));
assert_eq!(read, shown(text), "{text}");
read
};
let r = read(r#"{"interface":{"enableAnimations":"nope"},"integrations":{"allowAgentControl":false,"controlPort":7878}}"#);
assert!(!r.allow_agent_control, "a bad value in another section");
assert_eq!(r.control_port, 7878);
let r = read(r#"{"integrations":{"allowAgentControl":false,"controlPort":"x"}}"#);
assert!(!r.allow_agent_control, "a bad port");
assert_eq!(r.control_port, 0, "the bad port alone takes its default");
// A bad Allow Agent Control alone takes its default.
let r = read(r#"{"integrations":{"allowAgentControl":"no","controlPort":7878}}"#);
assert_eq!(r, photocraft_engine::prefs::Integrations { allow_agent_control: true, control_port: 7878 });
read(r#"{"integrations":{"allowAgentControl":false,"controlPort":-1}}"#);
read(r#"{"integrations":{"allowAgentControl":false,"controlPort":4294967296}}"#);
read(r#"{"integrations":"off"}"#);
// serde reads a struct from a list too; the app does.
assert!(!read(r#"{"integrations":[false]}"#).allow_agent_control, "a list");
assert_eq!(read(r#"{"integrations":[false,7878]}"#).control_port, 7878);
let _ = std::fs::remove_dir_all(&dir);
}
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
#[test]
+1 -1
View File
@@ -4,7 +4,7 @@
## Layered TIFF
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: up to sixteen extra samples (spot colours and alpha channels, plus a Quick Mask) are written in the IFD and round-trip, with Photoshop image resources for the channel name, ink colour and solidity; Lab documents are saved flat, with their extra samples. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
## WebP
+7
View File
@@ -265,6 +265,9 @@ sync_github_name = false
[people.drunkeninja42]
real_name = "Kushagra Sharma"
[people.WebWork-rrs]
display_name = "Ratul Paul"
[people.softwaregames1]
sync_github_name = true
@@ -275,3 +278,7 @@ sync_github_name = false
[people.erenova]
display_name = "Erenova"
sync_github_name = false
[people.jasallen]
real_name = "Jason DeBoever"
sync_github_name = true
+145 -26
View File
@@ -1,42 +1,161 @@
//! Time and peak memory of the Photomerge multi-band blend: `n` RGB sources of `mp` megapixels
//! (3:2), each overlapping the next by half its width.
//! `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]` (default 4 × 24 MP).
//! Peak memory is the growth of the process's high-water mark during the blend (Linux only).
//! Time and peak memory of Photomerge's multi-band blend on overlapping RGB sources.
//! Backwards compatible: `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]`.
//! `cargo xtask perf --bench bench_multiband --quick` also collects repeatable JSON timings.
//! The peak is the entire process's resident-set high-water mark, not an estimate of allocations
//! by the blend alone (`photocraft_testkit::perf`: the kernel's mark on Linux, sampled elsewhere).
use std::path::PathBuf;
use std::time::Instant;
use photocraft_algo::panorama::{self, RoiImage};
use photocraft_testkit::perf::{self, RssSampler, Summary};
use serde_json::json;
/// The process's peak resident set in MiB (`VmHWM`), where `/proc` has it.
fn peak_mib() -> Option<f64> {
let s = std::fs::read_to_string("/proc/self/status").ok()?;
let kb: f64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?;
Some(kb / 1024.0)
const ROW: &str = "Photomerge multiband blend";
#[derive(Debug, PartialEq)]
struct Options {
n: usize,
mp: f64,
reps: usize,
json: Option<PathBuf>,
}
fn main() {
let mut args = std::env::args().skip(1);
let n: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4).max(1);
let mp: f64 = args.next().and_then(|a| a.parse().ok()).unwrap_or(24.0);
let sh = ((mp * 1e6 / 1.5).sqrt().round() as usize).max(2);
/// Parses legacy `[n] [mp]` arguments and the perf runner's `--reps`/`--json` flags.
/// Rejects missing, out-of-range, or non-finite values before allocating images.
fn options(args: impl IntoIterator<Item = String>) -> Result<Options, String> {
let mut out = Options { n: 4, mp: 24.0, reps: 1, json: None };
let mut positional = Vec::new();
let mut it = args.into_iter();
while let Some(arg) = it.next() {
match arg.as_str() {
"--json" => {
let path = it.next().filter(|p| !p.starts_with('-')).ok_or("--json needs a file path")?;
out.json = Some(PathBuf::from(path));
}
"--reps" => {
out.reps = it.next().ok_or("--reps needs an integer")?.parse().map_err(|_| "--reps needs an integer")?;
}
_ if !arg.starts_with('-') => positional.push(arg),
_ => return Err(format!("unknown option: {arg}")),
}
}
if positional.len() > 2 {
return Err("expected at most [n] [mp]".into());
}
if let Some(n) = positional.first() {
out.n = n.parse().map_err(|_| "n must be an integer")?;
}
if let Some(mp) = positional.get(1) {
out.mp = mp.parse().map_err(|_| "mp must be a number")?;
}
if !(1..=16).contains(&out.n) || !(1..=25).contains(&out.reps) || !out.mp.is_finite() || !(0.01..=36.0).contains(&out.mp) {
return Err("expected 1..=16 images, 0.01..=36 MP per image and 1..=25 repetitions".into());
}
Ok(out)
}
/// Builds deterministic overlapping RGB inputs and times each multi-band blend.
/// Writes individual samples and whole-process peak RSS to JSON when requested.
fn run() -> Result<(), String> {
let o = options(std::env::args().skip(1))?;
let sh = ((o.mp * 1e6 / 1.5).sqrt().round() as usize).max(2);
let sw = sh * 3 / 2;
let step = sw / 2;
let (cw, ch) = (step * (n - 1) + sw, sh);
let rois: Vec<RoiImage> = (0..n)
let (cw, ch) = (step * (o.n - 1) + sw, sh);
let rois: Vec<RoiImage> = (0..o.n)
.map(|k| {
let px = (0..sw * sh * 3).map(|i| ((i / 3 % sw * 7 + i / 3 / sw * 3 + k * 31 + i % 3 * 50) % 251) as f32 / 251.0).collect();
RoiImage { x0: (step * k) as i32, y0: 0, w: sw, h: sh, ch: 3, px, alpha: vec![1.0; sw * sh] }
})
.collect();
// Each canvas column belongs to the image whose centre is nearest, as a seam would split it.
let wts: Vec<Vec<f32>> = (0..n)
.map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(n - 1) == k { 1.0 } else { 0.0 }).collect())
let wts: Vec<Vec<f32>> = (0..o.n)
.map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(o.n - 1) == k { 1.0 } else { 0.0 }).collect())
.collect();
let levels = panorama::blend_levels(cw, ch).min(7);
let before = peak_mib();
let t0 = Instant::now();
let (px, cov) = panorama::multiband(cw, ch, &rois, &wts, levels);
let ms = t0.elapsed().as_secs_f64() * 1000.0;
let peak = peak_mib().zip(before).map_or("n/a".to_string(), |(a, b)| format!("{:.0} MiB", a - b));
println!("{n} × {sw}×{sh} ({mp} MP) RGB, canvas {cw}×{ch}, {levels} levels: {ms:.0} ms, peak +{peak} during the blend");
std::hint::black_box((px, cov));
let before = perf::process_peak_rss_bytes().or_else(perf::current_rss_bytes);
let sampler = RssSampler::start(std::time::Duration::from_millis(2));
let mut samples_ms = Vec::with_capacity(o.reps);
for _ in 0..o.reps {
let t0 = Instant::now();
let output = panorama::multiband(cw, ch, &rois, &wts, levels);
samples_ms.push(t0.elapsed().as_secs_f64() * 1000.0);
std::hint::black_box(&output);
}
let peak = perf::process_peak_rss_bytes().or(sampler.peak());
let delta = before.zip(peak).map(|(a, b)| b.saturating_sub(a) as f64 / (1024.0 * 1024.0));
// The same nearest-rank p50 that `cargo xtask perf` reports for these samples.
let p50 = Summary::of(&samples_ms).map_or(f64::NAN, |s| s.p50);
println!(
"{} × {}×{} ({:.2} MP) RGB, canvas {}×{}, {} levels: {:.1} ms p50 ({} runs), peak +{}",
o.n,
sw,
sh,
o.mp,
cw,
ch,
levels,
p50,
o.reps,
delta.map_or("n/a".to_string(), |m| format!("{m:.0} MiB"))
);
if let Some(path) = o.json {
let context = json!({"sources": o.n, "mp_per_source": o.mp, "canvas_width": cw, "canvas_height": ch, "levels": levels});
let report = perf::report("bench_multiband", context, vec![perf::row(ROW, &samples_ms, peak, None)], Some(&sampler));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
}
std::fs::write(&path, serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| format!("write {}: {e}", path.display()))?;
}
Ok(())
}
/// Runs the benchmark, reporting argument or output errors with a failing exit status.
fn main() {
if let Err(e) = run() {
eprintln!("bench_multiband: {e}");
std::process::exit(2);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Converts borrowed argument slices for the owned-string command-line parser.
fn parse(args: &[&str]) -> Result<Options, String> {
options(args.iter().map(|s| s.to_string()))
}
/// Confirms backwards compatibility with the positional and perf-runner forms.
#[test]
fn parses_old_positional_args_and_perf_runner_args() {
assert_eq!(parse(&[]).unwrap().n, 4);
assert_eq!(
parse(&["2", "0.25", "--reps", "3", "--json", "out.json"]).unwrap(),
Options { n: 2, mp: 0.25, reps: 3, json: Some(PathBuf::from("out.json")) }
);
// The 24-36 MP range AGENTS.md benchmarks at, as before the flags were added.
assert_eq!(parse(&["4", "36"]).unwrap().mp, 36.0);
}
/// Checks invalid counts, non-finite sizes, repetitions, and missing flag values.
#[test]
fn rejects_bad_benchmark_args() {
for args in [
vec!["0"],
vec!["17"],
vec!["2", "0"],
vec!["2", "NaN"],
vec!["2", "inf"],
vec!["2", "37"],
vec!["--reps", "0"],
vec!["--reps", "26"],
vec!["--json"],
vec!["--json", "--reps", "5"],
vec!["--reps"],
vec!["--unknown"],
] {
assert!(parse(&args).is_err(), "{args:?}");
}
}
}
+78
View File
@@ -0,0 +1,78 @@
//! Timing of Content-Aware Scale's seam carving alone (no document plumbing), on a synthetic
//! photo-like RGBA image: perf scenario P28's 24 MP width change (#211), the same image's height
//! (the transposed pass) and two images short enough for the exact full-resolution seam search.
//! Prints an FNV-style hash of each output's bits (over 32-bit words), so a speed-up can be checked
//! to choose exactly the same seams.
//! `cargo run --release -p photocraft-algo --example bench_seam_cas [reps] [case filter]`
use std::time::Instant;
use photocraft_algo::seam::carve;
/// Smooth gradients, a few hard-edged shapes and fine noise: enough structure that seams are
/// steered by content, as in a photo.
fn photo(w: usize, h: usize) -> Vec<f32> {
let mut seed = 0x9e37_79b9u32;
let mut noise = move || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
(seed % 1000) as f32 / 1000.0 - 0.5
};
let mut img = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let (u, v) = (x as f32 / w as f32, y as f32 / h as f32);
let sky = 0.35 + 0.4 * v;
let disc = ((u - 0.3).powi(2) + (v - 0.55).powi(2)) < 0.02;
let bar = (0.62..0.66).contains(&u) && v > 0.3;
let hill = v > 0.7 + 0.08 * (u * 11.0).sin();
let (r, g, b) = if disc {
(0.85, 0.3, 0.2)
} else if bar {
(0.1, 0.1, 0.12)
} else if hill {
(0.2, 0.45 + 0.1 * (u * 40.0).sin(), 0.15)
} else {
(sky * 0.6, sky * 0.8, sky)
};
let n = noise() * 0.02;
img.extend_from_slice(&[r + n, g + n, b + n, 1.0]);
}
}
img
}
fn fnv(v: &[f32]) -> u64 {
v.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, f| (h ^ u64::from(f.to_bits())).wrapping_mul(0x0100_0000_01b3))
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let reps = args.get(1).and_then(|s| s.parse::<usize>().ok()).unwrap_or(1).clamp(1, 20);
let only = args.get(2).cloned();
// (name, w, h, new w, new h)
let cases = [
("24 MP to 90 % width (P28, proxy rows)", 6000, 4000, 5400, 4000),
("24 MP to 90 % height (transposed, proxy rows)", 6000, 4000, 6000, 3600),
("5.4 MP to 90 % width (exact)", 3000, 1800, 2700, 1800),
("1.5 MP to 70 % width (exact)", 1500, 1000, 1050, 1000),
];
for (name, w, h, nw, nh) in cases {
if only.as_ref().is_some_and(|o| !name.contains(o.as_str())) {
continue;
}
let img = photo(w, h);
let mut times = Vec::with_capacity(reps);
let mut hash = 0;
for _ in 0..reps {
let t = Instant::now();
let out = carve(w, h, 4, &img, None, nw, nh);
times.push(t.elapsed().as_secs_f64() * 1000.0);
hash = fnv(&out);
}
times.sort_by(f64::total_cmp);
let p50 = times[times.len() / 2];
let max = times[times.len() - 1];
println!("{name:<48} p50 {p50:>9.1} ms max {max:>9.1} ms output {hash:016x}");
}
}
File diff suppressed because it is too large Load Diff
+275 -63
View File
@@ -56,8 +56,11 @@ pub fn validate_curve(points: &[[f32; 2]]) -> Result<(), String> {
use crate::photo_util::{hash01, linear_to_srgb, par_rows, srgb_to_linear};
mod local_tone;
mod per_pixel;
mod whites_blacks;
pub use per_pixel::{CURVE_LUT, Grade, Grain, PerPixel, Vignette, VignetteStyle, WB_STEPS, WhitesBlacks, per_pixel, white_black_points};
/// How the pixels encode light: the sRGB curve (gamma 0) or a pure power law (ProPhoto's 1.8,
/// Adobe RGB's 2.2). The tone controls work on light, so the document's own curve decodes it.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -166,6 +169,13 @@ pub struct CameraRaw {
/// The document's transfer curve: 0 for sRGB's, else the gamma of a power-law space (1.8
/// for ProPhoto). Set from the document's profile; Whites and Blacks act on light.
pub encoding_gamma: f32,
// Calibration (Camera Raw's Calibration panel)
/// Shadows › Tint: −100 (green) … 100 (magenta), strongest in the darkest tones.
pub shadow_tint: f32,
/// Red, Green and Blue Primary › Hue: −100 … 100, the primary turned towards its neighbours.
pub calibration_hue: [f32; 3],
/// Red, Green and Blue Primary › Saturation: −100 (grey) … 100 (purer).
pub calibration_sat: [f32; 3],
}
impl Default for CameraRaw {
@@ -222,6 +232,9 @@ impl Default for CameraRaw {
seed: 0,
pixel_scale: 1.0,
encoding_gamma: 0.0,
shadow_tint: 0.0,
calibration_hue: [0.0; 3],
calibration_sat: [0.0; 3],
}
}
}
@@ -229,6 +242,90 @@ impl Default for CameraRaw {
/// Hue band centres of the Color Mixer (degrees).
pub const HSL_BANDS: [f32; 8] = [0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 270.0, 300.0];
/// How far a primary turns at Hue ±100 (degrees), as the Color Mixer's hue sliders do.
const CALIBRATION_TURN: f32 = 30.0;
/// How much of each primary (red, green, blue) a hue is made of: triangular weights around
/// 0°, 120° and 240° that sum to one, so a colour between two primaries follows both.
pub fn calibration_weights(hue: f32) -> [f32; 3] {
let h = hue.rem_euclid(360.0);
let mut w = [0.0f32; 3];
for (i, centre) in [0.0f32, 120.0, 240.0].iter().enumerate() {
let d = (h - centre).abs().min(360.0 - (h - centre).abs());
w[i] = (1.0 - d / 120.0).max(0.0);
}
let sum: f32 = w.iter().sum();
if sum > 1e-6 { w.map(|v| v / sum) } else { [1.0, 0.0, 0.0] }
}
/// The Calibration panel's primaries on one linear-light pixel: its hue turns by the weighted
/// Hue of the primaries it is made of (±100 is ±30°, positive towards the next primary) and its
/// saturation scales by their weighted Saturation (−100 greys it); the luminance is kept, so
/// neutrals are untouched.
fn calibrate(c: [f32; 3], hue: [f32; 3], sat: [f32; 3]) -> [f32; 3] {
let [h, s, v] = rgb_to_hsv(c.map(|x| x.max(0.0)));
if s <= 1e-5 || v <= 1e-6 {
return c;
}
let w = calibration_weights(h);
let (mut dh, mut ds) = (0.0f32, 0.0f32);
for i in 0..3 {
dh += w[i] * hue[i].clamp(-100.0, 100.0);
ds += w[i] * sat[i].clamp(-100.0, 100.0);
}
let nh = h + dh / 100.0 * CALIBRATION_TURN;
let ns = (s * (1.0 + ds / 100.0)).clamp(0.0, 1.0);
let out = hsv_to_rgb(nh, ns, v);
let (l0, l1) = (luma(c), luma(out));
if l1 > 1e-6 { out.map(|x| x * l0 / l1) } else { out }
}
/// Shadows › Tint on one linear-light pixel: `tint` −1 (green) … 1 (magenta), weighted by how
/// dark the pixel is; the luminance is kept.
fn shadow_tint(c: [f32; 3], tint: f32) -> [f32; 3] {
let l = luma(c).max(0.0);
// Linear 0.18 is middle grey: full strength below it, fading out towards white.
let weight = (1.0 - (l / 0.18).sqrt().min(1.0)).powi(2) * 0.5 + (1.0 - l.min(1.0)).powi(4) * 0.5;
let k = tint * 0.12 * weight * (l + 0.02);
let o = [c[0] + k, c[1] - k, c[2] + k].map(|v| v.max(0.0));
let l2 = luma(o);
if l2 > 1e-6 { o.map(|v| v * l / l2) } else { o }
}
/// Camera Raw's Auto for the Light panel: Exposure, Contrast, Highlights, Shadows, Whites and
/// Blacks from the image's luminance histogram (`px` in the document's encoding). A well
/// exposed image gets near-zero settings; a dark one more exposure and lifted shadows; one
/// with a crushed or blown end gets Blacks or Whites back from the edge. The other panels are
/// left alone, as Camera Raw's Auto leaves them.
pub fn auto_tone(px: &[[f32; 4]], p: &mut CameraRaw) {
if px.is_empty() {
return;
}
let mut lum: Vec<f32> = px.iter().map(|q| luma([q[0], q[1], q[2]]).clamp(0.0, 1.0)).collect();
lum.sort_by(|a, b| a.total_cmp(b));
let at = |f: f32| lum[((lum.len() - 1) as f32 * f).round() as usize];
let (black, shadow, median, light, white) = (at(0.001), at(0.1), at(0.5), at(0.9), at(0.999));
// Exposure: the median towards middle grey (sRGB 0.46), damped so flat scenes aren't
// forced to it.
let median_lin = srgb_to_linear(median).max(1e-4);
p.exposure = (0.6 * (0.18f32 / median_lin).log2()).clamp(-2.0, 2.0);
// Blacks and Whites bring the ends to the edges without clipping more than Camera Raw does.
p.blacks = ((0.04 - black) * 400.0).clamp(-40.0, 40.0);
p.whites = ((0.96 - white) * 300.0).clamp(-40.0, 40.0);
// Shadows up when the darker tenth is crushed, highlights down when the brighter tenth is
// near white.
p.shadows = ((0.25 - shadow) * 200.0).clamp(0.0, 50.0);
p.highlights = ((light - 0.85) * -300.0).clamp(-50.0, 0.0);
// A little contrast for a flat histogram.
let spread = (light - shadow).max(0.0);
p.contrast = ((0.6 - spread) * 60.0).clamp(0.0, 25.0);
// Shown as Camera Raw shows them: Exposure to a hundredth of a stop, the others whole.
p.exposure = (p.exposure * 100.0).round() / 100.0;
for v in [&mut p.blacks, &mut p.whites, &mut p.shadows, &mut p.highlights, &mut p.contrast] {
*v = v.round();
}
}
fn luma(c: [f32; 3]) -> f32 {
0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
}
@@ -534,6 +631,10 @@ impl CameraRaw {
fn grade_neutral(&self) -> bool {
self.grade_shadows.neutral() && self.grade_midtones.neutral() && self.grade_highlights.neutral() && self.grade_global.neutral()
}
/// The Calibration panel at rest.
fn calibration_neutral(&self) -> bool {
self.shadow_tint == 0.0 && self.calibration_hue.iter().chain(&self.calibration_sat).all(|v| *v == 0.0)
}
fn curves_neutral(&self) -> bool {
[self.curve_highlights, self.curve_lights, self.curve_darks, self.curve_shadows].iter().all(|v| *v == 0.0)
&& [&self.point_curve, &self.point_curve_red, &self.point_curve_green, &self.point_curve_blue].iter().all(|c| is_linear(c))
@@ -541,6 +642,7 @@ impl CameraRaw {
/// True when every control is neutral (the filter is an identity).
pub fn is_identity(&self) -> bool {
self.wb_neutral()
&& self.calibration_neutral()
&& self.tone_neutral()
&& self.dehaze == 0.0
&& self.vibrance == 0.0
@@ -580,14 +682,27 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
let ps = p.pixel_scale.clamp(0.01, 1.0);
let long = w.max(h) as f32;
// 1. White balance + exposure in linear light.
if !p.wb_neutral() {
let mut g = white_balance_gains(p.temperature, p.tint);
let norm = luma(g);
g = g.map(|v| v / norm * 2f32.powf(p.exposure));
if let Some(g) = per_pixel::gains(p) {
par_rows(px, w, 1, |_, row| {
for q in row.iter_mut() {
for c in 0..3 {
q[c] = linear_to_srgb((srgb_to_linear(q[c]) * g[c]).max(0.0));
*q = balance(*q, g);
}
});
}
// 1b. Calibration: the primaries turned and purer or greyer, and the shadows tinted, in
// linear light (the camera's primaries are what Camera Raw's panel calibrates).
if !p.calibration_neutral() {
let primaries = p.calibration_hue.iter().chain(&p.calibration_sat).any(|v| *v != 0.0);
let tint = p.shadow_tint.clamp(-100.0, 100.0) / 100.0;
par_rows(px, w, 1, |_, row| {
for q in row.iter_mut() {
let c = [srgb_to_linear(q[0]), srgb_to_linear(q[1]), srgb_to_linear(q[2])];
let mut o = if primaries { calibrate(c, p.calibration_hue, p.calibration_sat) } else { c };
if tint != 0.0 {
o = shadow_tint(o, tint);
}
for k in 0..3 {
q[k] = linear_to_srgb(o[k].max(0.0));
}
}
});
@@ -606,7 +721,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
let clar = (p.clarity != 0.0).then(|| guided_fast(&lc, &lc, w, h, ((long * 0.02) as usize).max(3), 0.005));
let tex = (p.texture != 0.0).then(|| gauss(&lc, w, h, (long * 0.002).max(1.5 * ps)));
let k_con = p.contrast / 100.0 * 0.6;
let tau = std::f32::consts::TAU;
par_rows(px, w, 1, |y, row| {
for (x, q) in row.iter_mut().enumerate() {
let i = y * w + x;
@@ -619,22 +733,14 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
if let Some(t) = &tex {
lv += p.texture / 100.0 * 0.9 * (lc[i] - t[i]).clamp(-0.1, 0.1);
}
if k_con != 0.0 {
let xx = lv.clamp(0.0, 1.0);
lv += -k_con * (tau * xx).sin() / tau;
}
let lv = lv.max(0.0) + (l0 - lc[i]);
let ratio = if l0 > 1e-5 { lv / l0 } else { 1.0 };
for c in 0..3 {
q[c] = if l0 > 1e-5 { q[c] * ratio } else { q[c] + (lv - l0) };
}
*q = tone(*q, l0, lc[i], lv, k_con);
}
});
}
// 3b. Whites and Blacks: global curves adapted to the image's black and white points.
if p.whites != 0.0 || p.blacks != 0.0 {
let enc = Encoding::of(p.encoding_gamma);
let (bp, wp) = luminance_points(px, w, h, enc);
let (bp, wp) = luminance_points(px, w, h, enc, |q| q);
let (xw, xb) = whites_blacks::amounts(p.whites.clamp(-100.0, 100.0), p.blacks.clamp(-100.0, 100.0), bp, wp);
whites_blacks::apply(px, w, xw, xb, enc);
}
@@ -646,14 +752,7 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
let (master, cr, cg, cb) = (pts(&p.point_curve), pts(&p.point_curve_red), pts(&p.point_curve_green), pts(&p.point_curve_blue));
let hsl = !p.hsl_neutral();
let grade = !p.grade_neutral();
let wheel_tint = |wh: &Wheel| -> [f32; 3] {
let c = hsv_to_rgb(wh.hue, 1.0, 1.0);
let l = luma(c);
[c[0] - l, c[1] - l, c[2] - l].map(|v| v * wh.sat / 100.0 * 0.3)
};
let tints = [wheel_tint(&p.grade_shadows), wheel_tint(&p.grade_midtones), wheel_tint(&p.grade_highlights), wheel_tint(&p.grade_global)];
let blend = 0.15 + p.grade_blending.clamp(0.0, 100.0) / 100.0 * 0.35;
let bal = p.grade_balance.clamp(-100.0, 100.0) / 100.0 * 0.25;
let Grade { tints, lums, blend, balance: bal } = Grade::new(p);
par_rows(px, w, 1, |_, row| {
for q in row.iter_mut() {
let mut c = [q[0], q[1], q[2]];
@@ -706,7 +805,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
let hi = smoothstep(0.5 + bal - blend, 1.0, l);
let mid = (1.0 - sh - hi).max(0.0);
let masks = [sh, mid, hi, 1.0];
let lums = [p.grade_shadows.lum, p.grade_midtones.lum, p.grade_highlights.lum, p.grade_global.lum];
for k in 0..4 {
for ch in 0..3 {
c[ch] += masks[k] * tints[k][ch];
@@ -732,11 +830,7 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
vignette(px, w, h, p);
}
if p.grain_amount > 0.0 {
let cell = 1.0 + p.grain_size / 25.0;
let rough = p.grain_roughness / 100.0;
let amt = p.grain_amount / 100.0 * 0.12;
let seed = p.seed as u64;
let pscale = 1.0 / ps;
let Grain { cell, roughness: rough, amount: amt, seed, scale: pscale } = Grain::new(p);
par_rows(px, w, 1, |y, row| {
for (x, q) in row.iter_mut().enumerate() {
// Sample in full-resolution coordinates so previews match the result.
@@ -759,24 +853,60 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
}
}
/// The end of stage 3 on one pixel: Contrast (strength `k_con`) on the luminance `lv` that
/// Clarity and Texture left, then the pixel rescaled to it. `l0` is its luminance, `lc` that
/// clipped to 1.
fn tone(mut q: [f32; 4], l0: f32, lc: f32, mut lv: f32, k_con: f32) -> [f32; 4] {
if k_con != 0.0 {
let tau = std::f32::consts::TAU;
let xx = lv.clamp(0.0, 1.0);
lv += -k_con * (tau * xx).sin() / tau;
}
let lv = lv.max(0.0) + (l0 - lc);
let ratio = if l0 > 1e-5 { lv / l0 } else { 1.0 };
for c in 0..3 {
q[c] = if l0 > 1e-5 { q[c] * ratio } else { q[c] + (lv - l0) };
}
q
}
/// Stage 3 on one pixel when Contrast is its only control.
fn contrast(q: [f32; 4], k_con: f32) -> [f32; 4] {
let l0 = luma([q[0], q[1], q[2]]).max(0.0);
let lc = l0.min(1.0);
tone(q, l0, lc, lc, k_con)
}
/// Stage 1 on one pixel: white balance and exposure `gains` in linear light.
fn balance(mut q: [f32; 4], g: [f32; 3]) -> [f32; 4] {
for c in 0..3 {
q[c] = linear_to_srgb((srgb_to_linear(q[c]) * g[c]).max(0.0));
}
q
}
/// The image's black and white points as sRGB-encoded grey levels: the 3rd percentile of the
/// luminance and the 99.5th of the brightest channel, on a copy at most 256 pixels across (so a
/// preview proxy and the full image agree).
fn luminance_points(px: &[[f32; 4]], w: usize, h: usize, enc: Encoding) -> (f32, f32) {
/// preview proxy and the full image agree). `map` is applied to each pixel first.
fn luminance_points(px: &[[f32; 4]], w: usize, h: usize, enc: Encoding, map: impl Fn([f32; 4]) -> [f32; 4] + Sync) -> (f32, f32) {
let f = w.max(h).div_ceil(256).max(1);
let (sw, sh) = (w.div_ceil(f), h.div_ceil(f));
let mut sum = vec![[0.0f64; 4]; sw * sh];
for y in 0..h {
for x in 0..w {
let q = px[y * w + x];
let s = &mut sum[(y / f) * sw + x / f];
for c in 0..3 {
s[c] += f64::from(enc.decode(q[c]).clamp(0.0, 1.0));
// Each band of `f` rows sums into its own row of cells, in the same order as one pass would.
let bands = crate::photo_util::par_map(sh, |sy| {
let mut sum = vec![[0.0f64; 4]; sw];
for y in sy * f..((sy + 1) * f).min(h) {
for x in 0..w {
let q = map(px[y * w + x]);
let s = &mut sum[x / f];
for c in 0..3 {
s[c] += f64::from(enc.decode(q[c]).clamp(0.0, 1.0));
}
s[3] += 1.0;
}
s[3] += 1.0;
}
}
let cells: Vec<[f32; 3]> = sum.iter().filter(|s| s[3] > 0.0).map(|s| [0, 1, 2].map(|c| (s[c] / s[3]) as f32)).collect();
sum
});
let cells: Vec<[f32; 3]> = bands.iter().flatten().filter(|s| s[3] > 0.0).map(|s| [0, 1, 2].map(|c| (s[c] / s[3]) as f32)).collect();
if cells.is_empty() {
return (0.0, 1.0);
}
@@ -933,24 +1063,7 @@ fn detail(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, ps: f32) {
}
fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) {
let amt = p.vignette_amount / 100.0;
let mid = p.vignette_midpoint.clamp(0.0, 100.0) / 100.0;
let feather = p.vignette_feather.clamp(0.0, 100.0) / 100.0;
let round = p.vignette_roundness.clamp(-100.0, 100.0) / 100.0;
let hl = p.vignette_highlights.clamp(0.0, 100.0) / 100.0;
let style = p.vignette_style.as_str();
let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0);
// Roundness: 0 follows the frame's aspect, +1 a circle, −1 a rounded rectangle.
let (ax, ay) = if round >= 0.0 {
let r = cx.min(cy) + (cx.max(cy) - cx.min(cy)) * (1.0 - round);
(if cx >= cy { r } else { cx }, if cy > cx { r } else { cy })
} else {
(cx, cy)
};
let pw = 2.0 + (-round).max(0.0) * 6.0;
let inner = 1.0 - mid * 0.8;
let edge0 = inner - feather * 0.5 * inner;
let edge1 = inner + feather * 0.5 * (1.4 - inner) + 0.02;
let Vignette { amount: amt, highlights: hl, style, centre: [cx, cy], axes: [ax, ay], power: pw, edges: [edge0, edge1] } = Vignette::new(w, h, p);
par_rows(px, w, 1, |y, row| {
for (x, q) in row.iter_mut().enumerate() {
let (dx, dy) = (((x as f32 + 0.5 - cx) / ax).abs(), ((y as f32 + 0.5 - cy) / ay).abs());
@@ -961,7 +1074,7 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) {
}
let l = luma([q[0], q[1], q[2]]).clamp(0.0, 1.0);
match style {
"paintOverlay" => {
VignetteStyle::PaintOverlay => {
let target = if amt < 0.0 { 0.0 } else { 1.0 };
let k = m * amt.abs();
for c in 0..3 {
@@ -975,7 +1088,7 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) {
let protect = smoothstep(0.6, 1.0, l) * hl;
k += (1.0 - k) * protect;
}
if style == "colorPriority" {
if style == VignetteStyle::ColorPriority {
let nl = l * k;
for c in 0..3 {
q[c] += nl - l;
@@ -1185,6 +1298,105 @@ mod tests {
assert!(v[0][0] < 0.35 && (v[(h / 2) * w + w / 2][0] - 0.5).abs() < 0.01, "{:?}", v[0]);
}
#[test]
fn calibration_keeps_the_develop_off_the_per_pixel_gpu_stages() {
// The per-pixel stages (the Camera Raw dialog's GPU preview) don't run Calibration, so
// settings with it must fall back to the CPU develop instead of previewing without it.
for p in [
CameraRaw { shadow_tint: 40.0, ..Default::default() },
CameraRaw { calibration_hue: [0.0, 0.0, 30.0], whites: 20.0, ..Default::default() },
CameraRaw { calibration_sat: [-50.0, 0.0, 0.0], exposure: 0.5, ..Default::default() },
] {
assert!(!p.spatial());
assert!(per_pixel(&p, 2, 2, false, Some((0.0, 1.0))).is_none(), "{p:?}");
}
assert!(per_pixel(&CameraRaw { exposure: 0.5, ..Default::default() }, 2, 2, false, None).is_some());
}
#[test]
fn calibration_turns_primaries_keeps_neutrals_and_tints_shadows() {
let w = calibration_weights(0.0);
assert!((w[0] - 1.0).abs() < 1e-6, "pure red is all red primary: {w:?}");
let w = calibration_weights(60.0);
assert!((w[0] - 0.5).abs() < 1e-6 && (w[1] - 0.5).abs() < 1e-6, "yellow is half red, half green: {w:?}");
let w = calibration_weights(300.0);
assert!((w[2] - 0.5).abs() < 1e-6 && (w[0] - 0.5).abs() < 1e-6, "magenta wraps round: {w:?}");
let (none, zero) = ([0.0f32; 3], [0.0f32; 3]);
let grey = calibrate([0.3, 0.3, 0.3], [100.0, -100.0, 50.0], [100.0, -100.0, 100.0]);
assert!(grey.iter().all(|v| (v - 0.3).abs() < 1e-5), "greys are untouched: {grey:?}");
// Red turned towards green: it gains green, keeps its luminance.
let red = calibrate([0.5, 0.0, 0.0], [100.0, 0.0, 0.0], zero);
assert!(red[1] > 0.05 && (red[1] / red[0] - 0.5).abs() < 0.02 && red[2] < 1e-6, "red turns 30° towards green (orange): {red:?}");
assert!((luma(red) - luma([0.5, 0.0, 0.0])).abs() < 1e-4, "luminance kept: {red:?}");
// Blue at saturation −100 is grey; green at −50 is paler.
let blue = calibrate([0.0, 0.0, 0.8], none, [0.0, 0.0, -100.0]);
assert!((blue[0] - blue[1]).abs() < 1e-5 && (blue[1] - blue[2]).abs() < 1e-5, "blue at −100 saturation is grey: {blue:?}");
let green = calibrate([0.2, 0.8, 0.2], none, [0.0, -50.0, 0.0]);
assert!(green[0] > 0.2 && green[1] < 0.8, "green at −50 is paler: {green:?}");
// A primary's sliders leave the other primaries alone.
let unchanged = calibrate([0.0, 0.0, 0.8], [100.0, 100.0, 0.0], [100.0, 100.0, 0.0]);
assert!(unchanged.iter().zip([0.0, 0.0, 0.8]).all(|(a, b)| (a - b).abs() < 1e-5), "{unchanged:?}");
// Shadows tint: magenta lifts red and blue over green in a dark pixel, keeps luminance,
// and leaves white alone.
let dark = shadow_tint([0.02, 0.02, 0.02], 1.0);
assert!(dark[0] > dark[1] && dark[2] > dark[1], "{dark:?}");
assert!((luma(dark) - 0.02).abs() < 1e-4, "luminance kept: {dark:?}");
let green = shadow_tint([0.02, 0.02, 0.02], -1.0);
assert!(green[1] > green[0], "{green:?}");
let white = shadow_tint([1.0, 1.0, 1.0], 1.0);
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-3), "white is not tinted: {white:?}");
// Through develop: neutral settings are an identity, a tint changes dark pixels only.
let mut p = CameraRaw::default();
assert!(p.is_identity());
p.shadow_tint = 100.0;
assert!(!p.is_identity());
let mut px = vec![[0.1, 0.1, 0.1, 1.0], [0.95, 0.95, 0.95, 1.0]];
develop(&mut px, 2, 1, &p, false);
assert!(px[0][0] > px[0][1], "dark grey turns magenta: {:?}", px[0]);
assert!((px[1][0] - px[1][1]).abs() < 0.01, "near-white stays neutral: {:?}", px[1]);
p = CameraRaw::default();
p.calibration_hue[2] = 100.0;
let mut px = vec![[0.0, 0.0, 0.8, 1.0]];
develop(&mut px, 1, 1, &p, false);
assert!(px[0][0] > 0.05, "blue turned towards red: {:?}", px[0]);
}
#[test]
fn auto_tone_brightens_dark_and_darkens_bright_images() {
let mut p = CameraRaw::default();
auto_tone(&[], &mut p);
assert_eq!(p, CameraRaw::default(), "no pixels: nothing changes");
let dark: Vec<[f32; 4]> = (0..4096)
.map(|i| {
let v = 0.05 + (i % 64) as f32 / 64.0 * 0.15;
[v, v, v, 1.0]
})
.collect();
auto_tone(&dark, &mut p);
assert!(p.exposure > 0.5, "a dark image gets exposure: {}", p.exposure);
assert!(p.shadows > 0.0, "and lifted shadows: {}", p.shadows);
let mut p = CameraRaw::default();
let bright: Vec<[f32; 4]> = (0..4096)
.map(|i| {
let v = 0.8 + (i % 64) as f32 / 64.0 * 0.19;
[v, v, v, 1.0]
})
.collect();
auto_tone(&bright, &mut p);
assert!(p.exposure < -0.5, "a bright image loses exposure: {}", p.exposure);
assert!(p.highlights < 0.0, "and its highlights come down: {}", p.highlights);
let mut p = CameraRaw::default();
let good: Vec<[f32; 4]> = (0..4096)
.map(|i| {
let v = 0.04 + (i % 64) as f32 / 63.0 * 0.92;
[v, v, v, 1.0]
})
.collect();
auto_tone(&good, &mut p);
assert!(p.exposure.abs() < 0.3 && p.blacks.abs() < 10.0 && p.whites.abs() < 10.0, "a full-range image is left nearly alone: {p:?}");
assert!(p.saturation == 0.0 && p.hsl_neutral(), "other panels untouched");
}
#[test]
fn float_documents_keep_overrange() {
let mut a = vec![[2.0f32, 1.5, 0.5, 1.0]; 16];
+240
View File
@@ -0,0 +1,240 @@
//! Develop's per-pixel stages resolved for one image: every constant and table they use, so
//! another implementation of them (the GPU preview, `photocraft_gpu::CameraRawDevelop`) runs the
//! same maths. [`develop`](super::develop) builds its own constants with the same functions.
use super::{CameraRaw, Encoding, Wheel, curve_lut, hsv_to_rgb, is_linear, luma, parametric_lut, white_balance_gains, whites_blacks};
/// Entries of the tone curve tables (parametric and point curves).
pub const CURVE_LUT: usize = 1024;
/// Samples of a Whites or Blacks curve row: grey levels 0, 1/32, …, 1.
pub const WB_STEPS: usize = whites_blacks::STEPS;
/// White balance and exposure: channel gains in linear light, when the stage is on.
pub(crate) fn gains(p: &CameraRaw) -> Option<[f32; 3]> {
if p.wb_neutral() {
return None;
}
let g = white_balance_gains(p.temperature, p.tint);
let norm = luma(g);
Some(g.map(|v| v / norm * 2f32.powf(p.exposure)))
}
/// Contrast's strength, when Contrast is set.
fn contrast(p: &CameraRaw) -> Option<f32> {
(p.contrast != 0.0).then_some(p.contrast / 100.0 * 0.6)
}
/// Whites and Blacks as two curve rows for the image's amounts.
#[derive(Clone, Debug, PartialEq)]
pub struct WhitesBlacks {
/// The Whites curve over grey levels 0, 1/32, …, 1, when Whites is set.
pub whites: Option<[f32; WB_STEPS]>,
/// The Blacks curve, applied after Whites.
pub blacks: Option<[f32; WB_STEPS]>,
/// The clipping directions (Whites up, Blacks down) curve each channel; the others scale the
/// pixel by the change of its luminance.
pub per_channel: bool,
}
/// Color Grading's wheels resolved.
#[derive(Clone, Debug, PartialEq)]
pub struct Grade {
/// Colour offsets of the shadows, midtones, highlights and global wheels.
pub tints: [[f32; 3]; 4],
/// Luminance of the same wheels (slider units).
pub lums: [f32; 4],
pub blend: f32,
pub balance: f32,
}
impl Grade {
pub(crate) fn new(p: &CameraRaw) -> Self {
let tint = |wh: &Wheel| -> [f32; 3] {
let c = hsv_to_rgb(wh.hue, 1.0, 1.0);
let l = luma(c);
[c[0] - l, c[1] - l, c[2] - l].map(|v| v * wh.sat / 100.0 * 0.3)
};
Grade {
tints: [tint(&p.grade_shadows), tint(&p.grade_midtones), tint(&p.grade_highlights), tint(&p.grade_global)],
lums: [p.grade_shadows.lum, p.grade_midtones.lum, p.grade_highlights.lum, p.grade_global.lum],
blend: 0.15 + p.grade_blending.clamp(0.0, 100.0) / 100.0 * 0.35,
balance: p.grade_balance.clamp(-100.0, 100.0) / 100.0 * 0.25,
}
}
}
/// The vignette's `vignette_style`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VignetteStyle {
HighlightPriority,
ColorPriority,
PaintOverlay,
}
/// The post-crop vignette's shape on a `w × h` frame.
#[derive(Clone, Debug, PartialEq)]
pub struct Vignette {
/// −1 … 1.
pub amount: f32,
/// 0 … 1.
pub highlights: f32,
pub style: VignetteStyle,
/// Frame centre (pixels).
pub centre: [f32; 2],
/// Half axes of the shape (pixels).
pub axes: [f32; 2],
/// Exponent of the superellipse.
pub power: f32,
/// The mask's smoothstep edges in normalised radius.
pub edges: [f32; 2],
}
impl Vignette {
pub(crate) fn new(w: usize, h: usize, p: &CameraRaw) -> Self {
let mid = p.vignette_midpoint.clamp(0.0, 100.0) / 100.0;
let feather = p.vignette_feather.clamp(0.0, 100.0) / 100.0;
let round = p.vignette_roundness.clamp(-100.0, 100.0) / 100.0;
let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0);
// Roundness: 0 follows the frame's aspect, +1 a circle, −1 a rounded rectangle.
let (ax, ay) = if round >= 0.0 {
let r = cx.min(cy) + (cx.max(cy) - cx.min(cy)) * (1.0 - round);
(if cx >= cy { r } else { cx }, if cy > cx { r } else { cy })
} else {
(cx, cy)
};
let inner = 1.0 - mid * 0.8;
Vignette {
amount: p.vignette_amount / 100.0,
highlights: p.vignette_highlights.clamp(0.0, 100.0) / 100.0,
style: match p.vignette_style.as_str() {
"paintOverlay" => VignetteStyle::PaintOverlay,
"colorPriority" => VignetteStyle::ColorPriority,
_ => VignetteStyle::HighlightPriority,
},
centre: [cx, cy],
axes: [ax, ay],
power: 2.0 + (-round).max(0.0) * 6.0,
edges: [inner - feather * 0.5 * inner, inner + feather * 0.5 * (1.4 - inner) + 0.02],
}
}
}
/// Grain: value noise sampled in full-resolution coordinates.
#[derive(Clone, Debug, PartialEq)]
pub struct Grain {
/// Noise cell size (full-resolution pixels).
pub cell: f32,
/// Weight of the finer octave.
pub roughness: f32,
pub amount: f32,
pub seed: u64,
/// Full-resolution pixels per pixel (`1 / pixel_scale`).
pub scale: f32,
}
impl Grain {
pub(crate) fn new(p: &CameraRaw) -> Self {
Grain {
cell: 1.0 + p.grain_size / 25.0,
roughness: p.grain_roughness / 100.0,
amount: p.grain_amount / 100.0 * 0.12,
seed: u64::from(p.seed),
scale: 1.0 / p.pixel_scale.clamp(0.01, 1.0),
}
}
}
/// Develop's per-pixel stages for one image and settings, in order. `None` stages are off.
#[derive(Clone, Debug, PartialEq)]
pub struct PerPixel {
/// 1. White balance and exposure gains in linear light (sRGB-decoded values).
pub gains: Option<[f32; 3]>,
/// 3. Contrast's strength (Contrast without Texture or Clarity is per pixel).
pub contrast: Option<f32>,
/// 3b. Whites and Blacks, in the document's encoding.
pub whites_blacks: Option<WhitesBlacks>,
/// The document's transfer curve: 0 for sRGB's, else a power-law gamma.
pub encoding: f32,
/// 4. Vibrance and Saturation (slider / 100).
pub presence: Option<[f32; 2]>,
/// 5. The parametric tone curve, [`CURVE_LUT`] entries over 0 … 1.
pub parametric: Option<Vec<f32>>,
/// 5. Point curves: master, red, green, blue.
pub point: [Option<Vec<f32>>; 4],
/// 6. Color Mixer hue, saturation and luminance per band.
pub hsl: Option<[[f32; 8]; 3]>,
/// 7. Color Grading.
pub grade: Option<Grade>,
/// 9. Vignette.
pub vignette: Option<Vignette>,
/// 9. Grain.
pub grain: Option<Grain>,
/// Clip RGB to 0 … 1 at the end (all but 32-bit documents).
pub clamp: bool,
}
impl CameraRaw {
/// Whether developing needs a stage that reads neighbouring pixels: Dehaze, Shadows and
/// Highlights, Texture, Clarity, sharpening or noise reduction.
pub fn spatial(&self) -> bool {
self.dehaze != 0.0
|| [self.highlights, self.shadows, self.texture, self.clarity].iter().any(|v| *v != 0.0)
|| self.sharpen_amount > 0.0
|| self.noise_luminance > 0.0
|| self.noise_color > 0.0
}
}
/// The image's black and white points that Whites and Blacks adapt to, for `px` (`w × h`,
/// display-encoded) developed per pixel with `p`: what [`develop`](super::develop) measures
/// after white balance and Contrast. `None` when Whites and Blacks are 0 or `p` needs a
/// spatial stage.
pub fn white_black_points(px: &[[f32; 4]], w: usize, h: usize, p: &CameraRaw) -> Option<(f32, f32)> {
if (p.whites == 0.0 && p.blacks == 0.0) || p.spatial() || px.len() != w.saturating_mul(h) {
return None;
}
let enc = Encoding::of(p.encoding_gamma);
let (g, k_con) = (gains(p), contrast(p));
Some(super::luminance_points(px, w, h, enc, |q| {
let q = g.map_or(q, |g| super::balance(q, g));
k_con.map_or(q, |k| super::contrast(q, k))
}))
}
/// Develop's stages for settings without spatial stages, on a `w × h` image; `points` are the
/// image's [`white_black_points`]. `None` when `p` needs a spatial stage or Calibration (these
/// stages don't run it), or Whites or Blacks is set without `points`. The points themselves
/// leave Calibration out, so a caller may cache them across Calibration changes.
pub fn per_pixel(p: &CameraRaw, w: usize, h: usize, float: bool, points: Option<(f32, f32)>) -> Option<PerPixel> {
if p.spatial() || !p.calibration_neutral() {
return None;
}
let identity = p.is_identity();
let whites_blacks = if p.whites != 0.0 || p.blacks != 0.0 {
let (bp, wp) = points?;
let (xw, xb) = whites_blacks::amounts(p.whites.clamp(-100.0, 100.0), p.blacks.clamp(-100.0, 100.0), bp, wp);
(xw != 0.0 || xb != 0.0).then(|| WhitesBlacks {
whites: (xw != 0.0).then(|| whites_blacks::whites_row(xw)),
blacks: (xb != 0.0).then(|| whites_blacks::blacks_row(xb)),
per_channel: xw > 0.0 || xb < 0.0,
})
} else {
None
};
let parametric_on = !(p.curve_highlights == 0.0 && p.curve_lights == 0.0 && p.curve_darks == 0.0 && p.curve_shadows == 0.0);
let pts = |c: &Vec<[f32; 2]>| (!is_linear(c)).then(|| curve_lut(c, CURVE_LUT));
Some(PerPixel {
gains: gains(p),
contrast: contrast(p),
whites_blacks,
encoding: Encoding::of(p.encoding_gamma).0,
presence: (p.vibrance != 0.0 || p.saturation != 0.0).then_some([p.vibrance / 100.0, p.saturation / 100.0]),
parametric: parametric_on.then(|| parametric_lut(p, CURVE_LUT)),
point: [pts(&p.point_curve), pts(&p.point_curve_red), pts(&p.point_curve_green), pts(&p.point_curve_blue)],
hsl: (!p.hsl_neutral()).then_some([p.hsl_hue, p.hsl_sat, p.hsl_lum]),
grade: (!p.grade_neutral()).then(|| Grade::new(p)),
vignette: (p.vignette_amount != 0.0).then(|| Vignette::new(w, h, p)),
grain: (p.grain_amount > 0.0).then(|| Grain::new(p)),
clamp: !float && !identity,
})
}
+20 -1
View File
@@ -24,7 +24,7 @@ use super::Encoding;
use crate::photo_util::par_rows;
/// Grey levels the curves are sampled at: 0, 1/32, …, 1 (sRGB-encoded).
const STEPS: usize = 33;
pub(crate) const STEPS: usize = 33;
/// The first row's internal amount; rows are 10 apart.
const WHITES_FROM: f32 = -100.0;
const BLACKS_FROM: f32 = -100.0;
@@ -315,6 +315,25 @@ fn curve(rows: &[[f32; STEPS]], from: f32, x: f32, v: f32) -> f32 {
at(&rows[i]) * (1.0 - t) + at(&rows[i + 1]) * t
}
/// The curve at a fractional internal amount `x` as one row: `curve(rows, from, x, v)` is this
/// row interpolated at `v`, because both interpolations are linear.
fn row_at(rows: &[[f32; STEPS]], from: f32, x: f32) -> [f32; STEPS] {
let r = ((x - from) / 10.0).clamp(0.0, (rows.len() - 1) as f32);
let i = (r as usize).min(rows.len() - 2);
let t = r - i as f32;
std::array::from_fn(|j| rows[i][j] * (1.0 - t) + rows[i + 1][j] * t)
}
/// The Whites curve at internal amount `x` as a row over grey levels 0, 1/32, …, 1.
pub(crate) fn whites_row(x: f32) -> [f32; STEPS] {
row_at(&WHITES, WHITES_FROM, x)
}
/// The Blacks curve at internal amount `x` as a row over grey levels 0, 1/32, …, 1.
pub(crate) fn blacks_row(x: f32) -> [f32; STEPS] {
row_at(&BLACKS, BLACKS_FROM, x)
}
/// Whites at internal amount `x` (−100 … 270) of grey level `v`.
pub(crate) fn whites(v: f32, x: f32) -> f32 {
curve(&WHITES, WHITES_FROM, x, v)
+1
View File
@@ -18,6 +18,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod art_history;
mod artistic;
mod artistic_fx;
mod blur;
+48 -18
View File
@@ -5,6 +5,14 @@ use photocraft_geom::Rect;
use photocraft_raster::{Surface, from_rgba_into};
use serde::{Deserialize, Serialize};
/// How a fill lays down its colour: blended with a mode, or Clear, which erases what it covers
/// (the Paint Bucket's Mode › Clear).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FillMode {
Blend(BlendMode),
Clear,
}
/// Gradient tool shape.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -59,7 +67,7 @@ pub fn sample_stops(stops: &[(f32, [f32; 4])], t: f32) -> [f32; 4] {
fn composite_area(
s: &mut Surface,
area: Rect,
blend_mode: BlendMode,
mode: FillMode,
coverage: impl Fn(i32, i32) -> f32 + Sync,
color: impl Fn(i32, i32) -> [f32; 4] + Sync,
lock_transparency: bool,
@@ -89,12 +97,18 @@ fn composite_area(
let i = ((y - r.y0) as usize * w + (x - r.x0) as usize) * n;
let px = &mut region[i..i + n];
let d = photocraft_raster::to_rgba(&fmt, px);
let o = if lock_transparency { blend::composite_atop(blend_mode, d, color(x, y), k) } else { blend::composite(blend_mode, d, color(x, y), k) };
let o = match mode {
FillMode::Blend(m) if lock_transparency => blend::composite_atop(m, d, color(x, y), k),
FillMode::Blend(m) => blend::composite(m, d, color(x, y), k),
// Locked transparency keeps the alpha, so Clear leaves the pixel as it is.
FillMode::Clear if lock_transparency => d,
FillMode::Clear => [d[0], d[1], d[2], d[3] * (1.0 - k.min(1.0))],
};
from_rgba_into(&fmt, o, &mut enc);
// A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is.
if let Some(i) = inks
&& fmt.mode == photocraft_color::ColorMode::Cmyk
&& blend_mode == BlendMode::Normal
&& mode == FillMode::Blend(BlendMode::Normal)
&& k >= 1.0
{
enc[..4].copy_from_slice(&i);
@@ -135,7 +149,7 @@ pub fn paint_gradient(
composite_area(
s,
area,
blend_mode,
FillMode::Blend(blend_mode),
|x, y| opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)),
|x, y| {
let t = tool_gradient_t(shape, from, to, x as f32 + 0.5, y as f32 + 0.5);
@@ -187,11 +201,12 @@ pub fn bucket_fill_src(
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
) -> bool {
bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None)
bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None, FillMode::Blend(BlendMode::Normal))
}
/// [`bucket_fill_src`] where `inks` (CMYK, 0..1) are what a fully covered pixel of a CMYK target
/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK).
/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK), laid down with `mode`
/// (the options bar's Mode).
#[allow(clippy::too_many_arguments)]
pub fn bucket_fill_inks(
s: &mut Surface,
@@ -205,6 +220,7 @@ pub fn bucket_fill_inks(
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
inks: Option<[f32; 4]>,
mode: FillMode,
) -> bool {
if !area.contains(seed.0, seed.1) {
return false;
@@ -212,7 +228,7 @@ pub fn bucket_fill_inks(
let img = crate::selection::rgba8_image(s, area);
let Some(region) = crate::selection::wand_region(&img, area, seed, tolerance, contiguous, anti_alias) else { return false };
drop(img);
bucket_fill_region_inks(s, &region, opacity, selection, lock_transparency, src, inks);
bucket_fill_region_inks(s, &region, opacity, selection, lock_transparency, src, inks, mode);
true
}
@@ -226,10 +242,11 @@ pub fn bucket_fill_region_src(
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
) {
bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None);
bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None, FillMode::Blend(BlendMode::Normal));
}
/// [`bucket_fill_region_src`] with the CMYK `inks` of [`bucket_fill_inks`].
/// [`bucket_fill_region_src`] with the CMYK `inks` and the `mode` of [`bucket_fill_inks`].
#[allow(clippy::too_many_arguments)]
pub fn bucket_fill_region_inks(
s: &mut Surface,
region: &crate::selection::Region,
@@ -238,18 +255,11 @@ pub fn bucket_fill_region_inks(
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
inks: Option<[f32; 4]>,
mode: FillMode,
) {
// Composite only over the filled region's box.
let b = region.bbox;
composite_area(
s,
b,
BlendMode::Normal,
|x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)),
src,
lock_transparency,
inks,
);
composite_area(s, b, mode, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, lock_transparency, inks);
}
#[cfg(test)]
@@ -333,4 +343,24 @@ mod tests {
assert_eq!(s.rgba(-1, 4), [0.0; 4], "outside the selection");
assert_eq!(s.rgba(-4, 4), [0.0; 4], "outside the region");
}
/// The Paint Bucket's Mode: Multiply darkens what's there, Clear erases it (half at 50 %).
#[test]
fn bucket_mode_blends_or_clears() {
let a = Rect::new(0, 0, 4, 1);
let grey = [0.5, 0.5, 0.5, 1.0];
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
let mut s = Surface::new(fmt);
s.fill_rect(a, &grey);
let red = |_, _| [1.0, 0.0, 0.0, 1.0];
assert!(bucket_fill_inks(&mut s, a, (0, 0), 0.0, true, false, 1.0, None, false, red, None, FillMode::Blend(BlendMode::Multiply)));
let px = s.rgba(1, 0);
assert!((px[0] - 0.5).abs() < 0.01 && px[1] < 0.01 && (px[3] - 1.0).abs() < 1e-3, "{fmt:?} multiply: {px:?}");
assert!(bucket_fill_inks(&mut s, a, (0, 0), 0.0, true, false, 0.5, None, false, red, None, FillMode::Clear));
assert!((s.rgba(1, 0)[3] - 0.5).abs() < 0.01, "{fmt:?} clear at 50 %");
// Locked transparency keeps the alpha, so Clear does nothing.
assert!(bucket_fill_inks(&mut s, a, (0, 0), 0.0, true, false, 1.0, None, true, red, None, FillMode::Clear));
assert!((s.rgba(1, 0)[3] - 0.5).abs() < 0.01, "{fmt:?} locked");
}
}
}
+627 -230
View File
File diff suppressed because it is too large Load Diff
+147 -18
View File
@@ -324,33 +324,114 @@ pub fn paste(scene: &Scene, i: usize, src: &Surface, src_rect: Rect, at: [f64; 2
Ok(Some(out))
}
/// The fixed frame of one clone stroke: the source and destination planes, the source offset in
/// plane-metric units and the brush radius, all set at the stroke's anchor (its first dab).
struct CloneFrame {
hs: Homography,
ls: (f64, f64),
hd: Homography,
hd_inv: Homography,
ld: (f64, f64),
offset: [f64; 2],
r_m: f64,
}
impl CloneFrame {
fn new(scene: &Scene, source: [f64; 2], anchor: [f64; 2], size: f64) -> Option<CloneFrame> {
let (ps, pd) = (scene.plane_at(source)?, scene.plane_at(anchor)?);
let ((hs, lus, lvs), (hd, lud, lvd)) = (scene.frame(ps)?, scene.frame(pd)?);
let (hs_inv, hd_inv) = (hs.inverse()?, hd.inverse()?);
let ms = metric(&hs_inv, (lus, lvs), source);
let md0 = metric(&hd_inv, (lud, lvd), anchor);
// Brush radius in metric units (from the image size at the anchor).
let probe = metric(&hd_inv, (lud, lvd), [anchor[0] + size / 2.0, anchor[1]]);
let r_m = ((probe[0] - md0[0]).hypot(probe[1] - md0[1])).max(1e-9);
let offset = [ms[0] - md0[0], ms[1] - md0[1]];
(offset.iter().all(|v| v.is_finite()) && r_m.is_finite()).then_some(CloneFrame { hs, ls: (lus, lvs), hd, hd_inv, ld: (lud, lvd), offset, r_m })
}
/// The source point (image px) that destination `p` copies from.
fn source_of(&self, p: [f64; 2]) -> [f64; 2] {
let m = metric(&self.hd_inv, self.ld, p);
let (x, y) = self.hs.apply((m[0] + self.offset[0]) / self.ls.0, (m[1] + self.offset[1]) / self.ls.1);
[x, y]
}
}
/// Image point → plane-metric coordinates through the plane's inverse homography.
fn metric(inv: &Homography, l: (f64, f64), p: [f64; 2]) -> [f64; 2] {
let (u, v) = inv.apply(p[0], p[1]);
[u * l.0, v * l.1]
}
/// Perspective clone stamp: one stroke of dabs at image points `points` on the destination
/// plane, copying from `source` (image px) with the offset fixed in plane-metric units at the
/// first dab. `size` is the brush diameter in px at the first dab; `hardness` 0..=1.
pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: &[[f64; 2]], size: f64, hardness: f64, opacity: f32) -> usize {
let Some(first) = points.first() else { return 0 };
let (Some(ps), Some(pd)) = (scene.plane_at(source), scene.plane_at(*first)) else { return 0 };
let (Some((hs, lus, lvs)), Some((hd, lud, lvd))) = (scene.frame(ps), scene.frame(pd)) else { return 0 };
let (Some(hs_inv), Some(hd_inv)) = (hs.inverse(), hd.inverse()) else { return 0 };
let metric = |inv: &Homography, lu: f64, lv: f64, p: [f64; 2]| {
let (u, v) = inv.apply(p[0], p[1]);
[u * lu, v * lv]
};
let ms = metric(&hs_inv, lus, lvs, source);
let md0 = metric(&hd_inv, lud, lvd, *first);
let offset = [ms[0] - md0[0], ms[1] - md0[1]];
// Brush radius in metric units (from the image size at the first dab).
let probe = metric(&hd_inv, lud, lvd, [first[0] + size / 2.0, first[1]]);
let r_m = ((probe[0] - md0[0]).hypot(probe[1] - md0[1])).max(1e-9);
clone_dabs(scene, surf, source, *first, points, size, hardness, opacity, None).0
}
/// Where a clone stroke that starts at `anchor` copying from `source` samples for destination
/// `p`: the same plane-metric offset. A live preview paints a stroke in pieces with it, and
/// Aligned strokes keep the first stroke's offset. `None` when `source` or `anchor` is off the
/// planes.
pub fn clone_source(scene: &Scene, source: [f64; 2], anchor: [f64; 2], p: [f64; 2]) -> Option<[f64; 2]> {
let s = CloneFrame::new(scene, source, anchor, 1.0)?.source_of(p);
s.iter().all(|v| v.is_finite()).then_some(s)
}
/// [`clone_stroke`]'s dabs at `points` for a stroke anchored at `anchor` (its first dab, which
/// fixes the offset and the brush's metric size). Samples a snapshot taken at this call.
/// For incremental painting use [`clone_dabs_from`] with one snapshot per stroke.
/// With `clip`, every dab is cut to it before any read or write (a document passes its canvas,
/// so far off-canvas points allocate no tiles). Returns the dabs painted and the area they changed.
#[allow(clippy::too_many_arguments)]
pub fn clone_dabs(
scene: &Scene,
surf: &mut Surface,
source: [f64; 2],
anchor: [f64; 2],
points: &[[f64; 2]],
size: f64,
hardness: f64,
opacity: f32,
clip: Option<Rect>,
) -> (usize, Rect) {
let src_copy = surf.clone();
clone_dabs_from(scene, surf, &src_copy, source, anchor, points, size, hardness, opacity, clip)
}
/// Paint a batch of one stroke using the same immutable source snapshot for every batch.
/// Source and destination must have the same pixel format; otherwise no dabs are painted.
/// `clip` as in [`clone_dabs`].
#[allow(clippy::too_many_arguments)]
pub fn clone_dabs_from(
scene: &Scene,
surf: &mut Surface,
src_copy: &Surface,
source: [f64; 2],
anchor: [f64; 2],
points: &[[f64; 2]],
size: f64,
hardness: f64,
opacity: f32,
clip: Option<Rect>,
) -> (usize, Rect) {
if surf.format() != src_copy.format() {
return (0, Rect::EMPTY);
}
let Some(f) = CloneFrame::new(scene, source, anchor, size) else { return (0, Rect::EMPTY) };
let CloneFrame { hs, ls: (lus, lvs), hd, hd_inv, ld: (lud, lvd), offset, r_m } = f;
let fmt = surf.format();
let n = fmt.channels();
// Scratch for `sample`: the dab's source pixels, the four bilinear corners, the result.
let mut win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() };
let (mut corners, mut s) = (vec![0.0f32; 4 * n], vec![0.0f32; n]);
let mut dabs = 0;
let mut changed_area = Rect::EMPTY;
for p in points {
let mc = metric(&hd_inv, lud, lvd, *p);
let mc = metric(&hd_inv, (lud, lvd), *p);
// Image-space footprint: map a metric circle's bounding points.
let footprint = |h: &Homography, lu: f64, lv: f64, c: [f64; 2]| {
let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
@@ -359,12 +440,19 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
let (x, y) = h.apply((c[0] + r_m * a.cos()) / lu, (c[1] + r_m * a.sin()) / lv);
bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)];
}
if bb.iter().any(|v| !v.is_finite()) {
return Rect::EMPTY;
}
Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1))
};
let area = footprint(&hd, lud, lvd, mc);
if area.is_empty() || area.width() > 4096 || area.height() > 4096 {
continue;
}
let area = clip.map_or(area, |c| area.intersect(&c));
if area.is_empty() {
continue;
}
// The source footprint, read once; `sample` reads any pixel outside it from the surface.
// Skipped when it dwarfs the destination (cloning from near to far in strong perspective):
// decoding it would cost more than the few per-pixel reads it saves.
@@ -385,9 +473,9 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
let mut changed = false;
for (i, px) in data.chunks_exact_mut(n).enumerate() {
let (x, y) = (area.x0 as f64 + (i % w) as f64 + 0.5, area.y0 as f64 + (i / w) as f64 + 0.5);
let m = metric(&hd_inv, lud, lvd, [x, y]);
let m = metric(&hd_inv, (lud, lvd), [x, y]);
let d = (m[0] - mc[0]).hypot(m[1] - mc[1]) / r_m;
if d >= 1.0 {
if d >= 1.0 || d.is_nan() {
continue;
}
let hard = hardness.clamp(0.0, 0.99);
@@ -396,7 +484,7 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
// Source pixel at the same metric offset.
let sm = [m[0] + offset[0], m[1] + offset[1]];
let (sx, sy) = hs.apply(sm[0] / lus, sm[1] / lvs);
sample(&src_copy, &win, sx - 0.5, sy - 0.5, &mut corners, &mut s);
sample(src_copy, &win, sx - 0.5, sy - 0.5, &mut corners, &mut s);
for c in 0..n {
px[c] += (s[c] - px[c]) * k;
}
@@ -405,9 +493,10 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
if changed {
surf.write_region(area, &data);
dabs += 1;
changed_area = if changed_area.is_empty() { area } else { changed_area.union(&area) };
}
}
dabs
(dabs, changed_area)
}
/// Pixels of `area` read from the source surface (`read_region` layout).
@@ -421,6 +510,10 @@ struct Window {
/// read from `s`.
fn sample(s: &Surface, win: &Window, x: f64, y: f64, corners: &mut Vec<f32>, out: &mut [f32]) {
let n = out.len();
if !x.is_finite() || !y.is_finite() {
out.fill(0.0);
return;
}
let (x0, y0) = (x.floor() as i32, y.floor() as i32);
let (tx, ty) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32);
let (x1, y1) = (x0.saturating_add(1), y0.saturating_add(1));
@@ -537,6 +630,42 @@ mod tests {
let n = clone_stroke(&sc, &mut s, [sx, sy], &[[dx, dy]], 10.0, 0.8, 1.0);
assert_eq!(n, 1);
assert!(s.rgba(dx as i32, dy as i32)[0] > 0.8, "{:?}", s.rgba(dx as i32, dy as i32));
// The stroke's anchor copies from the source itself.
let back = clone_source(&sc, [sx, sy], [dx, dy], [dx, dy]).unwrap();
assert!((back[0] - sx).abs() < 1e-6 && (back[1] - sy).abs() < 1e-6, "{back:?}");
}
#[test]
fn incremental_clone_uses_one_source_snapshot_per_stroke() {
let sc = Scene::new(VpPlane { corners: [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]] }, [50.0, 50.0], None, 800.0);
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
let mut original = Surface::new(fmt);
for x in 0..100 {
original.fill_rect(Rect::new(x, 0, x + 1, 100), &[x as f32 / 100.0, (x % 7) as f32 / 6.0, 0.3, 1.0]);
}
let points = [[40.0, 50.0], [44.0, 50.0], [48.0, 50.0], [52.0, 50.0]];
let mut whole = original.clone();
assert_eq!(clone_stroke(&sc, &mut whole, [36.0, 50.0], &points, 12.0, 0.8, 1.0), 4);
let mut pieces = original.clone();
for part in points.chunks(2) {
clone_dabs_from(&sc, &mut pieces, &original, [36.0, 50.0], points[0], part, 12.0, 0.8, 1.0, None);
}
assert_eq!(whole.read_region(Rect::new(0, 0, 100, 100)), pieces.read_region(Rect::new(0, 0, 100, 100)));
}
}
/// A clip cuts every dab before it reads or writes: dabs far outside it allocate no tiles.
#[test]
fn clipped_clone_allocates_no_tiles_outside_the_clip() {
let sc = Scene::new(VpPlane { corners: [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]] }, [50.0, 50.0], None, 800.0);
let clip = Rect::new(0, 0, 100, 100);
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(clip, &[0.5, 0.5, 0.5, 1.0]);
let tiles = s.tiles().count();
let points = [[50.0, 50.0], [-40.0, 50.0], [5000.0, 5000.0], [-90_000.0, 70_000.0]];
let (dabs, area) = clone_dabs(&sc, &mut s, [40.0, 50.0], points[0], &points, 20.0, 0.8, 1.0, Some(clip));
assert_eq!(dabs, 1);
assert!(clip.contains_rect(&area), "{area:?}");
assert_eq!(s.tiles().count(), tiles);
}
#[test]
+9 -3
View File
@@ -329,7 +329,7 @@ pub struct MenuParams {
pub struct UiSetParams {
/// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel
/// ({background: bool} picks which swatch the Color panel edits), maskTarget,
/// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio,
/// vectorMaskTarget, filterMaskTarget (paint the active smart object's filter mask), selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio,
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark, softLight, softDark), brushSection, brushTab,
/// brushesView, brushPicker ([x, y] opens the Brush Preset picker there, null closes it),
/// brushPickerName, brushPickerStroke, brushPickerTip (the picker card's parts, at least one
@@ -350,8 +350,14 @@ pub struct UiSetParams {
/// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a
/// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off":
/// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not
/// over this channel). updatesOpen (bool, the Help › Check for Updates window). Other fields
/// are an error.
/// over this channel). updatesOpen (bool, the Help › Check for Updates window).
/// histogramPanel (an object patching view ("compact", "expanded" or "allChannels"), channel
/// ("composite", "red", "green", "blue", "cyan", "magenta", "yellow", "black", "a", "b",
/// "luminosity" or "colors"), showChannelsInColor, showStatistics and source ("entireImage",
/// "selectedLayer" or "adjustmentComposite") for the Histogram panel), histogramRange ([from,
/// to] levels 0..255 whose Count and Percentile the panel shows, or null) and histogramRefresh
/// (true: an Uncached Refresh); `ui.inspect` reports them and the statistics under
/// `histogram`. Other fields are an error.
pub fields: Value,
}
+13 -3
View File
@@ -224,8 +224,14 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
planar configuration 1 or 2.
* Supports 1, 2 and 4-bit gray, 8/16/32/64-bit integers (32 and 64-bit are reduced to U16),
F16, F32 and F64 (converted to F32), WhiteIsZero (#691: the tones are no longer inverted),
palette images (opened as RGB), associated alpha (made straight) and extra samples (dropped
unless the first is alpha).
palette images (opened as RGB) and associated alpha (made straight).
* Extra samples: the first alpha sample (ExtraSamples 1 or 2, wherever it sits) is the image's
alpha. Every other extra sample, such as a spot colour or a further alpha channel
(ExtraSamples 0), comes back in `Metadata::extra_samples`: one plane per sample in the
image's sample type, with its ExtraSamples value as `kind` (a reserved value above 2 reads as
0). At most `MAX_EXTRA_SAMPLES` (16) are kept, and they count against `Limits::max_alloc` with
the image: when the image fits but its extra samples don't, it opens without them and carries
`DecodeWarning::ExtraSamplesDropped`.
* Every directory is reachable: `tiff_info` lists the main IFD chain and its SubIFDs (tag 330)
with each page's size, kind (page, reduced-resolution copy, mask) and storage, walked with a
seen-set, a depth limit and a 10 000-directory cap; `decode_tiff_page` opens any of them.
@@ -239,7 +245,11 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
* YCbCr, CIE Lab, signed integers and compressions that the `tiff` crate lacks (CCITT,
JPEG-in-TIFF) return `Unsupported`.
* **TIFF encode** writes classic TIFF, or BigTIFF when `EncodeOptions::tiff_bigtiff` is set or
the file could pass 4 GiB.
the file could pass 4 GiB. `Metadata::extra_samples` (up to 16) are written after the colour
and alpha samples, with their `kind` in the ExtraSamples tag (a reserved `kind` above 2 is
written as 0). Each plane must be exactly
`width × height × sample.bytes()` bytes; a plane of any other length, and more than 16 planes,
are encode errors rather than silently truncated or reinterpreted.
* **EXR**
* Multi-part files (a Maya/Arnold render writes one part per AOV) open the part that
looks most like a colour image — RGBA beats RGB beats YA beats a single channel, so a
+588 -31
View File
@@ -249,7 +249,9 @@ pub(crate) fn decode_page(bytes: &[u8], page: Option<usize>, limits: &Limits) ->
.ok_or_else(|| CodecError::InvalidImage(format!("TIFF page {index} does not exist (the file has {} directories)", info.pages.len())))?;
let dir = file.ifd(p.ifd_offset).ok_or_else(|| err("image directory cut off"))?;
let mut img = decode_ifd(&file, &dir, limits)?;
let extra_samples = std::mem::take(&mut img.meta.extra_samples);
img.meta = metadata(&file, &dir, &mut img.icc);
img.meta.extra_samples = extra_samples;
let pages = info.page_count();
if pages > 1 {
img.warnings.push(DecodeWarning::MorePages { total: info.complete.then_some(u32::try_from(pages).unwrap_or(u32::MAX)) });
@@ -423,6 +425,8 @@ struct Layout {
palette: Option<Vec<[u16; 3]>>,
/// Upper bound for one chunk's scratch buffer.
scratch_cap: usize,
/// File sample indices that are neither colour nor the chosen alpha (spot / extra alpha).
extra_at: Vec<usize>,
}
impl Layout {
@@ -436,6 +440,9 @@ impl Layout {
fn out_row(&self) -> usize {
self.w * self.out_channels() * self.out.bytes()
}
fn extra_row(&self) -> usize {
self.w.saturating_mul(self.extra_at.len()).saturating_mul(self.out.bytes())
}
/// A chunky strip already in the output's layout: decoded in place.
fn direct(&self) -> bool {
self.copyable() && self.across == 1
@@ -543,13 +550,16 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
// returns the alpha only when it is the first extra sample: otherwise it is asked for every
// sample (as multiband gray) and the colour and alpha samples are picked here. An alpha
// position past the samples the file has is ignored.
let color = match photometric {
let color: usize = match photometric {
2 | 8 | 9 => 3,
5 => 4,
_ => 1,
};
let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color + a as u64) < spp);
let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && alpha_at.is_some_and(|a| a > 0);
let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color as u64).saturating_add(a as u64) < spp);
// Samples the colour model does not keep. RGB, CMYK and Lab only hand back every sample when
// asked (photometric patched to black-is-zero below); gray already comes back as multiband.
let unused = !extra.is_empty() && (spp as usize) > color.saturating_add(usize::from(alpha_at.is_some()));
let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && (alpha_at.is_some_and(|a| a > 0) || (!planar && unused));
let mut patches: Vec<(u64, Vec<u8>)> = Vec::new();
let encode_u = |v: u64, len: usize| -> Vec<u8> {
let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() };
@@ -592,7 +602,31 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
_ if palette.is_some() => (ChannelLayout::Rgb, 1),
tiff::ColorType::Gray(_) => (ChannelLayout::Gray, 1),
tiff::ColorType::Multiband { num_samples, .. } if all_samples => {
(if photometric == 5 { ChannelLayout::CmykA } else { ChannelLayout::Rgba }, usize::from(num_samples))
// Lab reads as RGB here and is relabelled below.
let layout = match photometric {
2 | 8 | 9 => {
if alpha_at.is_some() {
ChannelLayout::Rgba
} else {
ChannelLayout::Rgb
}
}
5 => {
if alpha_at.is_some() {
ChannelLayout::CmykA
} else {
ChannelLayout::Cmyk
}
}
_ => {
if gray_alpha {
ChannelLayout::GrayA
} else {
ChannelLayout::Gray
}
}
};
(layout, usize::from(num_samples))
}
tiff::ColorType::Multiband { num_samples, .. } if photometric <= 1 => {
(if gray_alpha { ChannelLayout::GrayA } else { ChannelLayout::Gray }, usize::from(num_samples))
@@ -661,7 +695,8 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
.and_then(|s| s.checked_mul(src.bytes()))
.ok_or_else(|| CodecError::LimitExceeded("tile size".into()))?;
let scratch_cap = chunk_bytes.saturating_mul(2).max(64 << 20).min(limits.alloc_usize());
let lay = Layout {
let (extra_at, extras_over_limit) = extra_sample_indices(spp as usize, color, alpha_at, out_layout.has_alpha());
let mut lay = Layout {
w: wu,
src,
out,
@@ -677,7 +712,19 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
premultiplied: out_layout.has_alpha() && alpha_at.and_then(|a| extra.get(a)) == Some(&1),
palette,
scratch_cap,
extra_at,
};
// Extra samples share the allocation budget with the image: their interleaved buffer and the
// planes split from it are held next to the image, so both count against `max_alloc`. When
// they don't fit, the image still opens (it passed its own check above) without them.
let mut extras_dropped = 0;
if !lay.extra_at.is_empty() {
let per_pixel = out_layout.channels().saturating_add(lay.extra_at.len().saturating_mul(2)).saturating_mul(out.bytes());
if limits.check_bytes(w, h, per_pixel as u64).is_err() {
extras_dropped = u32::try_from(lay.extra_at.len()).unwrap_or(u32::MAX);
lay.extra_at.clear();
}
}
// The output, allocated once and fallibly.
let out_row = lay.out_row();
@@ -685,7 +732,11 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
let mut data = zeroed(len)?;
let band_bytes = out_row.checked_mul(ch.min(hu)).ok_or_else(|| CodecError::LimitExceeded("band size".into()))?;
let tally = decode_bands(&lay, &mut data, band_bytes, &mut dec, &open)?;
let extra_row = lay.extra_row();
let extra_len = extra_row.checked_mul(hu).ok_or_else(|| CodecError::LimitExceeded("extra samples".into()))?;
let mut extra_data = if lay.extra_at.is_empty() { Vec::new() } else { zeroed(extra_len)? };
let extra_band = extra_row.saturating_mul(ch.min(hu));
let tally = decode_bands(&lay, &mut data, band_bytes, extra_data.as_mut_slice(), extra_band, &mut dec, &open)?;
if tally.decoded == 0 && tally.missing > 0 {
return Err(err("the image data is missing (cut off or past the end of the file)"));
}
@@ -693,6 +744,22 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
convert_ab(&mut data, out_layout.channels(), out, photometric == 8, false);
}
let mut img = Image::from_raw(w, h, out_layout, out, data)?;
if !lay.extra_at.is_empty() {
// TIFF 6.0 defines ExtraSamples 0, 1 and 2 only; a reserved value reads as unspecified,
// so a save never writes it back.
let kind_of = |si: usize| match si.checked_sub(color).and_then(|k| extra.get(k)).copied() {
Some(v @ 0..=2) => v as u16,
_ => 0,
};
img.meta.extra_samples = split_extra_samples(&extra_data, wu, hu, &lay.extra_at, out, kind_of)?;
}
if extras_over_limit > 0 {
let count = u32::try_from(extras_over_limit).unwrap_or(u32::MAX);
img.warnings.push(DecodeWarning::ExtraSamplesOverLimit { format: F, count });
}
if extras_dropped > 0 {
img.warnings.push(DecodeWarning::ExtraSamplesDropped { format: F, count: extras_dropped });
}
if tally.missing > 0 {
img.warnings.push(DecodeWarning::Truncated { format: F });
}
@@ -728,6 +795,55 @@ fn convert_ab(data: &mut [u8], channels: usize, sample: SampleType, signed: bool
}
}
/// File sample indices that are not a colour channel and not the one alpha sample, capped at
/// [`crate::MAX_EXTRA_SAMPLES`], and how many past the cap were left out.
fn extra_sample_indices(spp: usize, color: usize, alpha_at: Option<usize>, has_alpha: bool) -> (Vec<usize>, usize) {
let mut used = vec![false; spp];
for i in 0..color.min(spp) {
if let Some(slot) = used.get_mut(i) {
*slot = true;
}
}
if has_alpha {
let a = alpha_at.map(|k| color.saturating_add(k)).unwrap_or(color);
if let Some(slot) = used.get_mut(a) {
*slot = true;
}
}
let mut all: Vec<usize> = used.into_iter().enumerate().filter(|(_, on)| !*on).map(|(i, _)| i).collect();
let over = all.len().saturating_sub(crate::MAX_EXTRA_SAMPLES);
all.truncate(crate::MAX_EXTRA_SAMPLES);
(all, over)
}
/// Interleaved extra samples (`extra_at.len()` per pixel) → one plane per sample. Each plane is
/// allocated fallibly (the caller has checked their total against the limits).
fn split_extra_samples(
buf: &[u8],
w: usize,
h: usize,
at: &[usize],
sample: SampleType,
mut kind_of: impl FnMut(usize) -> u16,
) -> Result<Vec<crate::ExtraSample>, CodecError> {
let b = sample.bytes();
let n = w.saturating_mul(h);
let spp = at.len();
let mut out = Vec::with_capacity(spp);
for (ei, &si) in at.iter().enumerate() {
let mut data = zeroed(n.saturating_mul(b))?;
for i in 0..n {
let src = (i.saturating_mul(spp).saturating_add(ei)).saturating_mul(b);
let dst = i.saturating_mul(b);
if let (Some(s), Some(d)) = (buf.get(src..src.saturating_add(b)), data.get_mut(dst..dst.saturating_add(b))) {
d.copy_from_slice(s);
}
}
out.push(crate::ExtraSample { kind: kind_of(si), data });
}
Ok(out)
}
/// A zero-filled buffer, allocated fallibly. On native targets the zeroing (and the first
/// touch of every page, which dominates on a fresh multi-hundred-megabyte buffer) is spread over
/// all cores.
@@ -760,20 +876,43 @@ impl std::ops::Add for Tally {
}
/// Decodes every band into `data`, in parallel on native targets (one decoder and scratch
/// buffer per worker).
/// buffer per worker). `extra` receives samples that are not part of the colour image, one
/// band of rows at a time, when it is non-empty.
#[cfg(not(target_arch = "wasm32"))]
fn decode_bands<'a, O>(lay: &Layout, data: &mut [u8], band_bytes: usize, _first: &mut Decoder<Patched<'a>>, open: &O) -> Result<Tally, CodecError>
fn decode_bands<'a, O>(
lay: &Layout,
data: &mut [u8],
band_bytes: usize,
extra: &mut [u8],
extra_band: usize,
_first: &mut Decoder<Patched<'a>>,
open: &O,
) -> Result<Tally, CodecError>
where
O: Fn() -> Result<Decoder<Patched<'a>>, CodecError> + Sync,
{
use rayon::prelude::*;
if extra.is_empty() {
return data
.par_chunks_mut(band_bytes.max(1))
.enumerate()
.map_init(
|| open().map(|d| (d, Vec::new())).map_err(|e| e.to_string()),
|worker, (b, band)| {
let (dec, scratch) = worker.as_mut().map_err(|e| err(e.clone()))?;
decode_band(lay, dec, scratch, b, band, None)
},
)
.try_reduce(Tally::default, |a, b| Ok(a + b));
}
data.par_chunks_mut(band_bytes.max(1))
.zip(extra.par_chunks_mut(extra_band.max(1)))
.enumerate()
.map_init(
|| open().map(|d| (d, Vec::new())).map_err(|e| e.to_string()),
|worker, (b, band)| {
|worker, (b, (band, extra_band))| {
let (dec, scratch) = worker.as_mut().map_err(|e| err(e.clone()))?;
decode_band(lay, dec, scratch, b, band)
decode_band(lay, dec, scratch, b, band, Some(extra_band))
},
)
.try_reduce(Tally::default, |a, b| Ok(a + b))
@@ -781,20 +920,41 @@ where
/// Decodes every band into `data`, one after the other.
#[cfg(target_arch = "wasm32")]
fn decode_bands<'a, O>(lay: &Layout, data: &mut [u8], band_bytes: usize, first: &mut Decoder<Patched<'a>>, _open: &O) -> Result<Tally, CodecError>
fn decode_bands<'a, O>(
lay: &Layout,
data: &mut [u8],
band_bytes: usize,
extra: &mut [u8],
extra_band: usize,
first: &mut Decoder<Patched<'a>>,
_open: &O,
) -> Result<Tally, CodecError>
where
O: Fn() -> Result<Decoder<Patched<'a>>, CodecError> + Sync,
{
let mut scratch = Vec::new();
let mut tally = Tally::default();
for (b, band) in data.chunks_mut(band_bytes.max(1)).enumerate() {
tally = tally + decode_band(lay, first, &mut scratch, b, band)?;
if extra.is_empty() {
for (b, band) in data.chunks_mut(band_bytes.max(1)).enumerate() {
tally = tally + decode_band(lay, first, &mut scratch, b, band, None)?;
}
return Ok(tally);
}
for (b, (band, extra_band)) in data.chunks_mut(band_bytes.max(1)).zip(extra.chunks_mut(extra_band.max(1))).enumerate() {
tally = tally + decode_band(lay, first, &mut scratch, b, band, Some(extra_band))?;
}
Ok(tally)
}
/// Decodes band `b` (one strip, or one row of tiles, in every plane) into its output rows.
fn decode_band(lay: &Layout, dec: &mut Decoder<Patched<'_>>, scratch: &mut Vec<u8>, b: usize, band: &mut [u8]) -> Result<Tally, CodecError> {
fn decode_band(
lay: &Layout,
dec: &mut Decoder<Patched<'_>>,
scratch: &mut Vec<u8>,
b: usize,
band: &mut [u8],
mut extra: Option<&mut [u8]>,
) -> Result<Tally, CodecError> {
let out_row = lay.out_row();
let rows = band.len() / out_row.max(1);
let px = lay.out_channels() * lay.out.bytes();
@@ -836,16 +996,95 @@ fn decode_band(lay: &Layout, dec: &mut Decoder<Patched<'_>>, scratch: &mut Vec<u
}
let x0 = col * lay.cw;
let n = lay.cw.min(lay.w.saturating_sub(x0));
for (src_row, dst_row) in buf.chunks_exact(stride).zip(band.chunks_exact_mut(out_row)).take(rows) {
for (ri, (src_row, dst_row)) in buf.chunks_exact(stride).zip(band.chunks_exact_mut(out_row)).take(rows).enumerate() {
let Some(dst) = dst_row.get_mut(x0 * px..(x0 + n) * px) else { continue };
convert_row(lay, src_row, dst, n, plane);
if !lay.planar && plane == 0 && !lay.extra_at.is_empty() {
let erow = lay.extra_row();
if let Some(extra) = extra.as_deref_mut()
&& let Some(er) = extra.get_mut(ri.saturating_mul(erow)..ri.saturating_mul(erow).saturating_add(erow))
{
deposit_extras(lay, src_row, er, n, x0);
}
}
}
}
}
if lay.planar && !lay.extra_at.is_empty() {
read_planar_extras(lay, dec, scratch, b, rows, extra, &mut tally)?;
}
finish_band(lay, band);
Ok(tally)
}
/// Copies the non-colour samples of a chunky row into the extra-sample band.
fn deposit_extras(lay: &Layout, src: &[u8], dst: &mut [u8], n: usize, x0: usize) {
let ech = lay.extra_at.len();
if ech == 0 {
return;
}
let px = ech * lay.out.bytes();
let Some(dst) = dst.get_mut(x0.saturating_mul(px)..(x0.saturating_add(n)).saturating_mul(px)) else { return };
for (ei, &si) in lay.extra_at.iter().enumerate() {
transfer(lay.src, src, lay.chunk_spp, si, dst, lay.out, ech, ei, n);
}
}
/// Planar files store each extra sample as its own plane. Read those planes into `extra`.
fn read_planar_extras(
lay: &Layout,
dec: &mut Decoder<Patched<'_>>,
scratch: &mut Vec<u8>,
b: usize,
rows: usize,
extra: Option<&mut [u8]>,
tally: &mut Tally,
) -> Result<(), CodecError> {
let Some(extra) = extra else { return Ok(()) };
let ech = lay.extra_at.len();
let erow = lay.extra_row();
if ech == 0 || erow == 0 {
return Ok(());
}
for (ei, &file_plane) in lay.extra_at.iter().enumerate() {
for col in 0..lay.across {
let chunk = file_plane
.checked_mul(lay.chunks_per_plane)
.and_then(|c| c.checked_add(b.checked_mul(lay.across)?))
.and_then(|c| c.checked_add(col))
.and_then(|c| u32::try_from(c).ok())
.ok_or_else(|| err("chunk index out of range"))?;
let pref = dec.image_chunk_buffer_layout(chunk).map_err(map_err)?;
let stride = pref.row_stride.map(|s| s.get()).ok_or_else(|| err("empty chunk row"))?;
if pref.len > lay.scratch_cap {
return Err(CodecError::LimitExceeded(format!("a {} byte strip or tile", pref.len)));
}
if scratch.len() < pref.len {
scratch.resize(pref.len, 0);
}
let buf = scratch.get_mut(..pref.len).ok_or_else(|| err("scratch buffer"))?;
match dec.read_chunk_bytes(chunk, buf) {
Ok(()) => tally.decoded += 1,
Err(e) if is_eof(&e) => {
tally.missing += 1;
continue;
}
Err(e) => return Err(map_err(e)),
}
let x0 = col * lay.cw;
let n = lay.cw.min(lay.w.saturating_sub(x0));
for (ri, src_row) in buf.chunks_exact(stride).take(rows).enumerate() {
let start = ri.saturating_mul(erow);
let Some(er) = extra.get_mut(start..start.saturating_add(erow)) else { continue };
let px = ech * lay.out.bytes();
let Some(dst) = er.get_mut(x0.saturating_mul(px)..(x0.saturating_add(n)).saturating_mul(px)) else { continue };
transfer(lay.src, src_row, 1, 0, dst, lay.out, ech, ei, n);
}
}
}
Ok(())
}
/// Converts `n` pixels of one decoded chunk row into output pixels (`plane` for planar data).
fn convert_row(lay: &Layout, src: &[u8], dst: &mut [u8], n: usize, plane: usize) {
let och = lay.out_channels();
@@ -1072,6 +1311,230 @@ custom_colortype!(Lab16, u16, PI::CIELab, &[16; 3], &[SampleFormat::Uint; 3], in
custom_colortype!(LabA8, u8, PI::CIELab, &[8; 4], &[SampleFormat::Uint; 4], int);
custom_colortype!(LabA16, u16, PI::CIELab, &[16; 4], &[SampleFormat::Uint; 4], int);
// Colour types with 1..=MAX_EXTRA_SAMPLES trailing extra samples (spot channels, extra alpha).
// One generic type per (sample, photometric, bit depth, total sample count) instead of a
// hand-written type for every layout. `N` is the total sample count, not the extra count.
const fn extra_photo(v: u16) -> PhotometricInterpretation {
match v {
1 => PhotometricInterpretation::BlackIsZero,
2 => PhotometricInterpretation::RGB,
5 => PhotometricInterpretation::CMYK,
8 => PhotometricInterpretation::CIELab,
_ => PhotometricInterpretation::RGB,
}
}
trait ExtraKind: Copy + TiffValue + 'static {
const FORMAT: SampleFormat;
fn predict(row: &[Self], out: &mut Vec<Self>, samples: usize);
}
impl ExtraKind for u8 {
const FORMAT: SampleFormat = SampleFormat::Uint;
fn predict(row: &[Self], out: &mut Vec<Self>, samples: usize) {
let n = samples.min(row.len());
out.extend_from_slice(&row[..n]);
out.extend(row.iter().zip(&row[n..]).map(|(prev, current)| current.wrapping_sub(*prev)));
}
}
impl ExtraKind for u16 {
const FORMAT: SampleFormat = SampleFormat::Uint;
fn predict(row: &[Self], out: &mut Vec<Self>, samples: usize) {
let n = samples.min(row.len());
out.extend_from_slice(&row[..n]);
out.extend(row.iter().zip(&row[n..]).map(|(prev, current)| current.wrapping_sub(*prev)));
}
}
impl ExtraKind for f32 {
const FORMAT: SampleFormat = SampleFormat::IEEEFP;
fn predict(row: &[Self], out: &mut Vec<Self>, _samples: usize) {
out.extend_from_slice(row);
}
}
struct Extras<T, const PHOTO: u16, const BITS: u16, const N: usize>(std::marker::PhantomData<T>);
impl<T, const PHOTO: u16, const BITS: u16, const N: usize> TiffColorType for Extras<T, PHOTO, BITS, N>
where
T: ExtraKind,
{
type Inner = T;
const TIFF_VALUE: PhotometricInterpretation = extra_photo(PHOTO);
const BITS_PER_SAMPLE: &'static [u16] = &[BITS; N];
const SAMPLE_FORMAT: &'static [SampleFormat] = &[T::FORMAT; N];
fn horizontal_predict(row: &[Self::Inner], result: &mut Vec<Self::Inner>) {
T::predict(row, result, N);
}
}
macro_rules! write_extras {
($n:expr, $enc:expr, $w:expr, $h:expr, $data:expr, $tags:expr, $sample:ty, $photo:literal, $bits:literal, $base:literal) => {
match $n {
1 => write_one::<Extras<$sample, $photo, $bits, { $base + 1 }>, K>($enc, $w, $h, $data, $tags),
2 => write_one::<Extras<$sample, $photo, $bits, { $base + 2 }>, K>($enc, $w, $h, $data, $tags),
3 => write_one::<Extras<$sample, $photo, $bits, { $base + 3 }>, K>($enc, $w, $h, $data, $tags),
4 => write_one::<Extras<$sample, $photo, $bits, { $base + 4 }>, K>($enc, $w, $h, $data, $tags),
5 => write_one::<Extras<$sample, $photo, $bits, { $base + 5 }>, K>($enc, $w, $h, $data, $tags),
6 => write_one::<Extras<$sample, $photo, $bits, { $base + 6 }>, K>($enc, $w, $h, $data, $tags),
7 => write_one::<Extras<$sample, $photo, $bits, { $base + 7 }>, K>($enc, $w, $h, $data, $tags),
8 => write_one::<Extras<$sample, $photo, $bits, { $base + 8 }>, K>($enc, $w, $h, $data, $tags),
9 => write_one::<Extras<$sample, $photo, $bits, { $base + 9 }>, K>($enc, $w, $h, $data, $tags),
10 => write_one::<Extras<$sample, $photo, $bits, { $base + 10 }>, K>($enc, $w, $h, $data, $tags),
11 => write_one::<Extras<$sample, $photo, $bits, { $base + 11 }>, K>($enc, $w, $h, $data, $tags),
12 => write_one::<Extras<$sample, $photo, $bits, { $base + 12 }>, K>($enc, $w, $h, $data, $tags),
13 => write_one::<Extras<$sample, $photo, $bits, { $base + 13 }>, K>($enc, $w, $h, $data, $tags),
14 => write_one::<Extras<$sample, $photo, $bits, { $base + 14 }>, K>($enc, $w, $h, $data, $tags),
15 => write_one::<Extras<$sample, $photo, $bits, { $base + 15 }>, K>($enc, $w, $h, $data, $tags),
16 => write_one::<Extras<$sample, $photo, $bits, { $base + 16 }>, K>($enc, $w, $h, $data, $tags),
other => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unsupported extra samples ({other})")).into()),
}
};
}
/// An empty buffer with room for `len` elements, allocated fallibly: a save too large for memory
/// is an error, not an abort.
fn try_with_capacity<T>(len: usize) -> io::Result<Vec<T>> {
let mut v = Vec::new();
v.try_reserve_exact(len).map_err(|_| io::Error::new(io::ErrorKind::OutOfMemory, format!("cannot allocate {len} samples for the extra samples")))?;
Ok(v)
}
fn bytes_as_u16(b: &[u8]) -> io::Result<Vec<u16>> {
let chunks = b.as_chunks::<2>().0;
let mut v = try_with_capacity(chunks.len())?;
v.extend(chunks.iter().map(|c| u16::from_ne_bytes(*c)));
Ok(v)
}
fn bytes_as_f32(b: &[u8]) -> io::Result<Vec<f32>> {
let chunks = b.as_chunks::<4>().0;
let mut v = try_with_capacity(chunks.len())?;
v.extend(chunks.iter().map(|c| f32::from_ne_bytes(*c)));
Ok(v)
}
/// Interleaves colour (and alpha) samples with [`Metadata::extra_samples`].
fn interleave_extras(img: &Image) -> Result<Vec<u8>, String> {
let (w, h) = img.dimensions();
let n = (w as usize).checked_mul(h as usize).ok_or_else(|| "image size overflows".to_string())?;
let sb = img.sample_type().bytes();
let ch = img.layout().channels();
let extras = &img.meta.extra_samples;
let total = ch.checked_add(extras.len()).ok_or_else(|| "too many samples".to_string())?;
let px = total.checked_mul(sb).ok_or_else(|| "sample size overflows".to_string())?;
let len = n.checked_mul(px).ok_or_else(|| "image size overflows".to_string())?;
let mut out = try_with_capacity::<u8>(len).map_err(|e| e.to_string())?;
out.resize(len, 0);
let base = img.data();
let base_px = ch.saturating_mul(sb);
for i in 0..n {
let src = i.saturating_mul(base_px);
let dst = i.saturating_mul(px);
let Some(s) = base.get(src..src.saturating_add(base_px)) else {
return Err("image buffer is shorter than its dimensions".into());
};
let Some(d) = out.get_mut(dst..dst.saturating_add(base_px)) else {
return Err("extra-sample buffer is short".into());
};
d.copy_from_slice(s);
for (k, e) in extras.iter().enumerate() {
let es = i.saturating_mul(sb);
let ed = dst.saturating_add(base_px).saturating_add(k.saturating_mul(sb));
let Some(s) = e.data.get(es..es.saturating_add(sb)) else {
return Err(format!("extra sample {k} is shorter than the image"));
};
let Some(d) = out.get_mut(ed..ed.saturating_add(sb)) else {
return Err("extra-sample buffer is short".into());
};
d.copy_from_slice(s);
}
}
Ok(out)
}
fn write_with_extras<K: TiffKind>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>, K>, img: &Image, tags: &TagSet<'_>) -> tiff::TiffResult<()> {
use ChannelLayout as L;
use SampleType as S;
let (w, h) = img.dimensions();
let n = img.meta.extra_samples.len();
if img.layout().is_lab() {
let file = lab_file(img);
let bytes = interleave_extras(&file).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
return match (file.layout(), file.sample_type()) {
(L::Lab, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 8, 8, 3),
(L::Lab, _) => {
let v = bytes_as_u16(&bytes)?;
write_extras!(n, enc, w, h, &v, tags, u16, 8, 16, 3)
}
(_, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 8, 8, 4),
_ => {
let v = bytes_as_u16(&bytes)?;
write_extras!(n, enc, w, h, &v, tags, u16, 8, 16, 4)
}
};
}
let bytes = interleave_extras(img).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
match (img.layout(), img.sample_type()) {
(L::Gray, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 1, 8, 1),
(L::Gray, S::U16) => {
let v = bytes_as_u16(&bytes)?;
write_extras!(n, enc, w, h, &v, tags, u16, 1, 16, 1)
}
(L::Gray, S::F32) => {
let v = bytes_as_f32(&bytes)?;
write_extras!(n, enc, w, h, &v, tags, f32, 1, 32, 1)
}
(L::GrayA, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 1, 8, 2),
(L::GrayA, S::U16) => {
let v = bytes_as_u16(&bytes)?;
write_extras!(n, enc, w, h, &v, tags, u16, 1, 16, 2)
}
(L::GrayA, S::F32) => {
let v = bytes_as_f32(&bytes)?;
write_extras!(n, enc, w, h, &v, tags, f32, 1, 32, 2)
}
(L::Rgb, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 2, 8, 3),
(L::Rgb, S::U16) => {
let v = bytes_as_u16(&bytes)?;
write_extras!(n, enc, w, h, &v, tags, u16, 2, 16, 3)
}
(L::Rgb, S::F32) => {
let v = bytes_as_f32(&bytes)?;
write_extras!(n, enc, w, h, &v, tags, f32, 2, 32, 3)
}
(L::Rgba, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 2, 8, 4),
(L::Rgba, S::U16) => {
let v = bytes_as_u16(&bytes)?;
write_extras!(n, enc, w, h, &v, tags, u16, 2, 16, 4)
}
(L::Rgba, S::F32) => {
let v = bytes_as_f32(&bytes)?;
write_extras!(n, enc, w, h, &v, tags, f32, 2, 32, 4)
}
(L::Cmyk, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 5, 8, 4),
(L::Cmyk, S::U16) => {
let v = bytes_as_u16(&bytes)?;
write_extras!(n, enc, w, h, &v, tags, u16, 5, 16, 4)
}
(L::Cmyk, S::F32) => {
let v = bytes_as_f32(&bytes)?;
write_extras!(n, enc, w, h, &v, tags, f32, 5, 32, 4)
}
(L::CmykA, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 5, 8, 5),
(L::CmykA, S::U16) => {
let v = bytes_as_u16(&bytes)?;
write_extras!(n, enc, w, h, &v, tags, u16, 5, 16, 5)
}
(L::CmykA, S::F32) => {
let v = bytes_as_f32(&bytes)?;
write_extras!(n, enc, w, h, &v, tags, f32, 5, 32, 5)
}
_ => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unsupported extra samples ({n})")).into()),
}
}
struct TagSet<'a> {
icc: Option<&'a [u8]>,
/// The EXIF block's directories and IFD0 tags, written as the TIFF's own.
@@ -1079,7 +1542,8 @@ struct TagSet<'a> {
xmp: Option<&'a [u8]>,
dpi: Option<(f32, f32)>,
text: Vec<(Tag, &'a str)>,
alpha: bool,
/// ExtraSamples tag, including the unassociated-alpha marker when the image has alpha.
extra_samples: &'a [u16],
photoshop_resources: Option<&'a [u8]>,
photoshop_layers: Option<&'a [u8]>,
}
@@ -1109,9 +1573,8 @@ where
im.y_resolution(Rational { n: (y * 1000.0).round() as u32, d: 1000 });
}
let d = im.encoder();
if tags.alpha {
// 2 = unassociated alpha
d.write_tag(Tag::ExtraSamples, &[2u16][..])?;
if !tags.extra_samples.is_empty() {
d.write_tag(Tag::ExtraSamples, tags.extra_samples)?;
}
if let Some(icc) = tags.icc {
d.write_tag(Tag::IccProfile, Undefined(icc))?;
@@ -1142,10 +1605,24 @@ where
im.write_data(data)
}
/// A Lab image as CIELab stores it: a* and b* signed, 8 or 16 bits (CIELab has no float form:
/// 32-bit Lab is written with 16 bits). Extra samples follow the new sample type.
fn lab_file(img: &Image) -> Image {
let sample = if img.sample_type() == SampleType::U8 { SampleType::U8 } else { SampleType::U16 };
// `convert` brings the extra samples to the new sample type with the image.
let mut file = img.convert(img.layout(), sample);
let channels = file.layout().channels();
convert_ab(file.data_mut(), channels, sample, true, true);
file
}
/// Writes the image with the colour type matching its layout and sample type.
fn write_image<K: TiffKind>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>, K>, img: &Image, tags: &TagSet<'_>) -> tiff::TiffResult<()> {
use ChannelLayout as L;
use SampleType as S;
if !img.meta.extra_samples.is_empty() {
return write_with_extras::<K>(enc, img, tags);
}
let (w, h) = img.dimensions();
let u16s = || img.to_u16_samples().unwrap_or_default();
let f32s = || img.to_f32_samples().unwrap_or_default();
@@ -1170,13 +1647,9 @@ fn write_image<K: TiffKind>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>, K>, img:
(L::CmykA, S::U16) => write_one::<CmykA16, K>(enc, w, h, &u16s(), tags),
(L::CmykA, S::F32) => write_one::<CmykA32F, K>(enc, w, h, &f32s(), tags),
(L::Lab | L::LabA, _) => {
// CIELab has no float form: 32-bit Lab is written with 16 bits.
let sample = if img.sample_type() == S::U8 { S::U8 } else { S::U16 };
let mut file = img.convert(img.layout(), sample);
let channels = file.layout().channels();
convert_ab(file.data_mut(), channels, sample, true, true);
let file = lab_file(img);
let u16s = || file.to_u16_samples().unwrap_or_default();
match (file.layout(), sample) {
match (file.layout(), file.sample_type()) {
(L::Lab, S::U8) => write_one::<Lab8, K>(enc, w, h, file.data(), tags),
(L::Lab, _) => write_one::<Lab16, K>(enc, w, h, &u16s(), tags),
(_, S::U8) => write_one::<LabA8, K>(enc, w, h, file.data(), tags),
@@ -1198,11 +1671,48 @@ fn estimated_size(img: &Image, tags: &TagSet<'_>) -> u64 {
+ tags.exif.iter().flat_map(|e| e.ifd0.iter().chain(&e.exif).chain(&e.gps)).map(|f| f.value.len() as u64 + 12).sum::<u64>();
// Strip tables: one offset and one count per strip of about 1 MB.
let tables = (img.data().len() as u64 / (1 << 20) + 1) * 16;
(img.data().len() as u64).saturating_add(payloads).saturating_add(text).saturating_add(tables).saturating_add(1 << 20)
let extras: u64 = img.meta.extra_samples.iter().map(|e| e.data.len() as u64).sum();
(img.data().len() as u64).saturating_add(extras).saturating_add(payloads).saturating_add(text).saturating_add(tables).saturating_add(1 << 20)
}
/// The byte length of one extra-sample plane: one sample per pixel of `img`, in its sample type.
/// `None` when the pixel count overflows a `usize` (no such image buffer can exist).
fn extra_plane_len(img: &Image) -> Option<usize> {
let (w, h) = img.dimensions();
(w as usize).checked_mul(h as usize)?.checked_mul(img.sample_type().bytes())
}
/// Every extra plane must hold exactly one sample per pixel of `img`, in its sample type.
///
/// The writer copies one sample per pixel out of each plane, so a plane of any other length
/// would be silently truncated (long) or read past its end (short). A sample-type conversion
/// reinterprets the bytes in the new width instead, so an unchecked plane would also change
/// meaning rather than fail. Refuse the image instead of writing either.
fn check_extra_planes(img: &Image) -> Result<(), CodecError> {
let (w, h) = img.dimensions();
let sample = img.sample_type();
let plane_len = extra_plane_len(img).ok_or_else(|| CodecError::encode(F, format!("the extra-sample size of a {w}x{h} image overflows")))?;
for (k, e) in img.meta.extra_samples.iter().enumerate() {
if e.data.len() != plane_len {
return Err(CodecError::encode(
F,
format!("extra sample {k} is {} bytes; a {w}x{h} {sample:?} image needs exactly {plane_len} (one sample per pixel)", e.data.len()),
));
}
}
Ok(())
}
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
let img = src.converted(plan.layout, plan.sample);
// Check the source planes before `converted`, which brings them to the plan's sample type
// with the image but deliberately leaves a wrong-sized plane alone: past that point a bad
// plane would be reinterpreted in the new width rather than rejected.
check_extra_planes(src)?;
// `converted` brings the extra samples to the plan's sample type with the image.
encode_ready(&src.converted(plan.layout, plan.sample), opts)
}
fn encode_ready(img: &Image, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
let compression = match opts.tiff_compression {
TiffCompression::None => tiff::encoder::Compression::Uncompressed,
TiffCompression::Lzw => tiff::encoder::Compression::Lzw,
@@ -1226,13 +1736,29 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
};
// TIFF writes no Orientation tag (= 1): keep the XMP from contradicting it.
let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(|x| crate::resolution::export_xmp(x, img.meta.dpi)) } else { None };
if img.meta.extra_samples.len() > crate::MAX_EXTRA_SAMPLES {
return Err(CodecError::encode(F, format!("TIFF stores at most {} extra samples", crate::MAX_EXTRA_SAMPLES)));
}
// Validate again in the sample type the planes now hold: `Image::convert` takes a plane's
// bytes before it rebuilds them, so one it could not rebuild would arrive here empty. The Lab
// path's later re-type through `lab_file` converts a plane only when its length already
// matches the image, which this guarantees.
check_extra_planes(img)?;
let mut kinds = Vec::with_capacity(img.meta.extra_samples.len().saturating_add(1));
if img.layout().has_alpha() {
kinds.push(2);
}
// TIFF 6.0 reserves ExtraSamples values above 2: those are written as 0 (unspecified).
for e in &img.meta.extra_samples {
kinds.push(if e.kind <= 2 { e.kind } else { 0 });
}
let tags = TagSet {
icc: if opts.embed_icc { img.icc.as_deref() } else { None },
exif: if opts.embed_metadata { img.meta.exif.as_deref().and_then(|e| tiff_exif::read(crate::orientation::tiff_body(e))) } else { None },
xmp: xmp.as_deref().map(str::as_bytes),
dpi: if opts.embed_metadata { img.meta.dpi.filter(|d| d.0 > 0.0 && d.1 > 0.0) } else { None },
text,
alpha: img.layout().has_alpha(),
extra_samples: &kinds,
// Document content, not metadata: written whenever present.
photoshop_resources: img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()),
photoshop_layers: img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()),
@@ -1245,17 +1771,48 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
let mut cursor = Cursor::new(Vec::new());
if big {
let mut enc = TiffEncoder::new_big(&mut cursor)?.with_compression(compression).with_predictor(predictor);
write_image::<TiffKindBig>(&mut enc, &img, &tags)?;
write_image::<TiffKindBig>(&mut enc, img, &tags)?;
} else {
let mut enc = TiffEncoder::new(&mut cursor)?.with_compression(compression).with_predictor(predictor);
write_image::<TiffKindStandard>(&mut enc, &img, &tags)?;
write_image::<TiffKindStandard>(&mut enc, img, &tags)?;
}
Ok(cursor.into_inner())
};
let big = opts.tiff_bigtiff || estimated_size(&img, &tags) > CLASSIC_LIMIT;
let big = opts.tiff_bigtiff || estimated_size(img, &tags) > CLASSIC_LIMIT;
match write(big) {
Ok(v) => Ok(v),
Err(tiff::TiffError::IntSizeError) if !big => write(true).map_err(|e| CodecError::encode(F, e)),
Err(e) => Err(CodecError::encode(F, e)),
}
}
#[cfg(test)]
mod bits_len {
use super::Extras;
use tiff::encoder::colortype::ColorType as TiffColorType;
#[test]
fn const_generic_sample_tables_have_one_entry_per_sample() {
// A repeat expression in an associated const (`&[BITS; N]`) has, on some compilers,
// promoted to a slice shorter than `N`. The encoder sizes every strip from that length.
assert_eq!(<Extras<u8, 2, 8, 4> as TiffColorType>::BITS_PER_SAMPLE.len(), 4, "{:?}", <Extras<u8, 2, 8, 4> as TiffColorType>::BITS_PER_SAMPLE);
assert_eq!(<Extras<u8, 2, 8, 5> as TiffColorType>::BITS_PER_SAMPLE.len(), 5);
assert_eq!(<Extras<u8, 2, 8, 19> as TiffColorType>::BITS_PER_SAMPLE.len(), 19);
assert_eq!(<Extras<u16, 5, 16, 6> as TiffColorType>::SAMPLE_FORMAT.len(), 6);
}
}
#[cfg(test)]
mod extra_sample_buffers {
use super::try_with_capacity;
/// The extra-sample writer's buffers are allocated fallibly: a size no allocation can serve
/// is an out-of-memory error the save reports, not an abort.
#[test]
fn an_impossible_buffer_is_an_error() {
let err = try_with_capacity::<u8>(usize::MAX).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::OutOfMemory);
assert!(try_with_capacity::<u16>(usize::MAX / 2).is_err());
assert!(try_with_capacity::<f32>(64).is_ok_and(|v| v.is_empty() && v.capacity() >= 64));
}
}
+51 -1
View File
@@ -145,6 +145,24 @@ pub struct Metadata {
/// Photoshop layer data (TIFF tag 37724 `ImageSourceData`): a layered TIFF's layers. Kept
/// opaque here; `photocraft-io` reads and writes it through `photocraft-psd`.
pub photoshop_layers: Option<Vec<u8>>,
/// Samples per pixel beyond the colour channels and the one alpha sample. TIFF extra
/// samples (spot colours, extra alpha channels) the pixel layout does not hold. Each entry
/// is one sample per pixel, native endian, in the image's [`SampleType`], row-major.
pub extra_samples: Vec<ExtraSample>,
}
/// How many extra TIFF samples an encoded file may carry. Decoders keep at most this many.
/// Sixteen covers a UV-DTF stack (white, varnish, gloss, primer, and further spots) without
/// approaching the PSD 56-channel limit.
pub const MAX_EXTRA_SAMPLES: usize = 16;
/// One TIFF extra sample (a spot channel or an alpha channel that is not the image alpha).
#[derive(Debug, Clone, PartialEq)]
pub struct ExtraSample {
/// TIFF ExtraSamples value: 0 unspecified, 1 associated alpha, 2 unassociated alpha.
pub kind: u16,
/// One sample per pixel, native endian, same sample type as the image.
pub data: Vec<u8>,
}
impl Metadata {
@@ -155,6 +173,7 @@ impl Metadata {
&& self.text.is_empty()
&& self.photoshop_resources.is_none()
&& self.photoshop_layers.is_none()
&& self.extra_samples.is_empty()
}
}
@@ -181,6 +200,12 @@ pub enum DecodeWarning {
/// A DPX element's transfer characteristic and colorimetric specification (SMPTE 268M codes,
/// 255 when undefined). The code values are imported as stored, without a colour transform.
DpxEncoding { transfer: u8, colorimetric: u8 },
/// The file's `count` extra samples (spot or extra alpha channels) did not fit the allocation
/// limit next to the image, so only the image was decoded.
ExtraSamplesDropped { format: Format, count: u32 },
/// The file has `count` extra samples past the first [`MAX_EXTRA_SAMPLES`], which are all a
/// decoder keeps; those past the cap were not imported.
ExtraSamplesOverLimit { format: Format, count: u32 },
}
/// The SMPTE 268M name of a transfer characteristic or colorimetric code.
@@ -231,6 +256,20 @@ impl fmt::Display for DecodeWarning {
dpx_code_name(*transfer),
dpx_code_name(*colorimetric)
),
DecodeWarning::ExtraSamplesDropped { format, count } => write!(
f,
"{count} extra {} file channel{} (spot or alpha) did not fit in memory and {} not imported",
format.name(),
if *count == 1 { "" } else { "s" },
if *count == 1 { "was" } else { "were" }
),
DecodeWarning::ExtraSamplesOverLimit { format, count } => write!(
f,
"{count} extra {} file channel{} (spot or alpha) past the first {MAX_EXTRA_SAMPLES} {} not imported",
format.name(),
if *count == 1 { "" } else { "s" },
if *count == 1 { "was" } else { "were" }
),
}
}
}
@@ -642,6 +681,17 @@ impl Image {
let values = convert_layout(&src, self.layout, layout);
quantize(&values, sample)
};
let mut meta = self.meta.clone();
if sample != self.sample {
// Extra samples are stored in the image's sample type, so they convert with it. A
// plane of the wrong size is left as it is: importers drop it with a warning.
let plane_len = (self.width as usize).saturating_mul(self.height as usize).saturating_mul(self.sample.bytes());
for e in meta.extra_samples.iter_mut().filter(|e| e.data.len() == plane_len) {
if let Ok(plane) = Image::from_raw(self.width, self.height, ChannelLayout::Gray, self.sample, std::mem::take(&mut e.data)) {
e.data = plane.convert(ChannelLayout::Gray, sample).data;
}
}
}
Image {
width: self.width,
height: self.height,
@@ -650,7 +700,7 @@ impl Image {
data,
icc: if layout.same_model(self.layout) { self.icc.clone() } else { None },
cicp: if layout.same_model(self.layout) { self.cicp } else { None },
meta: self.meta.clone(),
meta,
warnings: self.warnings.clone(),
}
}
+2 -1
View File
@@ -37,7 +37,8 @@ pub use crate::exif_color::{EXIF_COLOR_SPACE_SRGB, exif_color_space};
pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with};
pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, from_extension};
pub use crate::image::{
ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType,
ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, ExtraSample, Image,
MAX_EXTRA_SAMPLES, Metadata, SampleType,
};
pub use crate::options::{
DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, JpegFormat, Limits, PngCompression, PngStereo, TiffCompression,
+6
View File
@@ -331,6 +331,12 @@ impl Image {
}
let data = orient_bytes(self.data(), bpp, o, (w, h), ow)?;
let mut meta = self.meta.clone();
// Extra samples (a TIFF's alpha and spot planes, one sample a pixel) turn with the image.
// A plane of the wrong size is left as it is: importers drop it with a warning.
let plane_len = w.saturating_mul(h).saturating_mul(sample.bytes());
for e in meta.extra_samples.iter_mut().filter(|e| e.data.len() == plane_len) {
e.data = orient_bytes(&e.data, sample.bytes(), o, (w, h), ow)?;
}
if let Some(e) = &mut meta.exif
&& let Cow::Owned(fixed) = upright_exif(e)
{
+19
View File
@@ -211,6 +211,25 @@ fn png_opens_upright_for_every_orientation() {
}
}
/// Extra samples (a TIFF's alpha and spot planes) turn with the image, so a channel stays
/// aligned with the pixels for every orientation.
#[test]
fn extra_samples_turn_with_the_image() {
let (w, h) = (4, 7);
let up = exact_scene(w, h);
let up_extra: Vec<u8> = up.iter().map(|v| 255 - v).collect();
for o in 1..=8u16 {
let (s, sw, sh) = stored(&up, w, h, o);
let (s_extra, _, _) = stored(&up_extra, w, h, o);
let mut img = gray(sw, sh, s);
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: s_extra }];
let turned = img.oriented(o).unwrap();
assert_eq!(turned.data(), &up[..], "o={o}");
assert_eq!(turned.meta.extra_samples.len(), 1, "o={o}");
assert_eq!(turned.meta.extra_samples[0].data, up_extra, "o={o}");
}
}
#[test]
fn tiff_opens_upright_for_every_orientation() {
let (w, h) = (4, 7);
+33
View File
@@ -74,3 +74,36 @@ fn lab_converts_to_rgb_for_other_formats() {
assert_eq!(&p[4..7], &[255, 255, 255], "white");
assert!(p[0] > 180 && p[1] < 120 && p[2] > 140, "L 50, a 50, b −30 is a magenta-ish purple: {:?}", &p[..3]);
}
#[test]
fn lab_keeps_extra_samples() {
// A spot plate next to Lab: written as CIELab plus one unspecified extra sample, and read
// back as Lab with the spot (not as LabA).
for (layout, sample) in [(ChannelLayout::Lab, SampleType::U8), (ChannelLayout::LabA, SampleType::U16)] {
let px: Vec<f32> =
[lab(50.0, 50.0, -30.0), lab(100.0, 0.0, 0.0)].iter().flat_map(|p| p.iter().copied().chain(layout.has_alpha().then_some(0.5))).collect();
let mut img = Image::from_normalized(2, 1, layout, sample, &px).unwrap();
let spot: Vec<u8> = (0..2 * sample.bytes()).map(|i| (i * 37 + 5) as u8).collect();
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: spot.clone() }];
let file = uncompressed(&img);
if layout == ChannelLayout::Lab {
assert!(find(&file, &[128, 50, (-30i8) as u8, spot[0], 255, 0, 0, spot[1]]).is_some(), "CIELab samples with the spot");
}
let back = decode(&file).unwrap();
assert_eq!((back.layout(), back.sample_type()), (layout, sample));
let (a, b) = (img.to_normalized(), back.to_normalized());
assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= 1.5 / 65535.0), "{layout:?}: {a:?} {b:?}");
assert_eq!(back.meta.extra_samples, vec![ExtraSample { kind: 0, data: spot }], "{layout:?}");
}
}
#[test]
fn float_lab_writes_its_extra_samples_with_16_bits() {
let px: Vec<f32> = [lab(50.0, 50.0, -30.0), lab(100.0, 0.0, 0.0)].concat();
let mut img = Image::from_normalized(2, 1, ChannelLayout::Lab, SampleType::F32, &px).unwrap();
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: [0.25f32, 1.0].iter().flat_map(|v| v.to_ne_bytes()).collect() }];
let back = decode(&uncompressed(&img)).unwrap();
assert_eq!((back.layout(), back.sample_type()), (ChannelLayout::Lab, SampleType::U16));
let spot: Vec<u8> = [16384u16, 65535].iter().flat_map(|v| v.to_ne_bytes()).collect();
assert_eq!(back.meta.extra_samples, vec![ExtraSample { kind: 0, data: spot }]);
}
+38 -4
View File
@@ -128,13 +128,16 @@ fn planar_strips_at_every_depth() {
}
}
/// Extra samples that are not the alpha (ExtraSamples 0: a spot colour or an extra channel) are
/// not part of the image: they come back in `meta.extra_samples`, one plane each.
#[test]
fn extra_samples_that_are_not_alpha_are_dropped() {
fn extra_samples_that_are_not_alpha_are_kept_apart() {
let (w, h) = (5usize, 3usize);
let t = truth(w * h * 5, 4);
let rgb_of = |spp: usize, keep: usize| -> Vec<u8> { t.u8s[..w * h * spp].chunks(spp).flat_map(|p| p[..keep].to_vec()).collect() };
let extra = |spp: usize, at: usize| vec![ExtraSample { kind: 0, data: t.u8s[..w * h * spp].chunks(spp).map(|p| p[at]).collect() }];
for planar in [false, true] {
// RGB + one unspecified extra sample: RGB.
// RGB + one unspecified extra sample: RGB, and the sample as an extra plane.
let px = &t.u8s[..w * h * 4];
let chunks = if planar { planar_strips(px, w, h, 4, 1, 2) } else { px.chunks(w * 4 * 2).map(<[u8]>::to_vec).collect() };
let mut d = Dir { entries: base(w as u32, h as u32, 8, 4, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[0]);
@@ -144,7 +147,8 @@ fn extra_samples_that_are_not_alpha_are_dropped() {
let img = decode_ok(&build(true, false, &[d], &[0]).bytes);
assert_eq!(img.layout(), ChannelLayout::Rgb);
assert_eq!(img.data(), &rgb_of(4, 3)[..], "planar {planar}");
// RGB + alpha + an unspecified extra sample: RGBA.
assert_eq!(img.meta.extra_samples, extra(4, 3), "planar {planar}");
// RGB + alpha + an unspecified extra sample: RGBA and one extra plane.
let px = &t.u8s[..w * h * 5];
let chunks = if planar { planar_strips(px, w, h, 5, 1, 2) } else { px.chunks(w * 5 * 2).map(<[u8]>::to_vec).collect() };
let mut d = Dir { entries: base(w as u32, h as u32, 8, 5, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[2, 0]);
@@ -154,18 +158,48 @@ fn extra_samples_that_are_not_alpha_are_dropped() {
let img = decode_ok(&build(false, true, &[d], &[0]).bytes);
assert_eq!(img.layout(), ChannelLayout::Rgba);
assert_eq!(img.data(), &rgb_of(5, 4)[..], "planar {planar}");
assert_eq!(img.meta.extra_samples, extra(5, 4), "planar {planar}");
}
// Gray + alpha + extra: gray and alpha; gray + an unspecified extra: gray.
// Gray + alpha + extra: gray and alpha; gray + an unspecified extra: gray. Each keeps the
// extra sample as a plane.
let px = &t.u8s[..w * h * 3];
let d = strips(w as u32, h as u32, 8, 3, 1, 3, px).tag(338, SHORT, &[2, 0]);
let img = decode_ok(&build(true, false, &[d], &[0]).bytes);
assert_eq!(img.layout(), ChannelLayout::GrayA);
assert_eq!(img.data(), &rgb_of(3, 2)[..]);
assert_eq!(img.meta.extra_samples, extra(3, 2));
let px = &t.u8s[..w * h * 2];
let d = strips(w as u32, h as u32, 8, 2, 1, 3, px).tag(338, SHORT, &[0]);
let img = decode_ok(&build(true, false, &[d], &[0]).bytes);
assert_eq!(img.layout(), ChannelLayout::Gray);
assert_eq!(img.data(), &rgb_of(2, 1)[..]);
assert_eq!(img.meta.extra_samples, extra(2, 1));
}
/// A file may hold more extra samples than the `MAX_EXTRA_SAMPLES` a decode keeps (up to 64
/// samples a pixel open): the first ones open, and a warning counts those left out, so nothing
/// is lost without notice.
#[test]
fn extra_samples_past_the_cap_are_reported() {
let (w, h) = (3usize, 2usize);
const EXTRAS: usize = MAX_EXTRA_SAMPLES + 4;
let spp = 3 + EXTRAS;
let px: Vec<u8> = (0..w * h * spp).map(|i| (i % spp) as u8).collect();
let d = strips(w as u32, h as u32, 8, spp as u16, 2, h as u32, &px).tag(338, SHORT, &[0; EXTRAS]);
let img = decode_ok(&build(true, false, &[d], &[0]).bytes);
assert_eq!(img.layout(), ChannelLayout::Rgb);
assert_eq!(img.meta.extra_samples.len(), MAX_EXTRA_SAMPLES);
assert_eq!(img.meta.extra_samples.last().map(|e| e.data[0]), Some((3 + MAX_EXTRA_SAMPLES - 1) as u8));
assert_eq!(img.warnings, vec![DecodeWarning::ExtraSamplesOverLimit { format: Format::Tiff, count: 4 }]);
let text = img.warnings[0].to_string();
assert!(text.contains("4 extra TIFF file channels") && text.contains("past the first 16"), "{text}");
// At the cap exactly, nothing is left out.
let spp = 3 + MAX_EXTRA_SAMPLES;
let px: Vec<u8> = (0..w * h * spp).map(|i| (i % spp) as u8).collect();
let d = strips(w as u32, h as u32, 8, spp as u16, 2, h as u32, &px).tag(338, SHORT, &[0; MAX_EXTRA_SAMPLES]);
let img = decode_ok(&build(true, false, &[d], &[0]).bytes);
assert_eq!(img.meta.extra_samples.len(), MAX_EXTRA_SAMPLES);
assert!(img.warnings.is_empty(), "{:?}", img.warnings);
}
#[test]
+262
View File
@@ -41,6 +41,268 @@ fn tags_round_trip_through_the_encoder() {
}
}
#[test]
fn extra_samples_round_trip() {
for (layout, sample) in [
(ChannelLayout::Rgb, SampleType::U8),
(ChannelLayout::Rgba, SampleType::U8),
(ChannelLayout::Gray, SampleType::U16),
(ChannelLayout::Cmyk, SampleType::F32),
] {
let mut img = synth(4, 3, layout, sample, 2, 0.4);
let n = 4 * 3;
let b = sample.bytes();
let data: Vec<u8> = (0..n * b).map(|i| (i * 17) as u8).collect();
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: data.clone() }];
let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
let back = decode(&bytes).unwrap();
assert_eq!(back.layout(), layout, "{layout:?} {sample:?}");
assert_eq!(back.data(), img.data(), "{layout:?} {sample:?}");
assert_eq!(back.meta.extra_samples.len(), 1, "{layout:?} {sample:?}");
assert_eq!(back.meta.extra_samples[0].kind, 0, "{layout:?} {sample:?}");
assert_eq!(back.meta.extra_samples[0].data, data, "{layout:?} {sample:?}");
}
}
/// Tall enough that the default strip size (about 1 MB) cannot hold every row, so a writer
/// that only fills the first strip, or predicts across the wrong sample count, cannot hide.
/// The spot plane is checked with the `tiff` crate, not PhotoCraft's decoder.
#[test]
fn multi_strip_spot_round_trip_and_dump() {
let w = 800u32;
let h = 400u32;
let n = (w as usize) * (h as usize);
for (layout, channels) in [(ChannelLayout::Rgb, 3usize), (ChannelLayout::Rgba, 4usize)] {
let mut px = vec![0u8; n * channels];
let mut spot = vec![0u8; n];
for y in 0..h as usize {
for x in 0..w as usize {
let i = y * w as usize + x;
px[i * channels] = (x % 251) as u8;
px[i * channels + 1] = (y % 251) as u8;
px[i * channels + 2] = 40;
if channels == 4 {
px[i * channels + 3] = if x > 20 && x < 780 && y > 10 && y + 10 < h as usize { 255 } else { 0 };
}
spot[i] = ((x * 3 + y * 17) % 251) as u8;
}
}
let mut img = Image::from_u8(w, h, layout, px).unwrap();
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: spot.clone() }];
for compression in [TiffCompression::None, TiffCompression::Deflate, TiffCompression::Lzw, TiffCompression::PackBits] {
let bytes = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: compression, ..Default::default() }).unwrap();
let label = format!("{layout:?} {compression:?}");
let back = decode(&bytes).unwrap();
assert_eq!(back.meta.extra_samples.len(), 1, "{label}");
assert_eq!(back.meta.extra_samples[0].data, spot, "own decoder {label}");
assert_eq!(back.data(), img.data(), "own decoder rgb {label}");
let mut dec = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
let spp: u16 = dec.get_tag_unsigned(tiff::tags::Tag::SamplesPerPixel).unwrap();
let bps = dec.get_tag_u16_vec(tiff::tags::Tag::BitsPerSample).unwrap();
let extra = dec.get_tag_u16_vec(tiff::tags::Tag::ExtraSamples).unwrap();
let rows = dec.find_tag_unsigned::<u32>(tiff::tags::Tag::RowsPerStrip).unwrap();
assert_eq!(bps.len(), spp as usize, "{label}");
assert_eq!(spp as usize, channels + 1, "{label}");
assert_eq!(*extra.last().unwrap(), 0, "{label} spot ExtraSamples {extra:?}");
assert!(rows.is_some_and(|r| r < h) || compression == TiffCompression::PackBits, "{label} rows {rows:?}");
let patched = min_is_black(&bytes);
let mut indep = tiff::decoder::Decoder::new(std::io::Cursor::new(patched.as_slice())).unwrap();
let tiff::decoder::DecodingResult::U8(samples) = indep.read_image().unwrap() else { panic!("{label} not u8") };
let spp = spp as usize;
assert_eq!(samples.len(), n * spp, "{label}");
let mut bad = 0usize;
let mut first = None;
for (i, want) in spot.iter().enumerate() {
let got = samples.get(i * spp + spp - 1).copied();
if got != Some(*want) {
bad += 1;
first.get_or_insert((i, got));
}
}
assert_eq!(bad, 0, "{label} independent reader, first {first:?}");
}
}
}
/// Retag photometric interpretation 262 as MinIsBlack so `tiff`'s decoder returns every sample.
fn min_is_black(src: &[u8]) -> Vec<u8> {
let mut bytes = src.to_vec();
let le = bytes.first().copied() == Some(b'I');
let ru16 = |b: &[u8], i: usize| -> Option<u16> {
let s = b.get(i..i + 2)?;
Some(if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) })
};
let ru32 = |b: &[u8], i: usize| -> Option<u32> {
let s = b.get(i..i + 4)?;
Some(if le { u32::from_le_bytes(s.try_into().ok()?) } else { u32::from_be_bytes(s.try_into().ok()?) })
};
let Some(ifd) = ru32(&bytes, 4) else { return bytes };
let Some(n) = ru16(&bytes, ifd as usize) else { return bytes };
for e in 0..n as usize {
let at = ifd as usize + 2 + e * 12;
if ru16(&bytes, at) == Some(262) {
let value = if le { 1u16.to_le_bytes() } else { 1u16.to_be_bytes() };
if let Some(slot) = bytes.get_mut(at + 8..at + 10) {
slot.copy_from_slice(&value);
}
break;
}
}
bytes
}
#[test]
fn more_than_sixteen_extra_samples_is_refused() {
let mut img = synth(2, 2, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
img.meta.extra_samples = (0..MAX_EXTRA_SAMPLES + 1).map(|_| ExtraSample { kind: 0, data: vec![0; 4] }).collect();
assert!(encode(&img, Format::Tiff, &EncodeOptions::default()).is_err());
}
#[test]
fn sixteen_extra_samples_round_trip() {
for (layout, sample, base) in [
(ChannelLayout::Rgb, SampleType::U8, 3usize),
(ChannelLayout::Rgba, SampleType::U8, 4),
(ChannelLayout::Gray, SampleType::U16, 1),
(ChannelLayout::Cmyk, SampleType::F32, 4),
] {
let mut img = synth(2, 2, layout, sample, 1, 0.0);
let n = 2 * 2 * sample.bytes();
img.meta.extra_samples = (0..MAX_EXTRA_SAMPLES).map(|k| ExtraSample { kind: 0, data: vec![k as u8; n] }).collect();
let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
let back = decode(&bytes).unwrap();
assert_eq!(back.layout(), layout, "{layout:?} {sample:?} base {base}");
assert_eq!(back.data(), img.data(), "{layout:?}");
assert_eq!(back.meta.extra_samples.len(), MAX_EXTRA_SAMPLES, "{layout:?}");
assert_eq!(back.meta.extra_samples[7].kind, 0, "{layout:?}");
assert_eq!(back.meta.extra_samples[15].data, vec![15; n], "{layout:?}");
}
}
/// An extra plane holds exactly one sample per pixel of the image's sample type. A plane of any
/// other length is refused rather than written: the writer copies one sample per pixel, so a
/// longer plane would lose its tail and a shorter one would run out mid-image.
#[test]
fn wrong_sized_extra_planes_are_refused() {
let (w, h) = (2u32, 1u32);
for (label, plane) in [("too long", vec![10u8, 20, 30]), ("too short", vec![10u8]), ("empty", Vec::new())] {
let mut img = synth(w, h, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: plane.clone() }];
let err = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap_err();
assert!(matches!(err, CodecError::Encode { .. }), "{label}: {err}");
assert!(err.to_string().contains("extra sample 0") && err.to_string().contains("exactly 2"), "{label}: {err}");
}
// The exact length still writes, and comes back unchanged.
let plane = vec![10u8, 20];
let mut img = synth(w, h, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: plane.clone() }];
let back = decode(&encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap()).unwrap();
assert_eq!(back.meta.extra_samples, vec![ExtraSample { kind: 0, data: plane }]);
}
/// The sample-type conversion an encode performs must not launder a wrong-sized plane: float Lab
/// is stored as CIELab at 16 bits, and `Image::convert` leaves a plane whose length does not match
/// the image alone, so a 7- or 9-byte plane for two pixels would otherwise reach the writer
/// reinterpreted at the new width.
#[test]
fn a_wrong_sized_plane_is_refused_before_a_sample_type_conversion() {
let (w, h) = (2u32, 1u32);
// Two pixels at 32-bit float are 8 bytes; the file's 16-bit samples are 4.
for (label, len) in [("short", 7usize), ("long", 9)] {
let mut img = synth(w, h, ChannelLayout::Lab, SampleType::F32, 1, 0.0);
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: vec![0; len] }];
let err = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap_err();
assert!(matches!(err, CodecError::Encode { .. }), "{label}: {err}");
assert!(err.to_string().contains("exactly 8"), "{label}: {err}");
}
}
/// Extra samples count against `max_alloc` with the image (their interleaved buffer and the
/// planes split from it): a budget that holds the RGB image but not sixteen extra planes opens
/// the image without them, with a warning, instead of allocating past it.
#[test]
fn extra_samples_count_against_the_allocation_limit() {
// Big enough for several strips (about 1 MB each): one strip of all 19 samples fits the budget
// of the image alone, as in real files.
let (w, h) = (1024u32, 1024u32);
let n = (w * h) as usize;
let mut img = synth(w, h, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
img.meta.extra_samples = (0..MAX_EXTRA_SAMPLES).map(|k| ExtraSample { kind: 0, data: vec![k as u8; n] }).collect();
let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
let with = |max_alloc: u64| decode_with(&bytes, &DecodeOptions { limits: Limits { max_alloc, ..Limits::default() }, ..DecodeOptions::default() });
// RGB is 3 bytes a pixel; the extra samples add 2 x 16 more.
let image_only = 3 * n as u64;
let with_extras = (3 + 2 * MAX_EXTRA_SAMPLES as u64) * n as u64;
let back = with(with_extras).expect("everything fits");
assert_eq!(back.meta.extra_samples.len(), MAX_EXTRA_SAMPLES);
assert!(back.warnings.is_empty());
// The image fits, its extra samples don't: it opens as before they were read, with a warning.
for max_alloc in [image_only, with_extras - 1] {
let only = with(max_alloc).expect("the image alone fits");
assert!(only.meta.extra_samples.is_empty());
assert_eq!(only.warnings, vec![DecodeWarning::ExtraSamplesDropped { format: Format::Tiff, count: MAX_EXTRA_SAMPLES as u32 }]);
assert_eq!(only.data(), back.data());
}
assert!(matches!(with(image_only - 1), Err(CodecError::LimitExceeded(_))), "the image itself must still fit");
}
/// The value field of a little-endian classic TIFF's ExtraSamples entry (tag 338, SHORT) holding
/// two values, so they sit in the entry itself.
fn extra_samples_values(bytes: &mut [u8]) -> &mut [u8] {
assert_eq!(&bytes[..4], b"II*\0");
let entry = [0x52, 0x01, 3, 0, 2, 0, 0, 0];
let at = bytes.windows(8).position(|w| w == entry).expect("an ExtraSamples entry with two values");
&mut bytes[at + 8..at + 12]
}
/// ExtraSamples values above 2 are reserved (TIFF 6.0, p. 31): they read as 0 (unspecified) and
/// are written as 0, so a save never carries a reserved value.
#[test]
fn reserved_extra_sample_kinds_read_as_unspecified() {
let n = 4 * 4;
let mut img = synth(4, 4, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: vec![7; n] }, ExtraSample { kind: 0, data: vec![9; n] }];
let mut bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
// A file from another writer with reserved values 3 and 65535.
let values = extra_samples_values(&mut bytes);
assert_eq!(values, [0, 0, 0, 0]);
values.copy_from_slice(&[3, 0, 0xFF, 0xFF]);
let back = decode(&bytes).unwrap();
assert_eq!(back.meta.extra_samples.iter().map(|e| e.kind).collect::<Vec<_>>(), [0, 0]);
assert_eq!(back.layout(), ChannelLayout::Rgb, "a reserved value is no alpha");
assert_eq!(back.meta.extra_samples[0].data, vec![7; n]);
assert_eq!(back.meta.extra_samples[1].data, vec![9; n]);
// An image that carries a reserved kind saves it as 0.
img.meta.extra_samples[0].kind = 3;
img.meta.extra_samples[1].kind = u16::MAX;
let mut bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
assert_eq!(extra_samples_values(&mut bytes), [0, 0, 0, 0]);
}
/// Converting an image's sample type converts its extra samples with it, so they keep matching
/// the image: a 16-bit float TIFF's spot planes survive the 32-bit float a layered import uses.
#[test]
fn extra_samples_follow_a_sample_type_conversion() {
let (w, h) = (3u32, 2u32);
let n = (w * h) as usize;
let values = [0.0f32, 0.25, 0.5, 0.75, 1.0, 0.125];
let half = |v: f32| f16::from_f32(v).to_bits().to_ne_bytes();
let rgb: Vec<u8> = (0..n * 3).flat_map(|_| half(0.5)).collect();
let mut img = Image::from_raw(w, h, ChannelLayout::Rgb, SampleType::F16, rgb).unwrap();
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: values.iter().flat_map(|v| half(*v)).collect() }];
let wide = img.convert(ChannelLayout::Rgb, SampleType::F32);
let plane: Vec<f32> = wide.meta.extra_samples[0].data.as_chunks::<4>().0.iter().map(|c| f32::from_ne_bytes(*c)).collect();
assert_eq!(plane, values);
// To 8 bits, the plane quantizes exactly as a gray image of the same values does.
let narrow = wide.convert(ChannelLayout::Rgb, SampleType::U8);
let gray = Image::from_raw(w, h, ChannelLayout::Gray, SampleType::F32, wide.meta.extra_samples[0].data.clone()).unwrap();
assert_eq!(narrow.meta.extra_samples[0].data, gray.convert(ChannelLayout::Gray, SampleType::U8).data());
assert_eq!(narrow.meta.extra_samples[0].data.len(), n);
}
#[test]
fn absent_and_empty_tags_are_not_written() {
let mut img = synth(4, 4, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
@@ -0,0 +1,60 @@
//! 24 MP timing of Art History Brush on a representative stroke.
//! `cargo run --release -p photocraft-engine --example bench_art_history`
use std::time::Instant;
use photocraft_engine::Session;
use photocraft_geom::Rect;
use serde_json::json;
fn main() {
let (w, h) = (6016u32, 4000u32);
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": h, "depth": 8, "mode": "rgb"})).unwrap();
s.edit("texture", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let fmt = surf.format();
let row: Vec<f32> = (0..w)
.flat_map(|x| {
let t = (x % 256) as f32 / 255.0;
photocraft_raster::from_rgba(&fmt, [t, 0.35, 1.0 - t, 1.0])
})
.collect();
for y in 0..h as i32 {
surf.write_region(Rect::new(0, y, w as i32, y + 1), &row);
}
Ok(())
})
.unwrap();
s.execute("edit.fill", json!({"color": "#606060"})).unwrap();
let points: Vec<[i32; 2]> = (0..200).map(|i| [2800 + i, 2000]).collect();
let run = |s: &mut Session, style: &str| {
s.execute(
"paint.artHistoryBrush",
json!({
"points": points,
"size": 40,
"hardness": 100,
"area": 50,
"tolerance": 0,
"style": style,
"state": 0
}),
)
.unwrap();
};
// Warm the allocator / tile cache.
run(&mut s, "tightMedium");
s.undo();
for style in ["tightMedium", "looseCurl"] {
let t = Instant::now();
run(&mut s, style);
let ms = t.elapsed().as_secs_f64() * 1000.0;
println!(
"artHistoryBrush document {w}×{h} ({:.1} MP) stroke 200 px size 40 area 50 style {style} {ms:.3} ms",
(w as u64 * h as u64) as f64 / 1e6
);
s.undo();
}
}
@@ -0,0 +1,50 @@
//! End-to-end timing of `edit.contentAwareScale` (perf scenario P28, #211) without the UI: a
//! synthetic 24 MP RGB photo opened from JPEG like `perf_scenarios`' (gradients, soft blobs, fine
//! noise and a high-contrast disc), scaled to 90 % width, undone between runs.
//! `cargo run --release -p photocraft-engine --example bench_content_aware_scale [reps] [width height]`
use std::time::Instant;
use photocraft_engine::Session;
use serde_json::json;
fn photo_jpeg(w: u32, h: u32) -> Result<Vec<u8>, String> {
let mut px = vec![0u8; w as usize * h as usize * 3];
let (cx, cy, r) = (w as f32 * 0.55, h as f32 * 0.5, w.min(h) as f32 / 3.0);
for (y, row) in px.chunks_mut(w as usize * 3).enumerate() {
for x in 0..w as usize {
let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32);
let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 997) as f32 / 997.0 - 0.5;
let blob = (-(((fx - 0.4) * 3.0).powi(2) + ((fy - 0.55) * 4.0).powi(2))).exp();
let inside = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt() < r;
let (r0, g0, b0) = if inside { (0.85, 0.3 + 0.2 * (fx * 40.0).sin(), 0.15) } else { (0.35, 0.45, 0.55) };
let rgb = [r0 + 0.1 * blob + n * 0.08, g0 + 0.1 * blob + n * 0.08, b0 + 0.2 * fy + n * 0.08];
for (c, v) in rgb.into_iter().enumerate() {
row[x * 3 + c] = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
}
}
let img =
photocraft_codecs::Image::from_raw(w, h, photocraft_codecs::ChannelLayout::Rgb, photocraft_codecs::SampleType::U8, px).map_err(|e| e.to_string())?;
photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).map_err(|e| e.to_string())
}
fn main() -> Result<(), String> {
let args: Vec<String> = std::env::args().collect();
let num = |i: usize, d: u32| args.get(i).and_then(|v| v.parse::<u32>().ok()).unwrap_or(d);
let (reps, w, h) = (num(1, 3).clamp(1, 20) as usize, num(2, 6000).clamp(16, 20_000), num(3, 4000).clamp(16, 20_000));
let jpeg = photo_jpeg(w, h)?;
let doc = photocraft_io::import("photo.jpg", &jpeg).map_err(|e| e.to_string())?.document;
let mut s = Session::new();
s.open_document(doc, Some("photo.jpg".into()));
let mut times = Vec::with_capacity(reps);
for _ in 0..reps {
let t = Instant::now();
s.execute("edit.contentAwareScale", json!({"scaleX": 90, "scaleY": 100})).map_err(|e| e.to_string())?;
times.push(t.elapsed().as_secs_f64() * 1000.0);
s.execute("edit.undo", json!({})).map_err(|e| e.to_string())?;
}
times.sort_by(f64::total_cmp);
let (p50, max) = (times[times.len() / 2], times[times.len() - 1]);
println!("edit.contentAwareScale {w}x{h} to 90 % width: p50 {p50:.1} ms, max {max:.1} ms ({reps} runs)");
Ok(())
}
+28 -46
View File
@@ -8,7 +8,7 @@
use photocraft_doc::LayerContent;
use photocraft_geom::Rect;
use photocraft_paint::mixer::{MixerSettings, apply_mixer_stroke};
use photocraft_paint::mixer::{MixerSettings, MixerState};
use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement, color_replacement_effect};
use photocraft_paint::tile::PREVIEW_SIDE;
use photocraft_paint::{
@@ -462,6 +462,8 @@ pub struct LiveStroke {
/// document revision, params, seed and points), it takes these pixels instead of rendering the
/// stroke again.
pub struct LiveCommit {
/// Mixer load before and after the stroke; transient, never serialized into the journal.
pub(crate) mixer: Option<(MixerState, MixerState)>,
pub(crate) cmd: String,
pub(crate) base: (photocraft_doc::DocId, u64),
pub(crate) params: Value,
@@ -553,7 +555,16 @@ impl LiveStroke {
pub fn into_commit(mut self) -> Result<LiveCommit> {
let bounds = self.bounds();
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
Ok(LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: Some(self.seed), points: self.points, surface: surf.clone(), bounds })
Ok(LiveCommit {
mixer: None,
cmd: self.cmd,
base: self.base,
params: self.params,
seed: Some(self.seed),
points: self.points,
surface: surf.clone(),
bounds,
})
}
/// Everything the stroke has touched so far.
@@ -644,49 +655,9 @@ fn pct(p: &Value, k: &str, d: f32) -> f32 {
num(p, k).unwrap_or(d).clamp(0.0, 100.0) / 100.0
}
pub mod mixer_live;
fn mixer_brush(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "paint.mixerBrush";
let pts = parse_points(p, cmd)?;
let brush = resolve_brush(s, p, cmd)?;
// The brush's Mixer Brush options, overridden by the scalar params.
let bm = &brush.mixer;
let m = MixerSettings {
wet: pct(p, "wet", bm.wet * 100.0),
load: pct(p, "load", bm.load * 100.0),
mix: pct(p, "mix", bm.mix * 100.0),
flow: pct(p, "flow", bm.flow * 100.0),
sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers),
load_after_stroke: flag(p, "loadAfterStroke", bm.load_after_stroke),
clean_after_stroke: flag(p, "cleanAfterStroke", bm.clean_after_stroke),
};
let sample_all = m.sample_all_layers;
let paint = color(p.get("color"), s.tools.foreground);
let mut state = s.tools.mixer.clone();
state.begin_stroke(paint);
let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) };
let stroke = Stroke { brush, points: pts };
let dmg = s.edit("Mixer Brush", |doc, _| {
let sel = doc.selection.clone();
let sample = if sample_all {
let ds = photocraft_paint::dabs(&stroke);
let ctx = photocraft_paint::BrushContext::new(&stroke.brush);
let area = ds.iter().fold(Rect::EMPTY, |r, d| r.union(&ctx.dab_rect(d, false)));
let buf = photocraft_compose::render(doc, area);
let mut surf = Surface::new(photocraft_color::PixelFormat::RGBA32F);
let flat: Vec<f32> = buf.px.iter().flatten().copied().collect();
if !area.is_empty() {
surf.write_region(area, &flat);
}
Some(surf)
} else {
None
};
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock))
})?;
state.end_stroke(paint, m.clean_after_stroke, m.load_after_stroke);
s.tools.mixer = state;
Ok(damage_json(s, dmg))
mixer_live::run(s, p)
}
/// `brush.mixer.load`: Load Brush fills the Mixer Brush's reservoir with the color. The next stroke
@@ -1192,10 +1163,18 @@ macro_rules! spec {
/// Brush command specs.
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"brush.mixer.sample",
"Sample Mixer Brush",
r#"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?]],"sampleAllLayers":bool=brush setting,"size":px?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}}"#,
|s| s.active().map(|_| ()).ok_or_else(|| "no document".into()),
mixer_live::sample,
true
),
spec!(
"paint.pencil",
"Pencil",
r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|clear (erases with this brush)"="normal","erase":bool?,"block":bool=false (a hard 16×16 screen-px square, 16/zoom doc px; size, opacity, flow, hardness and dynamics ignored: the Eraser's Block mode),"zoom":view zoom=1,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##,
r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|clear (erases with this brush)"="normal","erase":bool?,"block":bool=false (a hard 16×16 screen-px square, 16/zoom doc px; size, opacity, flow, hardness and dynamics ignored: the Eraser's Block mode),"zoom":view zoom=1,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##,
has_paintable,
pencil,
true
@@ -1203,7 +1182,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"paint.mixerBrush",
"Mixer Brush",
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground (a color other than the one recorded by the last stroke, load or clean loads it first; a load or clean records the foreground),"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke (empty the brush after the stroke),"loadAfterStroke":bool=brush.mixer.loadAfterStroke (load the brush with `color` after the stroke),"seed":u64?}"##,
r##"{"points":[…] (long segments are interpolated),"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke,"loadAfterStroke":bool=brush.mixer.loadAfterStroke,"seed":u64?,"mixerState":{reservoir,amount,pickup,spatial,color} (optional captured replay state; spatial is null or 4096 RGBA colors)}"##,
has_paintable,
mixer_brush,
true
@@ -1252,3 +1231,6 @@ pub fn specs() -> Vec<CommandSpec> {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod mixer_live_tests;
+457
View File
@@ -0,0 +1,457 @@
//! Incremental Mixer Brush transaction. Source pixels are composed before each dab;
//! only the selected target changes. Dropping the transaction cancels it.
use super::*;
use photocraft_color::{PixelFormat, SampleType};
use photocraft_doc::{Document, LayerId};
use photocraft_paint::dynamics::DabGenerator;
use photocraft_paint::mixer::{MixerState, apply_mixer_dab_region};
use photocraft_paint::retouch::{Region, mix_back};
use photocraft_paint::{BrushContext, Dab};
use std::collections::BTreeSet;
use std::sync::Arc;
const CMD: &str = "paint.mixerBrush";
const MAX_TILES: usize = 256;
/// The largest tip the live preview renders; larger valid tips render on release.
const PREVIEW_SIZE: f32 = 1024.0;
const TILE: i32 = 256;
/// Accept bounded finite channels, including HDR values, without admitting unsafe arithmetic inputs.
fn valid_color(color: &[f32; 4]) -> bool {
color.iter().all(|v| v.is_finite() && v.abs() <= 1_000_000.0)
}
/// Validate finite paint-load values and the exact 64 by 64 shape of a spatial reservoir.
fn valid_state(state: &MixerState) -> bool {
state.amount.is_finite()
&& state.reservoir.as_ref().is_none_or(valid_color)
&& state.pickup.as_ref().is_none_or(valid_color)
&& state.color.as_ref().is_none_or(valid_color)
&& state.spatial.as_ref().is_none_or(|colors| colors.len() == 4096 && colors.iter().all(valid_color))
}
#[derive(Clone)]
struct Core {
doc: Arc<Document>,
generator: DabGenerator,
ctx: BrushContext,
pre: Surface,
work: Surface,
tiles: BTreeSet<(i32, i32)>,
preview_limits: bool,
state: MixerState,
settings: MixerSettings,
layer: Option<LayerId>,
params: Value,
selection: Option<Surface>,
lock: bool,
color_restrict: Option<usize>,
count: usize,
last: Option<StrokePoint>,
bounds: Rect,
}
impl Core {
/// Apply a batch against evolving working pixels and return its damage bounds.
/// Dab, footprint and tile budgets apply only to live previews; the release replays without them.
fn apply(&mut self, dabs: &[Dab]) -> Result<Rect> {
if self.preview_limits && self.count.saturating_add(dabs.len()) > 250_000 {
return Err(bad(CMD, "stroke exceeds 250000 dabs; start another stroke"));
}
let mut damage = Rect::EMPTY;
for dab in dabs {
let index = self.count;
self.count += 1;
let r = self.ctx.dab_rect(dab, false);
if r.is_empty() {
continue;
}
let full = self.ctx.dab_rect(dab, false);
if self.preview_limits && u64::from(full.width()).saturating_mul(u64::from(full.height())) > 4_194_304 {
return Err(bad(CMD, "Mixer Brush footprint exceeds 4194304 pixels"));
}
// Float working tiles avoid repeated integer-depth quantization. Initialize only
// touched tiles, keeping unchanged source tiles shared by the document snapshots.
for ty in r.y0.div_euclid(TILE)..=(r.y1 - 1).div_euclid(TILE) {
for tx in r.x0.div_euclid(TILE)..=(r.x1 - 1).div_euclid(TILE) {
if !self.tiles.contains(&(tx, ty)) {
if self.preview_limits && self.tiles.len() >= MAX_TILES {
return Err(bad(CMD, "Mixer Brush stroke working set exceeds 256 tiles"));
}
let tile = Rect::new(tx * TILE, ty * TILE, (tx + 1) * TILE, (ty + 1) * TILE);
self.work.write_region(tile, &self.pre.read_region(tile));
self.tiles.insert((tx, ty));
}
}
}
// Render the visible document including the active layer exactly once, before
// deposition. Masks, transforms, blend modes and adjustments use the compositor.
let sample = self.settings.sample_all_layers.then(|| {
let buf = photocraft_compose::render(&self.doc, r);
let fmt = self.pre.format();
let mut sample = Region::new(r, fmt.channels());
for (src, dst) in buf.px.iter().zip(sample.data.chunks_exact_mut(sample.ch)) {
photocraft_raster::from_rgba_into(&fmt, *src, dst);
}
sample
});
let fp = self.ctx.footprint(dab, index);
let mut region = Region::read(&self.work, r);
apply_mixer_dab_region(&mut region, &fp, self.pre.format(), sample.as_ref(), &self.settings, &mut self.state, self.lock);
region.write(&mut self.work);
let original = Region::read(&self.pre, r);
let (target, _) = crate::channel_cmds::target_surface(Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
mix_back(target, &original, &region, r, 1.0, self.selection.as_ref(), self.lock);
if let Some(k) = self.color_restrict {
let n = target.channels();
let mut values = target.read_region(r);
let before = self.pre.read_region(r);
for (new, old) in values.chunks_exact_mut(n).zip(before.chunks_exact(n)) {
for (c, (new, old)) in new.iter_mut().zip(old).enumerate() {
if c != k {
*new = *old;
}
}
}
target.write_region(r, &values);
}
damage = damage.union(&r);
}
self.bounds = self.bounds.union(&damage);
Ok(damage)
}
}
/// A real, cancellable paint result, displayed by the existing damage-aware canvas.
pub struct LiveMixer {
pub doc: Arc<Document>,
pub seed: u64,
core: Core,
state: MixerState,
tail: Rect,
cmyk: Option<Arc<photocraft_color::convert::CmykSpace>>,
original: Arc<Document>,
points: Vec<StrokePoint>,
original_state: MixerState,
}
impl LiveMixer {
/// Capture the current document and paint load, then render the initial samples with live-preview limits.
pub fn begin(s: &Session, p: &Value) -> Result<Self> {
Self::begin_with_limits(s, p, true)
}
/// Share initialization between live previews and command replay while keeping their tile budgets distinct.
fn begin_with_limits(s: &Session, p: &Value, preview_limits: bool) -> Result<Self> {
let (mut live, points) = Self::prepare(s, p, preview_limits)?;
live.push(&points)?;
Ok(live)
}
/// Whether a stroke the live preview refuses only for its tip size still paints on release.
/// `Err` when the stroke can't be painted at all (locked layer, bad params...).
pub fn defers_to_release(s: &Session, p: &Value) -> Result<bool> {
let (live, _) = Self::prepare(s, p, false)?;
Ok(live.core.ctx.brush.size > PREVIEW_SIZE)
}
/// Validate the params and capture the document and paint load, without rendering a dab.
fn prepare(s: &Session, p: &Value, preview_limits: bool) -> Result<(Self, Vec<StrokePoint>)> {
has_paintable(s).map_err(EngineError::Other)?;
if preview_limits && p.get("points").and_then(Value::as_array).is_some_and(|v| v.len() > 65_536) {
return Err(bad(CMD, "stroke exceeds 65536 recorded input samples"));
}
let points = parse_points(p, CMD)?;
let brush = resolve_brush(s, p, CMD)?;
let bm = &brush.mixer;
let settings = MixerSettings {
wet: pct(p, "wet", bm.wet * 100.0),
load: pct(p, "load", bm.load * 100.0),
mix: pct(p, "mix", bm.mix * 100.0),
flow: pct(p, "flow", bm.flow * 100.0),
sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers) && crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels,
clean_after_stroke: flag(p, "cleanAfterStroke", bm.clean_after_stroke),
load_after_stroke: flag(p, "loadAfterStroke", bm.load_after_stroke),
};
let initial_state = if let Some(value) = p.get("mixerState") {
if value.get("spatial").and_then(Value::as_array).is_some_and(|v| v.len() != 4096) {
return Err(bad(CMD, "spatial reservoir must contain exactly 4096 colors"));
}
serde_json::from_value::<MixerState>(value.clone()).map_err(|e| bad(CMD, e.to_string()))?
} else {
s.tools.mixer.clone()
};
let mut state = initial_state;
if !valid_state(&state) {
return Err(bad(CMD, "loaded paint must be finite and color magnitudes at most 1000000"));
}
let paint = color(p.get("color"), s.tools.foreground);
if !valid_color(&paint) {
return Err(bad(CMD, "loaded color must be finite and magnitudes at most 1000000"));
}
// Earlier captured replay states had no foreground marker. Keep their loaded paint.
if p.get("mixerState").is_some_and(|v| v.get("color").is_none()) && (state.reservoir.is_some() || state.spatial.is_some()) {
state.color = Some(paint);
}
state.begin_stroke(paint);
let layer = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) };
let mut doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone();
let selection = doc.selection.clone();
let (target, lock) = crate::channel_cmds::target_surface(&mut doc, layer, p)?;
let pre = target.clone();
let work = Surface::new(PixelFormat { sample: SampleType::F32, ..pre.format() });
let core = Core {
doc: Arc::new(doc),
generator: DabGenerator::new(&brush, num(p, "zoom").unwrap_or(1.0)),
ctx: BrushContext::new(&brush),
pre,
work,
tiles: BTreeSet::new(),
preview_limits,
state: state.clone(),
settings,
layer,
params: p.clone(),
selection,
lock,
color_restrict: crate::channel_cmds::color_restriction(s, CMD, p),
count: 0,
last: None,
bounds: Rect::EMPTY,
};
// Checked last, so a tip that is too big is the only reason left (`defers_to_release`),
// and before `push` allocates a dense brush footprint.
if preview_limits && core.ctx.brush.size > PREVIEW_SIZE {
return Err(bad(CMD, "the live Mixer Brush preview is limited to 1024 px tips"));
}
let live = Self {
doc: core.doc.clone(),
seed: brush.seed,
core,
state,
tail: Rect::EMPTY,
cmyk: doc_cmyk_space(s),
original: s.active().ok_or(EngineError::NoDocument)?.doc.clone(),
points: Vec::new(),
original_state: s.tools.mixer.clone(),
};
Ok((live, points))
}
/// Return all damaged pixels, including the provisional smoothing tail currently shown by the preview.
pub fn bounds(&self) -> Rect {
self.core.bounds.union(&self.tail)
}
/// Whether the dab generator needs stationary time samples, as for a continuing airbrush stroke.
pub fn wants_time(&self) -> bool {
self.core.generator.wants_time()
}
/// Batches and individual input samples produce identical results. The speculative end
/// dab / smoothing tail is displayed but never fed into the next accepted input.
pub fn push(&mut self, points: &[StrokePoint]) -> Result<Rect> {
check_coords(points, CMD)?;
if self.core.preview_limits && self.points.len().saturating_add(points.len()) > 65_536 {
return Err(bad(CMD, "stroke exceeds 65536 recorded input samples"));
}
let cmyk = self.cmyk.clone();
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
let old_tail = self.tail;
let mut damage = Rect::EMPTY;
for point in points {
// Bound each temporary dab batch without rejecting sparse mouse events or
// long Shift-click lines. Live and recorded replay use identical interpolation.
let start = self.core.last.unwrap_or(*point);
let steps = ((point.x - start.x).hypot(point.y - start.y) / 2048.0).ceil().max(1.0) as usize;
for step in 1..=steps {
let f = step as f64 / steps as f64;
let ff = f as f32;
let q = if step == steps {
*point
} else {
StrokePoint {
x: start.x + (point.x - start.x) * f,
y: start.y + (point.y - start.y) * f,
pressure: start.pressure + (point.pressure - start.pressure) * ff,
tilt_x: start.tilt_x + (point.tilt_x - start.tilt_x) * ff,
tilt_y: start.tilt_y + (point.tilt_y - start.tilt_y) * ff,
rotation: start.rotation + (point.rotation - start.rotation) * ff,
wheel: start.wheel + (point.wheel - start.wheel) * ff,
time: start.time + (point.time - start.time) * f,
}
};
let (mut dabs, mut dual) = (Vec::new(), Vec::new());
self.core.generator.push(&[q], &mut dabs, &mut dual);
damage = damage.union(&self.core.apply(&dabs)?);
}
self.core.last = Some(*point);
}
let mut finished = self.core.clone();
let (mut dabs, mut dual) = (Vec::new(), Vec::new());
finished.generator.finish(&mut dabs, &mut dual);
self.tail = finished.apply(&dabs)?;
self.doc = finished.doc;
self.state = finished.state;
self.points.extend_from_slice(points);
Ok(damage.union(&old_tail).union(&self.tail))
})
}
/// Keep the same compact journal params as other paint tools: UI settings, points and seed.
fn commit_params(&self) -> Result<Value> {
let mut params = self.core.params.clone();
params["points"] = serde_json::to_value(&self.points).map_err(|e| bad(CMD, e.to_string()))?;
params["seed"] = json!(self.seed);
Ok(params)
}
/// Stage the already rendered pixels for the ordinary journaled command. Refuse stale
/// document/tool state. The command consumes this single-use cache only for identical params.
pub fn prepare_commit(self, session: &mut Session) -> Result<Value> {
if !session.active().is_some_and(|st| Arc::ptr_eq(&st.doc, &self.original) && (self.core.layer.is_none() || st.active_layer == self.core.layer))
|| session.tools.mixer != self.original_state
{
return Err(bad(CMD, "document or loaded paint changed during the stroke"));
}
let params = self.commit_params()?;
let base = session.active().map(|st| (st.doc.id, st.revision)).ok_or(EngineError::NoDocument)?;
let bounds = self.bounds();
let mut doc = (*self.doc).clone();
let surface = crate::channel_cmds::target_surface(&mut doc, self.core.layer, &params)?.0.clone();
let mut after = self.state;
after.end_stroke(self.core.ctx.brush.color, self.core.settings.clean_after_stroke, self.core.settings.load_after_stroke);
session.live_commit = Some(LiveCommit {
cmd: CMD.into(),
base,
params: params.clone(),
seed: Some(self.seed),
points: self.points,
surface,
bounds,
mixer: Some((self.original_state, after)),
});
Ok(params)
}
}
/// Consume a matching prepared result or replay without the preview tile cap, then commit one history step.
/// The completed stroke also applies the automatic clean/load settings to the stored paint load.
pub(super) fn run(s: &mut Session, p: &Value) -> Result<Value> {
let points = parse_points(p, CMD)?;
let cached =
s.live_commit.take().filter(|live| live.matches(s, CMD, p, &points) && live.mixer.as_ref().is_some_and(|(before, _)| *before == s.tools.mixer));
if let Some(live) = cached {
let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) };
s.edit("Mixer Brush", |doc, _| {
*crate::channel_cmds::target_surface(doc, id, p)?.0 = live.surface;
Ok(())
})?;
if let Some((_, after)) = live.mixer {
s.tools.mixer = after;
}
let mut result = damage_json(s, live.bounds);
result["fromLive"] = json!(true);
return Ok(result);
}
let live = LiveMixer::begin_with_limits(s, p, false)?;
let damage = live.bounds();
s.edit("Mixer Brush", |doc, _| {
*doc = (*live.doc).clone();
Ok(())
})?;
let mut state = live.state;
let paint = live.core.ctx.brush.color;
state.end_stroke(paint, live.core.settings.clean_after_stroke, live.core.settings.load_after_stroke);
s.tools.mixer = state;
Ok(damage_json(s, damage))
}
/// Sampling changes only session tool state, never document pixels or document Undo.
pub(super) fn sample(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("points").and_then(Value::as_array).is_some_and(|v| v.len() != 1) {
return Err(bad("brush.mixer.sample", "exactly one sample point is required"));
}
let points = parse_points(p, "brush.mixer.sample")?;
if points.len() != 1 {
return Err(bad("brush.mixer.sample", "exactly one sample point is required"));
}
let mut brush = resolve_brush(s, p, "brush.mixer.sample")?;
if brush.size > 1024.0 {
return Err(bad("brush.mixer.sample", "sampling size is limited to 1024 pixels"));
}
brush.flow = 1.0;
let dabs = photocraft_paint::dabs(&Stroke { brush: brush.clone(), points });
let dab = dabs.first().ok_or_else(|| bad("brush.mixer.sample", "brush has no contact"))?;
let ctx = BrushContext::new(&brush);
let rect = ctx.dab_rect(dab, false);
if u64::from(rect.width()).saturating_mul(u64::from(rect.height())) > 4_194_304 {
return Err(bad("brush.mixer.sample", "sampling footprint is too large"));
}
let fp = ctx.footprint(dab, 0);
let st = s.active().ok_or(EngineError::NoDocument)?;
let mut doc = crate::float_cmds::displayed(st, (0, 0)).unwrap_or_else(|| (*st.doc).clone());
let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) };
let cmyk = doc_cmyk_space(s);
let load = photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || -> Result<Vec<[f32; 4]>> {
let surface = if flag(p, "sampleAllLayers", brush.mixer.sample_all_layers) && crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels {
let buf = photocraft_compose::render(&doc, rect);
let mut surface = Surface::new(PixelFormat::RGBA32F);
surface.write_region(rect, &buf.px.iter().flatten().copied().collect::<Vec<_>>());
surface
} else if crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels {
id.and_then(|id| doc.layer(id))
.and_then(|layer| layer.surface())
.cloned()
.ok_or_else(|| bad("brush.mixer.sample", "current layer has no raster pixels to sample"))?
} else {
crate::channel_cmds::target_surface(&mut doc, id, p)?.0.clone()
};
let mut load = Vec::with_capacity(4096);
let (sin, cos) = dab.angle.sin_cos();
for row in 0..64 {
for col in 0..64 {
let mut u = ((col as f32 + 0.5) / 32.0 - 1.0) * dab.radius;
let mut v = ((row as f32 + 0.5) / 32.0 - 1.0) * (dab.radius * dab.roundness).max(0.5);
if dab.flip_x {
u = -u;
}
if dab.flip_y {
v = -v;
}
let (mut dx, mut up) = (u * cos + v * sin, u * sin - v * cos);
if dab.proj_scale < 0.999 {
let (sin, cos) = dab.proj_angle.sin_cos();
let along = (dx * cos + up * sin) * (dab.proj_scale.max(0.05) - 1.0);
dx += along * cos;
up += along * sin;
}
let x = (dab.center.x as f32 + dx).floor() as i32;
let y = (dab.center.y as f32 - up).floor() as i32;
let mut color = surface.rgba(x, y);
let coverage = if x >= fp.rect.x0 && x < fp.rect.x1 && y >= fp.rect.y0 && y < fp.rect.y1 { fp.at(x, y) } else { 0.0 };
color[3] *= coverage.clamp(0.0, 1.0);
load.push(color);
}
}
Ok(load)
})?;
let mut average = [0.0f32; 4];
for c in &load {
for k in 0..3 {
average[k] += c[k] * c[3];
}
average[3] += c[3];
}
if average[3] <= 0.0 {
return Err(bad("brush.mixer.sample", "the sampled area contains no paint; the previous brush load is unchanged"));
}
for k in 0..3 {
average[k] /= average[3];
}
average[3] = 1.0;
s.tools.mixer.load(average);
s.tools.mixer.spatial = Some(load);
s.tools.mixer.pickup = None;
s.tools.mixer.color = Some(s.tools.foreground);
Ok(json!({"width":64,"height":64,"color":average}))
}
@@ -0,0 +1,444 @@
use super::mixer_live::LiveMixer;
use super::*;
use photocraft_color::PixelFormat;
use std::sync::Arc;
/// Build format-specific red/green sampling layers and an empty painting target.
fn fixture(fmt: PixelFormat) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width":192,"height":96,"background":"#808080"})).unwrap();
s.execute("layer.new.layer", json!({"name":"Source"})).unwrap();
s.edit("fixture", |doc, id| {
let (surface, _) = crate::channel_cmds::target_surface(doc, *id, &json!({}))?;
*surface = Surface::new(fmt);
surface.fill_rect(Rect::new(0, 0, 96, 96), &[1.0, 0.0, 0.0, 1.0]);
surface.fill_rect(Rect::new(96, 0, 192, 96), &[0.0, 1.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("layer.new.layer", json!({"name":"Paint"})).unwrap();
s.edit("depth", |doc, id| {
*crate::channel_cmds::target_surface(doc, *id, &json!({}))?.0 = Surface::new(fmt);
Ok(())
})
.unwrap();
s.tools.brush.mixer.sample_all_layers = true;
s
}
/// Snapshot the active raster target for exact pixel comparisons without borrowing the session.
fn target(s: &Session) -> Surface {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone()
}
/// All-layer previews match replay and Undo exactly across supported depths without editing source layers.
#[test]
fn mixer_live_empty_layer_exact_commit_undo_source_preservation_and_chunking() {
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
let mut s = fixture(fmt);
assert!(s.tools.brush.mixer.sample_all_layers);
let before = s.active().unwrap().doc.clone();
let params = json!({"points":[[40,48]],"seed":47,"size":24,"wet":100,"mix":100,"cleanAfterStroke":false});
let mut live = LiveMixer::begin(&s, &params).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
assert!(live.doc.layer(id).unwrap().surface().unwrap().rgba(40, 48)[3] > 0.0);
assert_eq!(target(&s).rgba(40, 48)[3], 0.0, "preview must not edit the session");
let mut points = vec![StrokePoint::new(40.0, 48.0, 1.0)];
for x in [56.0, 72.0, 88.0, 104.0, 120.0, 136.0, 152.0] {
let p = StrokePoint::new(x, 48.0, 1.0);
live.push(&[p]).unwrap();
points.push(p);
}
let preview = live.doc.layer(id).unwrap().surface().unwrap().clone();
let mut full = params.clone();
full["points"] = json!(points);
s.execute("paint.mixerBrush", full).unwrap();
assert_eq!(preview.read_region(before.bounds()), target(&s).read_region(before.bounds()), "live/replay tolerance is exactly zero");
for old in &before.layers {
if old.id != id {
assert_eq!(
old.surface().unwrap().read_region(before.bounds()),
s.active().unwrap().doc.layer(old.id).unwrap().surface().unwrap().read_region(before.bounds())
);
}
}
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(target(&s).read_region(before.bounds()), before.layer(id).unwrap().surface().unwrap().read_region(before.bounds()));
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(target(&s).read_region(before.bounds()), preview.read_region(before.bounds()));
}
}
/// Current-layer mixing matches the frozen kernel; dropping a preview and cleaning the load paint nothing.
#[test]
fn mixer_live_current_layer_matches_reference_and_cancels() {
let mut s = fixture(PixelFormat::RGBA32F);
let p = json!({"points":[[30,30],[60,34],[100,60]],"seed":9,"size":16,"sampleAllLayers":false,"wet":65,"mix":70,"load":90});
let mut expected = target(&s);
let brush = resolve_brush(&s, &p, "paint.mixerBrush").unwrap();
let mut state = photocraft_paint::mixer::MixerState::default();
state.load(s.tools.foreground);
photocraft_paint::mixer::apply_mixer_stroke(
&mut expected,
None,
&Stroke { brush, points: parse_points(&p, "paint.mixerBrush").unwrap() },
&MixerSettings { wet: 0.65, mix: 0.70, load: 0.90, sample_all_layers: false, ..Default::default() },
&mut state,
None,
false,
);
let live = LiveMixer::begin(&s, &p).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
let actual = live.doc.layer(id).unwrap().surface().unwrap().read_region(s.active().unwrap().doc.bounds());
let expected = expected.read_region(s.active().unwrap().doc.bounds());
let max = actual.iter().zip(expected).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(max <= 1e-6, "reference error {max}");
drop(live);
assert_eq!(target(&s).rgba(30, 30)[3], 0.0);
s.execute("brush.mixer.clean", json!({})).unwrap();
s.execute("paint.mixerBrush", json!({"points":[[30,30],[60,30]],"sampleAllLayers":false,"wet":0})).unwrap();
assert_eq!(target(&s).rgba(30, 30)[3], 0.0, "clean brush must stay empty");
}
/// Sampling retains a spatial two-color load while leaving document pixels and history alone.
#[test]
fn mixer_sample_is_spatial_and_never_edits_document() {
let mut s = fixture(PixelFormat::RGBA8);
let before = s.active().unwrap().doc.clone();
s.execute("brush.mixer.sample", json!({"points":[[96,48]],"size":40,"brush":{"hardness":1.0,"pressureSize":false}})).unwrap();
assert!(std::sync::Arc::ptr_eq(&before, &s.active().unwrap().doc));
let load = s.tools.mixer.spatial.as_ref().unwrap();
assert_eq!(load.len(), 4096);
assert!(load.iter().any(|p| p[0] > 0.9 && p[1] < 0.1 && p[3] > 0.9));
assert!(load.iter().any(|p| p[1] > 0.9 && p[0] < 0.1 && p[3] > 0.9));
s.execute("paint.mixerBrush", json!({"points":[[48,48]],"size":40,"wet":0,"mix":0,"brush":{"hardness":1.0,"pressureSize":false}})).unwrap();
assert!(target(&s).rgba(40, 48)[0] > 0.9);
assert!(target(&s).rgba(56, 48)[1] > 0.9);
assert!(s.tools.mixer.spatial.is_some(), "sample persists across strokes");
}
/// Smoothing tails match replay and invalid input cannot mutate the last valid preview.
#[test]
fn mixer_live_smoothing_tail_and_invalid_input() {
let mut s = fixture(PixelFormat::RGBA16);
let p = json!({"points":[[20,40]],"size":12,"seed":42,"brush":{"smoothing":{"amount":0.6,"catchUpOnEnd":true}}});
let mut live = LiveMixer::begin(&s, &p).unwrap();
live.push(&[StrokePoint::new(60.0, 40.0, 0.6)]).unwrap();
live.push(&[StrokePoint::new(100.0, 48.0, 1.0)]).unwrap();
let before = live.doc.clone();
assert!(live.push(&[StrokePoint::new(f64::NAN, 0.0, 1.0)]).is_err());
assert!(std::sync::Arc::ptr_eq(&before, &live.doc));
let mut commit = p.clone();
commit["points"] = json!([[20, 40, 1], [60, 40, 0.6], [100, 48, 1]]);
s.execute("paint.mixerBrush", commit).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
assert_eq!(
live.doc.layer(id).unwrap().surface().unwrap().read_region(s.active().unwrap().doc.bounds()),
target(&s).read_region(s.active().unwrap().doc.bounds())
);
assert!(s.execute("paint.mixerBrush", json!({"points":[[0,0]],"size":5001})).is_err());
assert!(s.execute("brush.mixer.sample", json!({"points":[[0,0],[1,1]]})).is_err());
}
/// Composite sampling honors source masks and blending while current-layer mode ignores lower paint.
#[test]
fn mixer_sampling_obeys_masks_opacity_blend_and_current_layer_switch() {
let mut s = fixture(PixelFormat::RGBA8);
let source = s.active().unwrap().doc.layers.iter().find(|l| l.name == "Source").unwrap().id;
s.edit("source composition", |doc, _| {
let layer = doc.layer_mut(source).unwrap();
layer.opacity = 0.4;
layer.blend = photocraft_color::BlendMode::Multiply;
layer.mask = Some(photocraft_doc::LayerMask::hide_all());
Ok(())
})
.unwrap();
let expected = photocraft_compose::render(&s.active().unwrap().doc, Rect::new(39, 39, 40, 40)).px[0];
s.execute("brush.mixer.sample", json!({"points":[[40,40]],"size":8})).unwrap();
let actual = s.tools.mixer.reservoir.unwrap();
for c in 0..3 {
assert!((actual[c] - expected[c]).abs() < 1e-5);
}
s.execute("brush.mixer.clean", json!({})).unwrap();
s.execute("paint.mixerBrush", json!({"points":[[40,40],[80,40]],"size":8,"wet":100,"mix":100,"sampleAllLayers":false})).unwrap();
assert_eq!(target(&s).rgba(50, 40)[3], 0.0, "current empty layer provides no paint");
s.execute("paint.mixerBrush", json!({"points":[[40,40],[80,40]],"size":8,"wet":100,"mix":100,"sampleAllLayers":true})).unwrap();
assert!(target(&s).rgba(50, 40)[3] > 0.9, "all layers supplies the visible composite");
}
/// Later overlapping dabs pick up freshly deposited paint from the live composite.
#[test]
fn mixer_overlapping_dabs_resample_the_evolving_composite() {
let mut s = fixture(PixelFormat::RGBA32F);
s.execute("tools.setColors", json!({"foreground":"#0000ff"})).unwrap();
let params = json!({"points":[[50,48]],"size":20,"seed":1,"wet":100,"mix":50,"cleanAfterStroke":false});
let mut live = LiveMixer::begin(&s, &params).unwrap();
live.push(&[StrokePoint::new(54.0, 48.0, 1.0), StrokePoint::new(50.0, 48.0, 1.0)]).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
let pixel = live.doc.layer(id).unwrap().surface().unwrap().rgba(50, 48);
assert!(pixel[2] > 0.5, "deposited blue feeds back into later pickups: {pixel:?}");
}
/// The shared live commit keeps shown pixels and one Undo step, refusing stale document snapshots.
#[test]
fn mixer_prepared_commit_uses_displayed_pixels_and_rejects_stale_documents() {
let mut s = fixture(PixelFormat::RGBA8);
let p = json!({"points":[[20,40]],"size":16,"target":"pixels","seed":55});
let mut live = LiveMixer::begin(&s, &p).unwrap();
live.push(&[StrokePoint::new(80.0, 40.0, 0.6)]).unwrap();
let shown = live.doc.clone();
let id = s.active().unwrap().active_layer.unwrap();
let steps = s.active().unwrap().history.past_len();
let commit = live.prepare_commit(&mut s).unwrap();
assert!(s.live_commit.is_some());
s.execute("paint.mixerBrush", commit.clone()).unwrap();
assert!(s.live_commit.is_none());
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
assert_eq!(target(&s).read_region(shown.bounds()), shown.layer(id).unwrap().surface().unwrap().read_region(shown.bounds()));
s.execute("edit.undo", json!({})).unwrap();
s.execute("paint.mixerBrush", commit).unwrap();
assert_eq!(target(&s).read_region(shown.bounds()), shown.layer(id).unwrap().surface().unwrap().read_region(shown.bounds()));
let stale = LiveMixer::begin(&s, &p).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
assert!(stale.prepare_commit(&mut s).is_err());
}
/// Load and clean affect tool state without dropping a floating selection or creating document history.
#[test]
fn mixer_load_commands_preserve_floating_selection_and_history() {
let mut s = fixture(PixelFormat::RGBA8);
s.execute("select.all", json!({})).unwrap();
s.execute("select.float", json!({"dx":8,"dy":0,"copy":true})).unwrap();
let doc = s.active().unwrap().doc.clone();
let steps = s.active().unwrap().history.past_len();
for (command, params) in [("brush.mixer.sample", json!({"points":[[96,48]],"size":24})), ("brush.mixer.load", json!({})), ("brush.mixer.clean", json!({}))]
{
s.execute(command, params).unwrap();
assert!(std::sync::Arc::ptr_eq(&doc, &s.active().unwrap().doc));
assert_eq!(s.active().unwrap().history.past_len(), steps);
assert!(crate::float_cmds::floating(s.active().unwrap()).is_some());
}
}
/// Malformed reservoir input is rejected and automatic reload follows the selected setting.
#[test]
fn mixer_rejects_malformed_load_and_retains_or_reloads_paint() {
let mut s = fixture(PixelFormat::RGBA8);
let before = s.active().unwrap().doc.clone();
for state in [json!({"spatial":[]}), json!({"spatial":[[1,0,0,1]]}), json!({"amount":"bad"}), json!({"reservoir":[1e300,0,0,1]})] {
assert!(s.execute("paint.mixerBrush", json!({"points":[[40,40]],"mixerState":state})).is_err());
assert!(std::sync::Arc::ptr_eq(&before, &s.active().unwrap().doc));
}
assert!(s.execute("brush.mixer.load", json!({"color":[1e300,0,0,1]})).is_err());
s.execute("brush.mixer.load", json!({"color":"#ff0000"})).unwrap();
s.execute("paint.mixerBrush", json!({"points":[[40,40],[80,40]],"size":10,"loadAfterStroke":false,"cleanAfterStroke":false,"load":10})).unwrap();
assert!(s.tools.mixer.amount > 0.0 && s.tools.mixer.amount < 1.0);
s.execute("paint.mixerBrush", json!({"points":[[40,40],[80,40]],"size":10,"loadAfterStroke":true,"load":10})).unwrap();
assert_eq!(s.tools.mixer.amount, 1.0);
}
/// Large tips keep off-canvas layer pixels and commit the exact preview with reversible history.
#[test]
fn mixer_large_tip_retains_off_canvas_paint_and_commits_the_live_result() {
let mut s = fixture(PixelFormat::RGBA8);
let source = s.active().unwrap().doc.clone();
let params = json!({"points":[[96,48]],"size":1024,"wet":0,"mix":0,"color":"#1122ee","brush":{"pressureSize":false}});
let live = LiveMixer::begin(&s, &params).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
let shown = live.doc.layer(id).unwrap().surface().unwrap().clone();
assert!(shown.rgba(-100, 48)[3] > 0.99, "off-canvas layer pixels remain paintable");
let params = live.prepare_commit(&mut s).unwrap();
s.execute("paint.mixerBrush", params).unwrap();
assert_eq!(target(&s), shown);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.active().unwrap().doc, *source);
}
/// Large pointer jumps must replay identically instead of turning a valid stroke into an input-limit error.
#[test]
fn mixer_long_segments_are_interpolated_and_replay_matches_live() {
let mut s = fixture(PixelFormat::RGBA8);
let p = json!({"points":[[20,48],[5020,48]],"seed":71,"size":2,"sampleAllLayers":false,"wet":0,"mix":0,"load":100,"color":"#ff0000"});
let live = LiveMixer::begin(&s, &p).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
let expected = live.doc.layer(id).unwrap().surface().unwrap().clone();
s.execute("paint.mixerBrush", p).unwrap();
for x in [20, 1020, 2068, 4090, 5019] {
assert_eq!(target(&s).rgba(x, 48), expected.rgba(x, 48));
assert!(target(&s).rgba(x, 48)[3] > 0.0);
}
}
/// Authorization, validation, unknown commands and missing targets all discard the single-use offer.
#[test]
fn mixer_prepared_offer_is_dropped_on_every_early_dispatch_failure() {
for failure in 0..4 {
let mut s = fixture(PixelFormat::RGBA8);
let live = LiveMixer::begin(&s, &json!({"points":[[20,20]],"size":4})).unwrap();
let p = live.prepare_commit(&mut s).unwrap();
let before = s.active().unwrap().doc.clone();
let result = match failure {
0 => {
s.authorize = Some(|_, _| Err(EngineError::Other("denied".into())));
s.execute("paint.mixerBrush", p)
}
1 => s.execute("paint.mixerBrush", json!("invalid params")),
2 => s.execute("unknown.command", p),
_ => {
s.active_mut().unwrap().active_layer = None;
s.execute("paint.mixerBrush", p)
}
};
assert!(result.is_err());
assert!(s.live_commit.is_none(), "failed dispatch retained the preview: {failure}");
assert!(Arc::ptr_eq(&s.active().unwrap().doc, &before));
}
}
/// A stroke beyond the live tile budget still renders on command replay and remains undoable.
#[test]
fn mixer_command_outgrows_the_preview_tile_budget_without_losing_the_stroke() {
let mut s = fixture(PixelFormat::RGBA8);
let points: Vec<_> = (0..257).map(|i| [128.0 + (i % 17) as f64 * 512.0, 128.0 + (i / 17) as f64 * 512.0]).collect();
let p = json!({"points":points,"size":2,"brush":{"spacing":10.0,"smoothing":{"amount":0.0}},"seed":44,
"sampleAllLayers":false,"wet":0,"mix":0,"load":100,"color":"#ff0000"});
let before = s.active().unwrap().doc.clone();
let err = LiveMixer::begin(&s, &p).err().expect("bounded preview");
assert!(err.to_string().contains("256 tiles"), "{err}");
assert!(Arc::ptr_eq(&s.active().unwrap().doc, &before));
s.execute("paint.mixerBrush", p).unwrap();
// Spacing deliberately leaves gaps; inspect the final tile's stroke region instead
// of assuming the exact release pixel is a dab center.
let end = Rect::new(600, 7776, 672, 7840);
assert!(target(&s).read_region(end).as_chunks::<4>().0.iter().any(|px| px[3] > 0.0));
s.execute("edit.undo", json!({})).unwrap();
assert!(target(&s).read_region(end).as_chunks::<4>().0.iter().all(|px| px[3] == 0.0));
}
/// An empty sample reports an error while retaining the previous paint load and document.
#[test]
fn mixer_empty_samples_preserve_the_previous_load_and_document() {
let mut s = fixture(PixelFormat::RGBA8);
s.execute("brush.mixer.sample", json!({"points": [[96,48]], "size": 40})).unwrap();
let state = s.tools.mixer.clone();
let doc = s.active().unwrap().doc.clone();
for params in [json!({"points": [[20,20]], "size": 8, "sampleAllLayers": false}), json!({"points": [[-100,-100]], "size": 8, "sampleAllLayers": true})] {
let error = s.execute("brush.mixer.sample", params).unwrap_err().to_string();
assert!(error.contains("contains no paint"), "{error}");
assert_eq!(s.tools.mixer, state);
assert!(Arc::ptr_eq(&s.active().unwrap().doc, &doc));
}
}
/// Rotating a hard round sampling grid must give zero coverage beyond the footprint, not panic.
#[test]
fn mixer_rotated_hard_tip_samples_safely_and_preserves_the_document() {
for angle in [45.0, -45.0, 135.0] {
let mut s = fixture(PixelFormat::RGBA8);
s.execute("tools.setBrush", json!({"brush":{"size":40,"angle":angle,"hardness":1.0,"pressureSize":false}})).unwrap();
let before = s.active().unwrap().doc.clone();
s.execute("brush.mixer.sample", json!({"points":[[48,48]]})).unwrap();
let load = s.tools.mixer.spatial.as_ref().unwrap();
assert_eq!(load.len(), 4096);
assert!(load.iter().any(|c| c[3] == 0.0));
assert!(load.iter().any(|c| c[0] > 0.9 && c[3] > 0.9));
assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc));
}
}
/// The default stays current-layer-only; all-layer sampling is an explicit user choice.
#[test]
fn mixer_default_does_not_sample_other_layers() {
assert!(!MixerSettings::default().sample_all_layers);
assert!(!Session::new().tools.brush.mixer.sample_all_layers);
}
/// Large session tips and spatial reservoirs stay out of each journaled stroke.
#[test]
fn mixer_shared_live_commit_keeps_the_journal_compact() {
let mut s = fixture(PixelFormat::RGBA8);
s.execute("brush.mixer.sample", json!({"points":[[96,48]],"size":40})).unwrap();
s.tools.brush.tip = TipShape::Sampled(GrayTile::from_fn(256, 256, |_, _| 1.0));
for x in [40.0, 60.0, 80.0] {
let p = json!({"points":[[x,48]],"size":12,"seed":7,"wet":0,"mix":0});
let live = LiveMixer::begin(&s, &p).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
let shown = live.doc.layer(id).unwrap().surface().unwrap().clone();
let params = live.prepare_commit(&mut s).unwrap();
assert!(s.live_commit.is_some());
let result = s.execute("paint.mixerBrush", params).unwrap();
assert_eq!(result["fromLive"], true);
assert_eq!(target(&s), shown);
assert!(s.live_commit.is_none());
let params = &s.journal.last().unwrap().1;
for field in ["brush", "mixerState", "color"] {
assert!(params.get(field).is_none(), "{field}");
}
assert!(serde_json::to_vec(params).unwrap().len() < 1024);
}
}
/// Preview limits never reject valid command sizes, including the maximum session size.
#[test]
fn mixer_large_brushes_replay_after_the_preview_budget() {
for (size, pressure) in [(2000, 1.0), (5000, 1.0)] {
let mut s = fixture(PixelFormat::RGBA8);
let before = s.active().unwrap().doc.clone();
let params = json!({"points":[[96,48,pressure]],"size":size,"wet":0,"mix":0,"sampleAllLayers":false,
"color":"#ff0000","brush":{"pressureSize":true,"hardness":1.0}});
assert!(LiveMixer::begin(&s, &params).is_err());
s.execute("paint.mixerBrush", params).unwrap();
assert!(target(&s).rgba(96, 48)[0] > 0.9);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.active().unwrap().doc, *before);
}
}
/// Long strokes past the live preview's sample and dab budgets still render on command replay
/// (Stroke Path with Simulate Pressure sends up to 200,000 samples), as main painted them.
#[test]
fn mixer_command_paints_strokes_beyond_the_preview_sample_and_dab_budgets() {
// 70,000 samples a pixel apart (above 65,536), or 2,000 samples 170 px apart (about 340,000 dabs).
for (count, step, budget) in [(70_000, 1, "65536"), (2_000, 170, "250000 dabs")] {
let mut s = fixture(PixelFormat::RGBA8);
// Zig-zagging along one row, one dab per pixel.
let points: Vec<_> = (0..count).map(|i: i32| [f64::from(10 + (i * step % 340 - 170).abs()), 48.0]).collect();
let p = json!({"points":points,"size":2,"brush":{"spacing":0.5,"hardness":1.0,"pressureSize":false,"smoothing":{"amount":0.0}},
"seed":7,"sampleAllLayers":false,"wet":0,"mix":0,"load":100,"color":"#ff0000"});
let err = LiveMixer::begin(&s, &p).err().expect("bounded preview");
assert!(err.to_string().contains(budget), "{err}");
let steps = s.active().unwrap().history.past_len();
s.execute("paint.mixerBrush", p).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
assert!(target(&s).rgba(100, 48)[0] > 0.9);
}
}
+106 -22
View File
@@ -94,7 +94,7 @@ impl ChannelView {
/// The layer mask shown on the canvas (#196) and how, if its layer still has a mask.
pub fn shown_layer_mask<'a>(&self, doc: &'a Document) -> Option<(&'a photocraft_doc::LayerMask, crate::mask_view_cmds::MaskViewMode)> {
let v = self.layer_mask?;
Some((doc.layer(v.layer)?.mask.as_ref()?, v.mode))
Some((crate::mask_view_cmds::viewed_mask(doc.layer(v.layer)?, v.filter)?, v.mode))
}
}
@@ -212,6 +212,8 @@ pub enum GraySurface {
Alpha(usize),
/// The Quick Mask.
QuickMask,
/// The active smart object's filter mask (FILE-215-3).
FilterMask,
}
/// Isolated Curves channel for the active document, matching [`inject_target`].
@@ -246,6 +248,7 @@ pub fn isolated_curve_channel_for(st: &DocState, layers_panel_mask: bool) -> Opt
Some(IsolatedCurveChannel { name: "Quick Mask".into(), key: "", hist: 0, tint: false, translate: true, gray: Some(GraySurface::QuickMask) })
}
ChannelTarget::LayerMask => layer_mask_curve_channel(st),
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some_and(|v| v.filter) => filter_mask_curve_channel(st),
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() || layers_panel_mask => layer_mask_curve_channel(st),
_ => None,
}
@@ -258,6 +261,15 @@ fn layer_mask_curve_channel(st: &DocState) -> Option<IsolatedCurveChannel> {
Some(IsolatedCurveChannel { name, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::LayerMask) })
}
/// The Curves target when the active smart object's filter mask is targeted (its thumbnail on the
/// Smart Filters row, FILE-215-3): a gray plane, though the mask may not exist until the first edit.
pub fn filter_mask_curve_channel(st: &DocState) -> Option<IsolatedCurveChannel> {
let layer = st.active_layer.and_then(|id| st.doc.layer(id))?;
crate::smart_cmds::filter_mask_paintable(layer).ok()?;
let name = format!("{} Filter Mask", layer.name);
Some(IsolatedCurveChannel { name, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::FilterMask) })
}
fn edit_mode(doc: &Document) -> ColorMode {
doc.pixel_format().mode
}
@@ -602,6 +614,9 @@ fn mask_plane(s: &Session, p: &Value, cmd: &str) -> Result<Option<Vec<f32>>> {
pub(crate) enum Target {
Pixels,
Mask,
/// A smart object's filter mask (`"target":"filterMask"`): where it is black the smart
/// filters don't show (FILE-215-3).
FilterMask,
Alpha(usize),
QuickMask,
}
@@ -610,6 +625,7 @@ pub(crate) fn target_of(p: &Value) -> Target {
match p.get("target") {
Some(Value::String(s)) => match s.as_str() {
"mask" => Target::Mask,
"filterMask" => Target::FilterMask,
"quickMask" => Target::QuickMask,
_ => Target::Pixels,
},
@@ -654,6 +670,13 @@ pub(crate) fn read_target_surface<'a>(doc: &'a Document, layer: Option<LayerId>,
let id = layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
doc.layer(id).and_then(|l| l.mask.as_ref()).map(|m| &m.surface).ok_or(EngineError::NoLayer(id))
}
Target::FilterMask => {
let id = layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
doc.layer(id)
.and_then(|l| crate::mask_view_cmds::viewed_mask(l, true))
.map(|m| &m.surface)
.ok_or_else(|| EngineError::Other("the layer has no filter mask".into()))
}
}
}
@@ -686,10 +709,17 @@ fn edits_mask(id: &str) -> bool {
/// The precondition of `id` when `p` targets the active layer's mask (`"target":"mask"`): the
/// layer needs a mask. None when another precondition applies.
pub(crate) fn mask_target_enabled(s: &Session, id: &str, p: &Value) -> Option<std::result::Result<(), String>> {
if target_of(p) != Target::Mask || !edits_mask(id) {
return None;
match target_of(p) {
// The commands that paint, fill, filter or adjust the filter mask need a smart object with
// smart filters, not a pixel layer. Paste keeps its own check (something to paste).
Target::FilterMask if follows_filter_mask(id) && !PASTE_IDS.contains(&id) => {
Some(crate::active_layer_of(s).and_then(crate::smart_cmds::filter_mask_paintable))
}
Target::Mask if edits_mask(id) => {
Some(crate::active_layer_of(s).and_then(|l| if l.mask.is_some() { Ok(()) } else { Err("the active layer has no layer mask".into()) }))
}
_ => None,
}
Some(crate::active_layer_of(s).and_then(|l| if l.mask.is_some() { Ok(()) } else { Err("the active layer has no layer mask".into()) }))
}
/// Whether command `id` edits the targeted channel or mask when its params name no `"target"`
@@ -700,15 +730,68 @@ pub fn follows_target(id: &str) -> bool {
routed(id) || id == "select.float" || id == "layer.translate"
}
/// Whether command `id` edits a smart object's targeted filter mask (`"target":"filterMask"`):
/// every routed command but those that act on the smart object itself while its filter mask is
/// targeted (Edit › Transform, Apply Image, Convert for Smart Filters, Last Filter, Liquify, the
/// lens filters and Vanishing Point, which add a smart filter, the render filters that make a new
/// layer, and Desaturate), so neither the shell nor the engine sends those the filter mask.
pub fn follows_filter_mask(id: &str) -> bool {
routed(id)
&& !id.starts_with("edit.transform.")
&& !matches!(
id,
"image.applyImage"
| "image.adjustments.desaturate"
| "filter.vanishingPoint"
| "filter.render.flame"
| "filter.render.tree"
| "filter.render.pictureFrame"
| "filter.convertForSmartFilters"
| "filter.lastFilter"
| crate::distort_cmds::LIQUIFY
| crate::lens_cmds::LENS
| crate::lens_cmds::WIDE
| crate::lens_cmds::RAW
)
}
/// The paint and retouch tools that follow the targeted channel or mask.
const PAINT_IDS: &[&str] = &[
"paint.stroke",
"paint.pencil",
"paint.bucket",
"paint.gradient",
"paint.mixerBrush",
"paint.cloneStamp",
"paint.patternStamp",
"paint.healingBrush",
"paint.spotHealing",
"paint.dodge",
"paint.burn",
"paint.sponge",
"paint.blur",
"paint.sharpen",
"paint.smudge",
"paint.historyBrush",
"paint.artHistoryBrush",
"paint.redEye",
"paint.remove",
];
/// Pasting into a targeted mask or channel (#1035).
const PASTE_IDS: &[&str] = &["edit.paste", "edit.pasteSpecial.pasteInPlace", "edit.pasteSpecial.pasteInto", "edit.pasteSpecial.pasteOutside"];
/// Commands whose target follows the Channels panel.
pub(crate) fn routed(id: &str) -> bool {
id.starts_with("filter.")
|| id == "plugin.run"
|| id.starts_with("image.adjustments.")
|| matches!(id, "paint.stroke" | "paint.pencil" | "paint.bucket" | "paint.gradient" | "paint.mixerBrush" | "edit.fill" | "image.applyImage")
|| matches!(id, "edit.fill" | "image.applyImage")
|| crate::fill_key_cmds::IDS.contains(&id)
|| PAINT_IDS.contains(&id)
// Pasting into a targeted mask or channel (#1035); Copy takes a targeted mask (#2928).
|| matches!(id, "edit.copy" | "edit.paste" | "edit.pasteSpecial.pasteInPlace" | "edit.pasteSpecial.pasteInto" | "edit.pasteSpecial.pasteOutside")
|| id == "edit.copy"
|| PASTE_IDS.contains(&id)
// Edit › Transform › Rotate/Flip turn a targeted mask or channel (#2835).
|| matches!(
id,
@@ -718,22 +801,6 @@ pub(crate) fn routed(id: &str) -> bool {
| "edit.transform.flipHorizontal"
| "edit.transform.flipVertical"
)
|| matches!(
id,
"paint.cloneStamp"
| "paint.patternStamp"
| "paint.healingBrush"
| "paint.spotHealing"
| "paint.dodge"
| "paint.burn"
| "paint.sponge"
| "paint.blur"
| "paint.sharpen"
| "paint.smudge"
| "paint.historyBrush"
| "paint.redEye"
| "paint.remove"
)
}
/// Fill in `"target"` from the targeted channel (or Quick Mask mode) when the caller gave none.
@@ -747,6 +814,13 @@ pub(crate) fn inject_target(s: &Session, id: &str, params: Value) -> Value {
ChannelTarget::Composite if st.doc.quick_mask.is_some() => json!("quickMask"),
ChannelTarget::LayerMask => json!("mask"),
// Viewing the active layer's mask (⌥-click): pixel commands edit the mask.
// A shown filter mask takes only the commands that edit it; the rest act on the smart object.
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some_and(|v| v.filter) => {
if !follows_filter_mask(id) {
return params;
}
json!("filterMask")
}
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() => json!("mask"),
_ => return params,
};
@@ -759,6 +833,16 @@ pub(crate) fn inject_target(s: &Session, id: &str, params: Value) -> Value {
}
}
/// The smart object whose filter mask command `id` with `params` edits (`"target":"filterMask"`):
/// its `"layer"` param, else the active layer. None for a command that doesn't edit the filter
/// mask, so moving the smart object doesn't re-render its filters.
pub(crate) fn filter_mask_layer(s: &Session, id: &str, params: &Value) -> Option<LayerId> {
if target_of(params) != Target::FilterMask || !follows_filter_mask(id) {
return None;
}
params.get("layer").and_then(Value::as_u64).map(LayerId).or_else(|| s.active()?.active_layer)
}
/// The colour channel pixel commands are limited to, if a single colour channel is targeted.
pub(crate) fn color_restriction(s: &Session, id: &str, params: &Value) -> Option<usize> {
if !routed(id) || is_channel_target(params) || crate::commands::is_mask_target(params) {
+11 -7
View File
@@ -65,13 +65,17 @@ pub(crate) fn has_paintable(s: &Session) -> std::result::Result<(), String> {
}
}
/// Surface a paint command writes to: the layer's pixels, or its mask with `"target":"mask"`.
/// Surface a paint command writes to: the layer's pixels, its mask with `"target":"mask"`, or a
/// smart object's filter mask with `"target":"filterMask"`.
pub(crate) fn paint_surface<'a>(doc: &'a mut Document, id: LayerId, p: &Value) -> Result<&'a mut photocraft_raster::Surface> {
if is_mask_target(p) {
check_all_unlocked(doc, id)?;
} else {
check_pixels_unlocked(doc, id)?;
}
if crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::FilterMask {
return crate::smart_cmds::filter_mask_surface(doc, id);
}
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if is_mask_target(p) {
l.mask.as_mut().map(|m| &mut m.surface).ok_or_else(|| EngineError::Other("layer has no mask".into()))
@@ -99,8 +103,10 @@ pub(crate) fn check_pixels_unlocked(doc: &Document, id: LayerId) -> Result<()> {
Ok(())
}
/// The params target a grayscale mask of the layer: its layer mask (`"mask"`) or, on a smart
/// object, its filter mask (`"filterMask"`).
pub(crate) fn is_mask_target(p: &Value) -> bool {
p.get("target").and_then(Value::as_str) == Some("mask")
matches!(p.get("target").and_then(Value::as_str), Some("mask" | "filterMask"))
}
fn can_undo(s: &Session) -> std::result::Result<(), String> {
@@ -467,9 +473,7 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value)
return s.edit(label, |doc, _| {
check_all_unlocked(doc, id)?;
let sel = doc.selection.clone();
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let mask = l.mask.as_mut().ok_or_else(|| EngineError::Other("layer has no mask".into()))?;
pixels::adjust_mask(&mut mask.surface, &adj, sel.as_ref()).map_err(EngineError::Other)?;
pixels::adjust_mask(paint_surface(doc, id, p)?, &adj, sel.as_ref()).map_err(EngineError::Other)?;
Ok(Value::Null)
});
}
@@ -614,7 +618,7 @@ fn build() -> Vec<CommandSpec> {
"Fill…",
["Edit"],
Some("Shift+F5"),
r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":row?,"snapshot":id? (contents=history: a History panel row as history.setSource, redo rows last, or a snapshot; default the History Brush source),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##,
r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":row?,"snapshot":id? (contents=history: a History panel row as history.setSource, redo rows last, or a snapshot; default the History Brush source),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##,
has_pixel_or_channel,
crate::fill_cmds::fill
),
@@ -1195,7 +1199,7 @@ fn build() -> Vec<CommandSpec> {
"Brush Stroke",
[],
None,
r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…BrushSettings}?,"preset":name?,"size":0.5..5000 px?,"hardness":0..1?,"opacity":0..1?,"flow":0..1?,"spacing":0..10?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|clear (erases with this brush)"="normal","erase":bool?,"smoothing":0..1?,"zoom":number=1,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##,
r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…BrushSettings}?,"preset":name?,"size":0.5..5000 px?,"hardness":0..1?,"opacity":0..1?,"flow":0..1?,"spacing":0..10?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|clear (erases with this brush)"="normal","erase":bool?,"smoothing":0..1?,"zoom":number=1,"seed":u64?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##,
has_paintable,
crate::brush_cmds::paint_stroke
),
+40 -3
View File
@@ -8,9 +8,15 @@
//! layers keep their transparency there, as after a plain crop. Each connected piece of the
//! uncovered area (each corner, each edge strip) is filled on its own, with Photoshop's sampling
//! window around that piece, and only pixels the image covers are sampled.
//!
//! Pixels an earlier crop with Delete Cropped Pixels off hid past the canvas edges are still the
//! layer's. Under a turned frame they count as covered, so they are kept (and sampled) rather than
//! filled over; a straight crop fills all of the area past the old canvas, them included, as
//! Photoshop does (#8060).
use photocraft_doc::{Document, LayerId};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use rayon::prelude::*;
use crate::{EngineError, Result};
@@ -22,7 +28,7 @@ const SEED: u64 = 1;
const MAX_PIXELS: u64 = 400_000_000;
/// Where the cropped canvas came from in the original document.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Debug)]
pub(crate) struct Coverage {
/// Cropped canvas → original document: `[a, b, c, d, e, f]` with
/// `x = a·u + b·v + e`, `y = c·u + d·v + f`.
@@ -32,12 +38,23 @@ pub(crate) struct Coverage {
pub height: f64,
/// Whether the map rotates (its resampled edge is antialiased against the background colour).
pub rotated: bool,
/// The filled layer's pixels before a turned crop (original document px), set by
/// [`Coverage::holding`]: where they lie past the old canvas they are hidden image, not
/// uncovered area. A straight crop has none: Photoshop fills over hidden pixels there.
pub held: Option<Surface>,
}
impl Coverage {
/// An unrotated crop to `r` (original document px).
pub fn crop(r: Rect, width: u32, height: u32) -> Self {
Coverage { map: [1.0, 0.0, 0.0, 1.0, f64::from(r.x0), f64::from(r.y0)], width: f64::from(width), height: f64::from(height), rotated: false }
Coverage { map: [1.0, 0.0, 0.0, 1.0, f64::from(r.x0), f64::from(r.y0)], width: f64::from(width), height: f64::from(height), rotated: false, held: None }
}
/// Takes the pixels of the layer the fill will go to from `doc` before a turned crop (the
/// surface is shared, not copied), so hidden pixels past the old canvas count as covered.
pub fn holding(mut self, doc: &Document, active: Option<LayerId>) -> Self {
self.held = target_layer(doc, active).ok().and_then(|id| doc.layer(id)).and_then(|l| l.surface()).cloned();
self
}
/// Whether the cropped canvas's pixel (u, v) is the original image's: its centre maps inside
@@ -48,7 +65,27 @@ impl Coverage {
let [a, b, c, d, e, f] = self.map;
let (cu, cv) = (f64::from(u) + 0.5, f64::from(v) + 0.5);
let (x, y) = (a * cu + b * cv + e, c * cu + d * cv + f);
x >= margin && y >= margin && x <= self.width - margin && y <= self.height - margin
(x >= margin && y >= margin && x <= self.width - margin && y <= self.height - margin) || self.holds(x, y)
}
/// Whether the layer held an image pixel at original document point (x, y) past the canvas
/// (#8060): mostly opaque there and one pixel around it too (the turned edge is blended with
/// the background colour, as at the canvas edge). Without an alpha channel
/// hidden pixels can't be told from empty ones: nothing is held.
fn holds(&self, x: f64, y: f64) -> bool {
let Some(surf) = &self.held else { return false };
let format = surf.format();
if !format.alpha || !(x.is_finite() && y.is_finite()) {
return false;
}
let a = surf.channels().saturating_sub(1);
let at = |x: f64, y: f64| {
let (fx, fy) = (x.floor(), y.floor());
let limit = f64::from(i32::MAX);
fx.abs() < limit && fy.abs() < limit && surf.sample_channel(fx as i32, fy as i32, a) > 0.5
};
const ROUND: [(f64, f64); 5] = [(0.0, 0.0), (-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)];
ROUND.iter().all(|&(dx, dy)| at(x + dx, y + dy))
}
fn margin(&self) -> f64 {
+9 -6
View File
@@ -307,15 +307,18 @@ fn copy(s: &mut Session, merged: bool) -> Result<Value> {
Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()]}))
}
/// Copy with the layer mask targeted (`"target":"mask"`, #2928): the mask's values as a
/// Copy the targeted layer mask (`"target":"mask"`, #2928) or smart-filter mask
/// (`"target":"filterMask"`): the mask's values as a
/// grayscale clip, inside the selection or the whole canvas — an untouched mask copies at its
/// default value, so a plain Reveal All / Hide All mask transfers too. Paste then writes into
/// another layer's mask (targeted, #1035), in this or another document.
fn copy_mask(s: &mut Session) -> Result<Value> {
fn copy_mask(s: &mut Session, filter: bool) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let canvas = d.doc.bounds();
let id = d.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
let mask = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?.mask.as_ref().ok_or_else(|| EngineError::Other("the active layer has no layer mask".into()))?;
let layer = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let mask = crate::mask_view_cmds::viewed_mask(layer, filter)
.ok_or_else(|| EngineError::Other(if filter { "the active layer has no filter mask" } else { "the active layer has no layer mask" }.into()))?;
let sel = d.doc.selection.as_ref();
let area = sel.map_or(canvas, |m| m.content_bounds().intersect(&canvas));
if area.is_empty() {
@@ -347,7 +350,7 @@ fn copy_mask(s: &mut Session) -> Result<Value> {
let b = bounds;
let (resolution, source) = (Some(d.doc.resolution_dpi), Some(d.doc.id));
s.clipboard = Some(Clip { surface, bounds: b, layers: None, resolution, source });
Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()], "target": "mask"}))
Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()], "target": if filter { "filterMask" } else { "mask" }}))
}
fn clear_selected(doc: &mut Document, id: LayerId, background: [f32; 4]) -> Result<()> {
@@ -999,11 +1002,11 @@ pub fn specs() -> Vec<CommandSpec> {
"Copy",
&["Edit"],
Some("Cmd+C"),
r##"{"target":"mask"? = the targeted mask, copied as grayscale (#2928)} (a targeted colour or alpha channel copies that channel, as grayscale)"##,
r##"{"target":"mask|filterMask"? = the targeted mask, copied as grayscale (#2928)} (a targeted colour or alpha channel copies that channel, as grayscale)"##,
can_copy,
|s, p| {
if crate::commands::is_mask_target(p) {
return copy_mask(s);
return copy_mask(s, crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::FilterMask);
}
match crate::channel_clip::copy(s)? {
Some(r) => Ok(r),
+1 -1
View File
@@ -18,7 +18,7 @@ use crate::{EngineError, Result, Session};
/// The fill-key command ids (routed to the Channels panel target like `edit.fill`).
pub const IDS: [&str; 5] = ["edit.fillForeground", "edit.fillBackground", "edit.fillForegroundPreserve", "edit.fillBackgroundPreserve", "edit.fillHistory"];
const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##;
const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##;
/// The shape or type layer the colour fill keys recolor instead of filling pixels.
fn target_live_layer(s: &Session, p: &Value) -> Option<LayerId> {
+2 -2
View File
@@ -161,9 +161,9 @@ pub(crate) fn validate_params(id: &str, params: &Value) -> Result<()> {
"layer" => (value.as_u64().is_some(), "an unsigned layer id".into()),
"coalesce" => (value.is_string(), "a string".into()),
"target" => (
value.as_str().is_some_and(|s| matches!(s, "pixels" | "mask" | "quickMask"))
value.as_str().is_some_and(|s| matches!(s, "pixels" | "mask" | "filterMask" | "quickMask"))
|| value.as_object().is_some_and(|o| o.len() == 1 && o.get("channel").and_then(Value::as_u64).is_some()),
"pixels, mask, quickMask or {\"channel\":index}".into(),
"pixels, mask, filterMask, quickMask or {\"channel\":index}".into(),
),
_ => return Err(bad(format!("unknown parameter `{key}`"))),
}
+3 -2
View File
@@ -291,7 +291,7 @@ fn float(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
if floating(st).is_none() {
let layer = st.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
let parts = CutParts::new(&st.doc, layer, copy, background, crate::commands::is_mask_target(p))?;
let parts = CutParts::new(&st.doc, layer, copy, background, crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Mask)?;
st.floating = Some(Floating { layer, offset: (0, 0), revision: st.revision, parts: Arc::new(parts), moved: false });
}
let f = st.floating.as_mut().ok_or(EngineError::NoDocument)?;
@@ -333,7 +333,8 @@ pub fn drop_floating(s: &mut Session) -> Result<Value> {
/// Commands that leave a floating piece floating: its own, and ones that don't touch documents.
fn keeps_floating(id: &str) -> bool {
matches!(id, "select.float" | "select.drop") || ["view.", "window.", "help.", "prefs."].iter().any(|p| id.starts_with(p))
matches!(id, "select.float" | "select.drop" | "brush.mixer.sample" | "brush.mixer.load" | "brush.mixer.clean")
|| ["view.", "window.", "help.", "prefs."].iter().any(|p| id.starts_with(p))
}
/// Before any command (`Session::dispatch`): Undo puts a floating piece back (and is used up:
+1
View File
@@ -32,6 +32,7 @@ const GATED: &[&str] = &[
"paint.sharpen",
"paint.smudge",
"paint.historyBrush",
"paint.artHistoryBrush",
"layer.translate",
];
+34 -7
View File
@@ -101,6 +101,12 @@ fn snapshot_index(s: &Session, cmd: &str, p: &Value) -> Result<usize> {
st.snapshots.iter().position(|snap| snap.id == id).ok_or_else(|| bad(cmd, format!("no snapshot {id}")))
}
/// The name `history.newSnapshot` gives a snapshot without one: "Snapshot N" (the New Snapshot
/// dialog suggests it too).
pub fn next_snapshot_name(st: &crate::DocState) -> String {
format!("Snapshot {}", st.next_snapshot)
}
/// A non-blank `name` param, trimmed; `None` when absent.
fn name_param(cmd: &str, p: &Value) -> Result<Option<String>> {
match p.get("name") {
@@ -140,9 +146,9 @@ fn new_snapshot(s: &mut Session, p: &Value) -> Result<Value> {
_ => (st.doc.clone(), st.layer_target()),
};
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
let name = name.unwrap_or_else(|| next_snapshot_name(st));
let id = st.next_snapshot;
st.next_snapshot = id.saturating_add(1);
let name = name.unwrap_or_else(|| format!("Snapshot {id}"));
st.snapshots.push(Snapshot { id, name: name.clone(), doc, layers });
Ok(json!({ "snapshot": id, "name": name }))
}
@@ -413,7 +419,8 @@ mod tests {
assert_eq!(entries(&s), before, "taking a snapshot is not a step");
let r = s.execute("history.newSnapshot", json!({"name": "Before green"})).unwrap();
assert_eq!(r, json!({"snapshot": 2, "name": "Before green"}));
assert_eq!(snapshots(&s), json!([{"id": 1, "name": "Snapshot 1"}, {"id": 2, "name": "Before green"}]));
let first = json!({"id": 0, "name": s.active().unwrap().doc.name});
assert_eq!(snapshots(&s), json!([first, {"id": 1, "name": "Snapshot 1"}, {"id": 2, "name": "Before green"}]));
}
#[test]
@@ -433,7 +440,7 @@ mod tests {
assert!(s.active().unwrap().active_layer.is_some());
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(top_pixel(&s), blue, "undo leaves the snapshot");
assert_eq!(snapshots(&s).as_array().unwrap().len(), 1, "snapshots are not history");
assert_eq!(snapshots(&s).as_array().unwrap().len(), 2, "snapshots are not history");
}
#[test]
@@ -465,7 +472,7 @@ mod tests {
let base = with.active().unwrap().memory_bytes();
with.execute("history.newSnapshot", json!({"from": "mergedLayers"})).unwrap();
let st = with.active().unwrap();
let flat = tile_bytes(&st.snapshots[0].doc);
let flat = tile_bytes(&st.snapshots.last().unwrap().doc);
assert!(flat > 0);
assert_eq!(st.memory_bytes(), base + flat);
// Room for the document's tiles and four fill tiles.
@@ -494,11 +501,11 @@ mod tests {
s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
let st = s.active().unwrap();
// The snapshot's green layer tile, now only in it and the last state.
let shared = tile_bytes(&Document { layers: st.snapshots[0].doc.layers[1..].to_vec(), ..(*st.doc).clone() });
let shared = tile_bytes(&Document { layers: st.snapshots.last().unwrap().doc.layers[1..].to_vec(), ..(*st.doc).clone() });
let all = st.history.unique_bytes(&st.doc);
let r = s.execute("edit.purge.histories", json!({})).unwrap();
assert_eq!(r["bytes"], json!(all - shared), "the green tile stays with the snapshot");
assert_eq!(s.active().unwrap().snapshots.len(), 1);
assert_eq!(s.active().unwrap().snapshots.len(), 2, "the first snapshot and the new one");
}
#[test]
@@ -509,11 +516,31 @@ mod tests {
}
s.execute("history.renameSnapshot", json!({"snapshot": 2, "name": "Lesson start"})).unwrap();
s.execute("history.deleteSnapshot", json!({"snapshot": 1})).unwrap();
assert_eq!(snapshots(&s), json!([{"id": 2, "name": "Lesson start"}, {"id": 3, "name": "Snapshot 3"}]));
s.execute("history.deleteSnapshot", json!({"snapshot": 0})).unwrap();
assert_eq!(snapshots(&s), json!([{"id": 2, "name": "Lesson start"}, {"id": 3, "name": "Snapshot 3"}]), "the first snapshot can go too");
let r = s.execute("history.newSnapshot", json!({})).unwrap();
assert_eq!(r["snapshot"], json!(4), "ids are never reused");
}
/// Photoshop's first snapshot (#2966): every document starts with a snapshot of itself as
/// opened, named after it, so the opening state can always be brought back, even after
/// History has dropped its oldest states.
#[test]
fn a_document_starts_with_a_first_snapshot_of_itself() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 20, "height": 10})).unwrap();
let name = s.active().unwrap().doc.name.clone();
assert_eq!(snapshots(&s), json!([{"id": 0, "name": name}]));
let opened = top_pixel(&s);
for _ in 0..3 {
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
}
s.active_mut().unwrap().history.purge_last();
s.execute("history.selectSnapshot", json!({"snapshot": 0})).unwrap();
assert_eq!(top_pixel(&s), opened, "back to the document as opened");
assert_eq!(entries(&s).last(), Some(&name), "a state named after the document");
}
#[test]
fn snapshot_commands_refuse_bad_params() {
let mut s = Session::new();
+2 -1
View File
@@ -366,7 +366,6 @@ mod tests {
json!({"state": 1.5}),
json!({"state": "1"}),
json!({"state": [1]}),
json!({"snapshot": 0}),
json!({"snapshot": 1}),
json!({"snapshot": -1}),
json!({"snapshot": "Open"}),
@@ -376,5 +375,7 @@ mod tests {
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
}
assert_eq!(s.active().unwrap().history.brush_source(), Some(0), "nothing changed");
// Snapshot 0 is the document as opened, so it is a valid source.
assert_eq!(s.execute(CMD, json!({"snapshot": 0})).unwrap()["snapshot"], 0);
}
}
+11
View File
@@ -610,6 +610,8 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Crop", |doc, active| {
let original = doc.size;
crop_doc(doc, "image.crop", r, delete, extension)?;
// A straight crop fills all of the area past the old canvas, pixels an earlier Delete Cropped
// Pixels off crop hid there included, as Photoshop does; only a turned frame keeps them (#8060).
if content_aware {
crate::crop_fill::fill_uncovered(doc, *active, &crate::crop_fill::Coverage::crop(r, original.width, original.height))?;
}
@@ -769,6 +771,15 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar
width: f64::from(size.width),
height: f64::from(size.height),
rotated: true,
held: None,
};
// The pixels before the turn: under a turned frame a Delete Cropped Pixels off crop's hidden ones
// count as image, as in Photoshop (a straight crop fills over them) (#8060).
let coverage = if content_aware {
let d = s.active().ok_or(EngineError::NoDocument)?;
coverage.holding(&d.doc, d.active_layer)
} else {
coverage
};
crate::analysis_cmds::compound(s, "Crop", |s| {
// Pixels the crop deletes needn't be turned first: keeping only what lands in the frame
+16 -1
View File
@@ -229,6 +229,7 @@ struct Running {
started_ms: f64,
coalesce: Option<String>,
color_restrict: Option<usize>,
filter_mask_layer: Option<LayerId>,
/// The document's layer selection when the job started: applying targets it, not whatever
/// got selected meanwhile (a Type click does that without editing the document, #7366).
layers: Option<LayerSelection>,
@@ -418,6 +419,7 @@ pub fn run<T: Send + 'static>(
started_ms: now_ms(),
coalesce: s.coalesce_request.clone(),
color_restrict: s.color_restrict,
filter_mask_layer: s.filter_mask_layer,
layers,
rx,
apply: Some(erased),
@@ -762,6 +764,9 @@ impl Session {
/// Run the command `id`: inline, or (with `background`) as a job when it is one.
pub(crate) fn dispatch(&mut self, id: &str, params: Value, background: bool) -> Result<Started> {
// The offer belongs to this one dispatch. Early errors drop it locally, including
// authorization, validation, floating-selection and admission failures.
let live_commit = self.live_commit.take();
let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
// Params are named fields: anything but an object (or null, meaning none) is a caller
// mistake, not "use every default".
@@ -818,6 +823,7 @@ impl Session {
let expand_new_fx = crate::commands::layer_panel_option(self, "expandNewEffects", true);
self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string);
self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params);
self.filter_mask_layer = crate::channel_cmds::filter_mask_layer(self, id, &run_params);
self.jobs.spawn = background;
// Commands this one runs on its own behalf journal after this mark (#3067).
let nested_from = self.journal.len();
@@ -830,14 +836,17 @@ impl Session {
// Last-resort guard (AGENTS.md, Never crash): a command that panics anyway fails with an
// error instead of taking the app down. `edit` only commits a document after its closure
// returns, so the documents are unchanged.
self.live_commit = live_commit.filter(|live| live.cmd == id);
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, &run_params)))
.unwrap_or_else(|_| Err(EngineError::Other(format!("`{id}` failed with an internal error (logged); the document is unchanged"))));
self.live_commit = None;
if self.actions.recording.is_none() {
self.actions.recording = recording;
}
self.jobs.spawn = false;
self.coalesce_request = None;
self.color_restrict = None;
self.filter_mask_layer = None;
let pending = self.jobs.pending.take();
match (r, pending) {
(Err(e), Some(job)) => {
@@ -950,7 +959,8 @@ impl Session {
}
fn apply_job(&mut self, job: &Running, apply: Option<Apply>, payload: Payload) -> Result<Value> {
let Running { label, doc, snapshot, coalesce, color_restrict, layers, command, params, journal, recorded_by, pre_selected, .. } = job;
let Running { label, doc, snapshot, coalesce, color_restrict, filter_mask_layer, layers, command, params, journal, recorded_by, pre_selected, .. } =
job;
// A recording started after the job (#7289) keeps what it has so far, and the job's step
// goes into the journal past its start, so that recording doesn't get it.
let foreign = self.actions.recording.is_some() && *recorded_by != Some(self.actions.take);
@@ -987,6 +997,7 @@ impl Session {
};
self.coalesce_request = coalesce.clone();
self.color_restrict = *color_restrict;
self.filter_mask_layer = *filter_mask_layer;
// Apply-time hooks can dispatch commands too. Keep the same single-step recording
// contract as synchronous dispatch without truncating work done while the worker ran.
let nested_from = self.journal.len();
@@ -1001,6 +1012,7 @@ impl Session {
}
self.coalesce_request = None;
self.color_restrict = None;
self.filter_mask_layer = None;
// Finish successful command bookkeeping on the document the job edited.
// Restoring the viewed document first would attribute Fade and slice updates
// to that document instead, when the user switched tabs mid-job.
@@ -1029,6 +1041,9 @@ impl Session {
}
fn end_job(&mut self, job: &Running, outcome: JobOutcome) {
// Every terminal path invalidates a preview, including worker errors, cancellation
// and discarded results whose document was closed or changed.
self.live_commit = None;
let (state, result, error) = match &outcome {
JobOutcome::Done(v) => ("done", Some(v.clone()), None),
JobOutcome::Failed(e) => ("failed", None, Some(e.clone())),
+55
View File
@@ -727,3 +727,58 @@ fn a_job_lands_in_the_recording_it_was_started_in_only() {
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(action_steps(&mut s, "A"), ["filter.blur.gaussianBlur"]);
}
/// Exercise six terminal job routes so prepared paint snapshots cannot outlive a completed or discarded job.
#[test]
fn every_job_terminal_path_drops_a_live_commit_stroke() {
use crate::brush_cmds::mixer_live::LiveMixer;
use std::sync::atomic::{AtomicBool, Ordering};
for outcome in 0..6 {
let mut s = session(32, 32);
let live = LiveMixer::begin(&s, &json!({"points": [[16, 16]], "size": 4})).unwrap();
live.prepare_commit(&mut s).unwrap();
let gate = Arc::new(AtomicBool::new(false));
let worker_gate = gate.clone();
let id = job(s
.start_job(
"test.mixer_cleanup",
json!({}),
"Mixer cleanup",
true,
move |ctx| {
while !worker_gate.load(Ordering::Relaxed) {
ctx.check()?;
std::thread::sleep(Duration::from_millis(1));
}
if outcome == 1 { Err(EngineError::Other("worker failed".into())) } else { Ok(()) }
},
move |_, ()| {
if outcome == 4 { Err(EngineError::Other("apply failed".into())) } else { Ok(json!({})) }
},
)
.unwrap());
assert!(s.live_commit.is_some());
if outcome == 2 {
assert!(s.cancel_job(id));
s.join_cancelled_jobs();
} else {
if outcome == 3 {
// Simulate replacement outside command dispatch, which itself clears offers.
let doc = (*s.active().unwrap().doc).clone();
s.active_mut().unwrap().doc = Arc::new(doc);
} else if outcome == 5 {
s.docs.clear();
s.active = None;
}
gate.store(true, Ordering::Relaxed);
}
let event = wait_event(&mut s, id);
match outcome {
0 => assert!(matches!(event.outcome, JobOutcome::Done(_))),
2 => assert!(matches!(event.outcome, JobOutcome::Cancelled)),
_ => assert!(matches!(event.outcome, JobOutcome::Failed(_))),
}
assert!(s.live_commit.is_none(), "terminal path {outcome} retained the preview");
}
}
+1 -1
View File
@@ -472,7 +472,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Camera Raw Filter…",
menu: &["Filter"],
shortcut: Some("Cmd+Shift+A"),
params: r##"{"temperature":-100..100=0,"tint":-100..100=0,"exposure":-5..5=0,"contrast":-100..100=0,"highlights":-100..100=0,"shadows":-100..100=0,"whites":-100..100=0,"blacks":-100..100=0,"texture":-100..100=0,"clarity":-100..100=0,"dehaze":-100..100=0,"vibrance":-100..100=0,"saturation":-100..100=0,"curveHighlights":-100..100=0,"curveLights":-100..100=0,"curveDarks":-100..100=0,"curveShadows":-100..100=0,"curveSplits":[25,50,75],"pointCurve":[[in,out]],"pointCurveRed":[[in,out]],"pointCurveGreen":[[in,out]],"pointCurveBlue":[[in,out]],"hslHue":[8],"hslSat":[8],"hslLum":[8],"gradeShadows":{"hue":deg,"sat":0..100,"lum":-100..100},"gradeMidtones":{},"gradeHighlights":{},"gradeGlobal":{},"gradeBlending":0..100=50,"gradeBalance":-100..100=0,"sharpenAmount":0..150=0,"sharpenRadius":0.5..3=1,"sharpenDetail":0..100=25,"sharpenMasking":0..100=0,"noiseLuminance":0..100=0,"noiseLuminanceDetail":0..100=50,"noiseColor":0..100=0,"noiseColorDetail":0..100=50,"grainAmount":0..100=0,"grainSize":0..100=25,"grainRoughness":0..100=50,"vignetteAmount":-100..100=0,"vignetteMidpoint":0..100=50,"vignetteRoundness":-100..100=0,"vignetteFeather":0..100=50,"vignetteHighlights":0..100=0,"vignetteStyle":"highlightPriority|colorPriority|paintOverlay","seed":u32=0}"##,
params: r##"{"temperature":-100..100=0,"tint":-100..100=0,"exposure":-5..5=0,"contrast":-100..100=0,"highlights":-100..100=0,"shadows":-100..100=0,"whites":-100..100=0,"blacks":-100..100=0,"texture":-100..100=0,"clarity":-100..100=0,"dehaze":-100..100=0,"vibrance":-100..100=0,"saturation":-100..100=0,"curveHighlights":-100..100=0,"curveLights":-100..100=0,"curveDarks":-100..100=0,"curveShadows":-100..100=0,"curveSplits":[25,50,75],"pointCurve":[[in,out]],"pointCurveRed":[[in,out]],"pointCurveGreen":[[in,out]],"pointCurveBlue":[[in,out]],"hslHue":[8],"hslSat":[8],"hslLum":[8],"gradeShadows":{"hue":deg,"sat":0..100,"lum":-100..100},"gradeMidtones":{},"gradeHighlights":{},"gradeGlobal":{},"gradeBlending":0..100=50,"gradeBalance":-100..100=0,"shadowTint":-100..100=0,"calibrationHue":[red,green,blue] -100..100,"calibrationSat":[red,green,blue] -100..100 (the Calibration panel: Shadows tint and the primaries' hue and saturation),"sharpenAmount":0..150=0,"sharpenRadius":0.5..3=1,"sharpenDetail":0..100=25,"sharpenMasking":0..100=0,"noiseLuminance":0..100=0,"noiseLuminanceDetail":0..100=50,"noiseColor":0..100=0,"noiseColorDetail":0..100=50,"grainAmount":0..100=0,"grainSize":0..100=25,"grainRoughness":0..100=50,"vignetteAmount":-100..100=0,"vignetteMidpoint":0..100=50,"vignetteRoundness":-100..100=0,"vignetteFeather":0..100=50,"vignetteHighlights":0..100=0,"vignetteStyle":"highlightPriority|colorPriority|paintOverlay","seed":u32=0}"##,
enabled: raw_enabled,
run: camera_raw_cmd,
journal: true,
+19 -6
View File
@@ -244,8 +244,9 @@ pub struct DocState {
/// pixel coordinates. Per-document view state: discarded when the document closes, never saved
/// to `.pcraft` or PSD, and not a history step.
pub color_samplers: Vec<[f64; 2]>,
/// History panel snapshots, oldest first (`history.newSnapshot`, #2966). Kept for the
/// session only, as in Photoshop: not history states, and not saved with the document.
/// History panel snapshots, oldest first (`history.newSnapshot`, #2966): the first (id 0) is
/// the document as opened. Kept for the session only, as in Photoshop: not history states,
/// and not saved with the document.
pub snapshots: Vec<history_cmds::Snapshot>,
/// The id the next snapshot gets; ids are never reused, so deleting one keeps the others.
next_snapshot: u64,
@@ -272,9 +273,14 @@ impl DocState {
pub fn new(doc: Document, path: Option<String>) -> Self {
let active_layer = doc.top_layer();
let mut history = History::default();
history.set_current_layers(LayerTarget { active: active_layer, selected: active_layer.into_iter().collect() });
let target = LayerTarget { active: active_layer, selected: active_layer.into_iter().collect() };
history.set_current_layers(target.clone());
let doc = Arc::new(doc);
// Photoshop's first snapshot: the document as opened, named after it, so the History
// panel's top row can always bring it back (#2966).
let first = history_cmds::Snapshot { id: 0, name: doc.name.clone(), doc: doc.clone(), layers: target };
Self {
doc: Arc::new(doc),
doc,
history,
active_layer,
selected_layers: active_layer.into_iter().collect(),
@@ -294,7 +300,7 @@ impl DocState {
show_only: None,
floating: None,
color_samplers: Vec::new(),
snapshots: Vec::new(),
snapshots: vec![first],
next_snapshot: 1,
source_snapshot: None,
}
@@ -499,6 +505,9 @@ pub struct Session {
pub quick_mask_options: channel_cmds::QuickMaskOptions,
/// Colour channel the running command may change (a single colour channel is targeted).
color_restrict: Option<usize>,
/// Smart object whose filter mask the running command edits (`"target":"filterMask"`): each
/// edit re-renders it, in the same history step.
filter_mask_layer: Option<LayerId>,
/// Edit › Preferences, keyboard shortcuts, menu and toolbar customisation (see `prefs`).
pub prefs: prefs::PrefsStore,
/// Release checks use an injected platform transport; no network I/O in the engine.
@@ -858,7 +867,7 @@ impl Session {
let d = self.active_mut()?;
d.doc.layer(target)?;
let active = d.active_layer.replace(target);
let r = (spec.enabled)(self);
let r = self.precondition(spec, params);
if let Some(d) = self.active_mut() {
d.active_layer = active;
}
@@ -872,6 +881,7 @@ impl Session {
let _authorization = crate::jobs::render_authorization_scope(self.authorize);
let _document = crate::doc_path::scope(self.active().and_then(|d| d.path.as_deref()).map(std::path::Path::new));
let restrict = self.color_restrict;
let filter_mask_layer = self.filter_mask_layer;
let sampler_map = self.sampler_map.take();
let symmetry_map = self.symmetry_map.take();
let st = self.active_mut().ok_or(EngineError::NoDocument)?;
@@ -885,6 +895,9 @@ impl Session {
if let (Some(k), Some(id)) = (restrict, active) {
channel_cmds::restrict_to_color(&before, &mut doc, id, k);
}
if let Some(id) = filter_mask_layer {
smart_cmds::refresh_after_filter_mask_edit(&mut doc, id)?;
}
st.doc = Arc::new(doc);
st.active_layer = active;
fix_selection(st);
+36 -10
View File
@@ -7,6 +7,9 @@
//! While a mask is shown, pixel commands without an explicit `target` edit that mask
//! ([`crate::channel_cmds`]'s target injection), so painting in mask view paints the mask.
//! The view ends when its layer stops being the active layer or loses its mask ([`fix`]).
//!
//! With `"mask":"filter"` the same view shows a smart object's filter mask (⌥-click the filter
//! mask thumbnail on its Smart Filters row), and painting while it's shown paints that mask.
use photocraft_doc::LayerId;
use serde::Serialize;
@@ -35,6 +38,19 @@ pub enum MaskViewMode {
pub struct LayerMaskView {
pub layer: LayerId,
pub mode: MaskViewMode,
/// The smart object's filter mask is shown, not its layer mask.
pub filter: bool,
}
/// The mask a view of `l` shows: its layer mask, or (`filter`) its smart filter mask.
pub fn viewed_mask(l: &photocraft_doc::Layer, filter: bool) -> Option<&photocraft_doc::LayerMask> {
if !filter {
return l.mask.as_ref();
}
match &l.content {
photocraft_doc::LayerContent::Smart(sm) => sm.filter_mask.as_ref(),
_ => None,
}
}
fn bad(msg: impl Into<String>) -> EngineError {
@@ -48,7 +64,7 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> {
/// The mask view of `st` when it is live: its layer is the active layer and still has a mask.
pub fn current(st: &DocState) -> Option<LayerMaskView> {
let v = st.channel_view.layer_mask?;
let live = st.active_layer == Some(v.layer) && st.doc.layer(v.layer).is_some_and(|l| l.mask.is_some());
let live = st.active_layer == Some(v.layer) && st.doc.layer(v.layer).is_some_and(|l| viewed_mask(l, v.filter).is_some());
live.then_some(v)
}
@@ -68,16 +84,26 @@ fn mode_name(v: Option<LayerMaskView>) -> &'static str {
}
}
/// The view as JSON (`{"layer","mode"}`, or `null` when off); part of `document.inspect`.
/// The view as JSON (`{"layer","mode"}`, plus `"mask":"filter"` for a filter mask, or `null` when
/// off); part of `document.inspect`.
pub fn view_json(st: &DocState) -> Value {
match current(st) {
Some(v) if v.filter => json!({"layer": v.layer.0, "mode": mode_name(Some(v)), "mask": "filter"}),
Some(v) => json!({"layer": v.layer.0, "mode": mode_name(Some(v))}),
None => Value::Null,
}
}
/// `{"layer":id?,"mode":"off"|"gray"|"overlay"|"toggleGray"|"toggleOverlay"="toggleGray"}`.
/// `{"layer":id?,"mode":"off"|"gray"|"overlay"|"toggleGray"|"toggleOverlay"="toggleGray","mask":"layer"|"filter"="layer"}`.
fn set_view(s: &mut Session, p: &Value) -> Result<Value> {
let filter = match p.get("mask") {
None | Some(Value::Null) => false,
Some(v) => match v.as_str() {
Some("layer") => false,
Some("filter") => true,
_ => return Err(bad("`mask` must be \"layer\" or \"filter\"")),
},
};
let mode = match p.get("mode") {
None | Some(Value::Null) => "toggleGray",
Some(v) => v.as_str().ok_or_else(|| bad("`mode` must be a string"))?,
@@ -88,9 +114,9 @@ fn set_view(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?),
};
let l = st.doc.layer(layer).ok_or(EngineError::NoLayer(layer))?;
let has_mask = l.mask.is_some();
let has_mask = viewed_mask(l, filter).is_some();
let name = l.name.clone();
let shown = current(st).filter(|v| v.layer == layer).map(|v| v.mode);
let shown = current(st).filter(|v| v.layer == layer && v.filter == filter).map(|v| v.mode);
let want = match mode {
"off" => None,
"gray" => Some(MaskViewMode::Gray),
@@ -100,16 +126,16 @@ fn set_view(s: &mut Session, p: &Value) -> Result<Value> {
other => return Err(bad(format!("unknown mode `{other}` (off|gray|overlay|toggleGray|toggleOverlay)"))),
};
if want.is_some() && !has_mask {
return Err(bad(format!("layer \"{name}\" has no layer mask")));
return Err(bad(format!("layer \"{name}\" has no {}", if filter { "filter mask" } else { "layer mask" })));
}
// ⌥-clicking another layer's mask selects that layer, as in Photoshop.
if want.is_some() && s.active().and_then(|st| st.active_layer) != Some(layer) {
s.select_layer(layer)?;
}
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
let next = want.map(|mode| LayerMaskView { layer, mode });
let next = want.map(|mode| LayerMaskView { layer, mode, filter });
// "off" for a layer whose mask isn't the one shown leaves the other view alone.
let applies = next.is_some() || st.channel_view.layer_mask.is_some_and(|v| v.layer == layer);
let applies = next.is_some() || st.channel_view.layer_mask.is_some_and(|v| v.layer == layer && v.filter == filter);
if applies && st.channel_view.layer_mask != next {
st.channel_view.layer_mask = next;
// Hiding the mask while every colour channel is off would leave a blank canvas.
@@ -123,7 +149,7 @@ fn set_view(s: &mut Session, p: &Value) -> Result<Value> {
st.last_damage = Some(photocraft_geom::Rect::EMPTY);
}
let v = current(st);
Ok(json!({"layer": layer.0, "mode": mode_name(v.filter(|v| v.layer == layer))}))
Ok(json!({"layer": layer.0, "mode": mode_name(v.filter(|v| v.layer == layer && v.filter == filter)), "mask": if filter { "filter" } else { "layer" }}))
}
/// `\` needs a layer mask on the active layer; without one Photoshop's key does nothing.
@@ -150,7 +176,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "View Layer Mask",
menu: &[],
shortcut: None,
params: r##"{"layer":id?,"mode":"off"|"gray"|"overlay"|"toggleGray"|"toggleOverlay"="toggleGray"} (gray: the mask alone, ⌥-click its thumbnail; overlay: red rubylith over the composite, ⇧⌥-click or `\`; view state, not an undo step; painting while shown paints the mask)"##,
params: r##"{"layer":id?,"mode":"off"|"gray"|"overlay"|"toggleGray"|"toggleOverlay"="toggleGray","mask":"layer"|"filter"="layer" (filter: a smart object's filter mask)} (gray: the mask alone, ⌥-click its thumbnail; overlay: red rubylith over the composite, ⇧⌥-click or `\`; view state, not an undo step; painting while shown paints the mask)"##,
enabled: has_doc,
run: set_view,
journal: false,
+49 -7
View File
@@ -1,6 +1,6 @@
//! Paint helpers: Paint Bucket and Gradient tool.
use photocraft_algo::paint::{GradientShape, bucket_fill_inks, bucket_fill_region_inks, bucket_fill_region_src, bucket_fill_src, paint_gradient};
use photocraft_algo::paint::{FillMode, GradientShape, bucket_fill_inks, bucket_fill_region_inks, paint_gradient};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, blend_from_str};
@@ -41,12 +41,27 @@ fn opacity(p: &Value, cmd: &str) -> Result<f32> {
Ok(crate::eraser_cmds::number(p, cmd, "opacity", 100.0, 1.0..=100.0)? as f32 / 100.0)
}
/// The Paint Bucket's `mode`: a blend mode (not Pass Through, a group's) or `clear`.
fn fill_mode(p: &Value) -> Result<FillMode> {
let bad = |msg: String| EngineError::BadParams { cmd: "paint.bucket".into(), msg };
match p.get("mode") {
None | Some(Value::Null) => Ok(FillMode::Blend(photocraft_color::BlendMode::Normal)),
Some(Value::String(m)) if m.eq_ignore_ascii_case("clear") => Ok(FillMode::Clear),
Some(Value::String(m)) => blend_from_str(m)
.filter(|m| *m != photocraft_color::BlendMode::PassThrough)
.map(FillMode::Blend)
.ok_or_else(|| bad(format!("unknown blend mode `{m}`"))),
Some(v) => Err(bad(format!("`mode` must be a blend mode name or `clear`, not {v}"))),
}
}
fn bucket(s: &mut Session, p: &Value) -> Result<Value> {
let fg = s.tools.foreground;
let c = color(p.get("color"), fg);
let (x, y) = (f(p, "x", 0.0).floor() as i32, f(p, "y", 0.0).floor() as i32);
let (tol, contiguous, aa) = (f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true));
let opacity = opacity(p, "paint.bucket")?;
let mode = fill_mode(p)?;
let all = b(p, "sampleAllLayers", false) && crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels;
// Other visible layers can define the region, but the fill still writes only to the target.
let region = if all {
@@ -83,16 +98,16 @@ fn bucket(s: &mut Session, p: &Value) -> Result<Value> {
let w = area.width() as usize;
let src = |px: i32, py: i32| rendered[(py - area.y0) as usize * w + (px - area.x0) as usize];
if let Some(region) = &region {
bucket_fill_region_src(surf, region, opacity, sel.as_ref(), lock, src);
bucket_fill_region_inks(surf, region, opacity, sel.as_ref(), lock, src, None, mode);
true
} else {
bucket_fill_src(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, src)
bucket_fill_inks(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, src, None, mode)
}
} else if let Some(region) = &region {
bucket_fill_region_inks(surf, region, opacity, sel.as_ref(), lock, |_, _| c, inks);
bucket_fill_region_inks(surf, region, opacity, sel.as_ref(), lock, |_, _| c, inks, mode);
true
} else {
bucket_fill_inks(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, |_, _| c, inks)
bucket_fill_inks(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, |_, _| c, inks, mode)
};
surf.prune();
Ok(ok)
@@ -162,7 +177,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Paint Bucket",
menu: &[],
shortcut: None,
params: r##"{"x":px,"y":px,"tolerance":0..255=32,"contiguous":bool=true,"antiAlias":bool=true,"sampleAllLayers":bool=false,"contents":"foreground|pattern"="foreground","color":"#rrggbb"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":1..100=100,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##,
params: r##"{"x":px,"y":px,"tolerance":0..255=32,"contiguous":bool=true,"antiAlias":bool=true,"sampleAllLayers":bool=false,"contents":"foreground|pattern"="foreground","color":"#rrggbb"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":1..100=100,"mode":"normal|multiply|…|clear (erases the filled area)"="normal","target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##,
enabled: crate::commands::has_paintable,
run: bucket,
journal: true,
@@ -172,7 +187,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Gradient",
menu: &[],
shortcut: None,
params: r##"{"from":[x,y],"to":[x,y],"style":"linear|radial|angle|reflected|diamond"="linear","colors":["#rrggbb",…]? (evenly spaced),"gradient":preset name?,"stops":[[t,"#rrggbb"|"foreground"|"background"],…]?,"transparency":[[t,0..100],…]? (default: the current gradient, see gradient.presets.select),"method":"classic|perceptual|linear|smooth"? (Photoshop's interpolation; omitted: straight stops),"smoothness":0..100=100,"reverse":bool=false,"dither":bool=true,"opacity":1..100=100,"mode":"normal|multiply|…"="normal","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##,
params: r##"{"from":[x,y],"to":[x,y],"style":"linear|radial|angle|reflected|diamond"="linear","colors":["#rrggbb",…]? (evenly spaced),"gradient":preset name?,"stops":[[t,"#rrggbb"|"foreground"|"background"],…]?,"transparency":[[t,0..100],…]? (default: the current gradient, see gradient.presets.select),"method":"classic|perceptual|linear|smooth"? (Photoshop's interpolation; omitted: straight stops),"smoothness":0..100=100,"reverse":bool=false,"dither":bool=true,"opacity":1..100=100,"mode":"normal|multiply|…"="normal","target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##,
enabled: crate::commands::has_paintable,
run: gradient,
journal: true,
@@ -210,6 +225,33 @@ mod tests {
(s, bottom)
}
/// The options bar's Mode reaches the fill: Multiply over grey, Clear erases, a bad name or
/// Pass Through is an error and changes nothing.
#[test]
fn bucket_mode_multiplies_clears_and_rejects_bad_names() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let base = json!({"x": 1, "y": 1, "antiAlias": false, "tolerance": 0});
let with = |extra: Value| {
let mut p = base.clone();
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
p
};
s.execute("paint.bucket", with(json!({"color": "#808080"}))).unwrap();
s.execute("paint.bucket", with(json!({"color": "#ff0000", "mode": "multiply"}))).unwrap();
let p = px(&s, 3, 3);
assert!((p[0] - 0.5).abs() < 0.01 && p[1] < 0.01 && p[3] > 0.99, "depth {depth}: {p:?}");
for bad in [json!("passThrough"), json!("sparkle"), json!(3)] {
assert!(s.execute("paint.bucket", with(json!({"mode": bad}))).is_err(), "{bad}");
}
assert_eq!(px(&s, 3, 3), p, "a refused mode paints nothing");
s.execute("paint.bucket", with(json!({"mode": "Clear", "opacity": 50}))).unwrap();
assert!((px(&s, 3, 3)[3] - 0.5).abs() < 0.01, "depth {depth}: half cleared");
}
}
#[test]
fn bucket_sample_all_layers_uses_visible_boundaries_at_all_depths() {
for depth in [8, 16, 32] {
+37 -12
View File
@@ -61,7 +61,7 @@ choice!(DarkTheme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", Sol
choice!(LightTheme { ProLight = "proLight", ProLightGray = "proLightGray", StudioLight = "studioLight", Classic = "classic", Adwaita = "adwaita", BreezeLight = "breezeLight", SoftLight = "softLight" } default ProLight);
choice!(CanvasColor { Default = "default", Black = "black", DarkGray = "darkGray", MediumGray = "mediumGray", LightGray = "lightGray", Custom = "custom" } default Default);
choice!(CanvasBorder { DropShadow = "dropShadow", Line = "line", None = "none" } default DropShadow);
choice!(UiScale { Auto = "auto", P75 = "75", P80 = "80", P85 = "85", P90 = "90", P95 = "95", P100 = "100", P125 = "125", P150 = "150", P175 = "175", P200 = "200", P250 = "250", P300 = "300" } default Auto);
choice!(UiScale { Auto = "auto", P75 = "75", P80 = "80", P85 = "85", P90 = "90", P95 = "95", P100 = "100", P105 = "105", P110 = "110", P115 = "115", P120 = "120", P125 = "125", P150 = "150", P175 = "175", P200 = "200", P250 = "250", P300 = "300" } default Auto);
choice!(
/// Graphics backend of the desktop app's window and GPU canvas (applies at next launch).
/// `auto` lets PhotoCraft pick (DX12 for Intel adapters on Windows); `cpu` composites on the
@@ -223,6 +223,7 @@ pub struct General {
pub always_create_smart_objects_when_placing: bool,
/// Scroll wheel zooms instead of panning.
pub zoom_with_scroll_wheel: bool,
/// Wheel zoom eases over the frames of a notch; off, a notch is one instant ×1.1 step.
pub animated_zoom: bool,
pub zoom_resizes_windows: bool,
pub use_legacy_free_transform: bool,
@@ -549,7 +550,11 @@ pub struct Performance {
pub memory_usage_mb: u32,
/// Undo steps kept per document (History panel states).
pub history_states: u32,
/// Display cache levels (mip levels kept for zoomed-out views).
/// Cache Levels, 1–8: the highest cache level the Histogram panel counts a large document at
/// (level n counts a proxy 2^(n-1) times smaller; 1 counts every pixel). Lower is more
/// accurate, higher faster. The panel picks the level from the document's size up to this;
/// the web build, which counts on the UI thread, reduces documents from a quarter of the size
/// the desktop does. Uncached Refresh always counts at level 1.
pub cache_levels: u32,
/// Side of the GPU canvas texture tiles, in pixels (applies at next launch).
pub cache_tile_size: u32,
@@ -901,6 +906,35 @@ pub struct RawDefaults {
pub open_in_camera_raw: bool,
/// The demosaic method raws are developed with; the open-time Camera Raw dialog starts from it.
pub demosaic: RawDemosaic,
/// Keep a raw file's develop settings in a sidecar (`IMG_0001.xmp` beside `IMG_0001.CR2`),
/// as Camera Raw's "Save image settings in: Sidecar .xmp files"; the sidecar is read again
/// when the file is next opened. Off reads sidecars but never writes one.
pub write_xmp_sidecars: bool,
}
impl RawColorSpace {
/// The built-in profile `edit.convertToProfile` knows this space as.
pub fn profile_id(self) -> &'static str {
match self {
RawColorSpace::Srgb => "srgb",
RawColorSpace::AdobeRgb => "adobe-rgb-compat",
RawColorSpace::ProPhoto => "prophoto-compat",
RawColorSpace::DisplayP3 => "display-p3",
}
}
}
impl RawSharpen {
/// Camera Raw's output sharpening for this medium as the Detail panel's (amount, radius):
/// none, Screen (light, fine), Matte paper (stronger, wider: ink spreads), Glossy paper.
pub fn preset(self) -> Option<(f32, f32)> {
match self {
RawSharpen::None => None,
RawSharpen::Screen => Some((25.0, 1.0)),
RawSharpen::Matte => Some((35.0, 1.2)),
RawSharpen::Glossy => Some((40.0, 1.0)),
}
}
}
impl RawDemosaic {
@@ -925,6 +959,7 @@ impl Default for RawDefaults {
apply_auto_tone: false,
open_in_camera_raw: true,
demosaic: RawDemosaic::Ahd,
write_xmp_sidecars: true,
}
}
}
@@ -1119,7 +1154,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[
"general.colorPicker",
"workspace.autoShowHiddenPanels",
"workspace.enableFloatingDocumentWindowDocking",
"performance.cacheLevels",
"performance.effectCacheMb",
"performance.legacyCompositing",
"scratchDisks.disks",
@@ -1129,15 +1163,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[
"plugIns.allowScriptsToConnect",
"type.textEngine",
"enhancedControls.scrubbySliderAcceleration",
"rawDefaults.colorSpace",
"rawDefaults.bitDepth",
"rawDefaults.sharpenFor",
"rawDefaults.openAsSmartObject",
"rawDefaults.applyAutoTone",
// Agent access is governed by the launch flags (`--control`, the automation roots), not
// by these yet.
"integrations.allowAgentControl",
"integrations.controlPort",
];
/// Is the preference at `path` (`"section.key"`) hidden from the Preferences dialog?
+15
View File
@@ -22,6 +22,21 @@ fn defaults_match_photoshop() {
}
}
#[test]
fn intermediate_ui_scale_presets_survive_restart() {
let mut s = Session::new();
assert_eq!(s.execute("prefs.get", json!({"path": "interface.uiScale"})).unwrap(), json!("auto"));
for scale in ["100", "105", "110", "115", "120", "125"] {
s.execute("prefs.set", json!({"path": "interface.uiScale", "value": scale})).unwrap();
let mut restored = Session::new();
restored.load_prefs_json(&s.prefs_to_json()).unwrap();
assert_eq!(restored.execute("prefs.get", json!({"path": "interface.uiScale"})).unwrap(), json!(scale));
}
// Presets remain discrete choices: arbitrary values must not silently replace a valid scale.
assert!(s.execute("prefs.set", json!({"path": "interface.uiScale", "value": "112"})).is_err());
assert_eq!(s.execute("prefs.get", json!({"path": "interface.uiScale"})).unwrap(), json!("125"));
}
/// #2264: the Studio workspace dot pattern can be hidden without changing themes.
#[test]
fn canvas_dot_grid_can_be_disabled_persisted_and_reset() {
+1 -1
View File
@@ -256,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Red Eye",
menu: &[],
shortcut: None,
params: r#"{"x":px, "y":px} | {"points":[[x,y],…] (at most 64)},"pupilSize":1..100=50,"darken":0..100=50,"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i} → {"damage":[x,y,w,h], "pixels":n}"#,
params: r#"{"x":px, "y":px} | {"points":[[x,y],…] (at most 64)},"pupilSize":1..100=50,"darken":0..100=50,"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i} → {"damage":[x,y,w,h], "pixels":n}"#,
enabled,
run: red_eye,
journal: true,
+29 -6
View File
@@ -1,5 +1,6 @@
//! Retouching tools as commands: Clone Stamp, Pattern Stamp, Healing Brush, Spot Healing Brush,
//! Patch, Content-Aware Move, Remove, Dodge, Burn, Sponge, Blur, Sharpen, Smudge and History Brush.
//! Patch, Content-Aware Move, Remove, Dodge, Burn, Sponge, Blur, Sharpen, Smudge, History Brush and
//! Art History Brush.
//!
//! Every command takes a Photoshop-style brush (`points`, `size`, `hardness`, `opacity`, `flow`,
//! `spacing`, `layer`), respects the active selection as a mask and the layer's transparency lock, and
@@ -24,6 +25,7 @@ use serde_json::{Value, json};
use crate::commands::{CommandSpec, blend_from_str};
use crate::{EngineError, Result, Session};
mod art_history;
mod content_aware_move;
mod patch;
mod pattern_stamp;
@@ -552,7 +554,7 @@ impl LiveRetouch {
let bounds = self.bounds();
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
let surface = surf.clone();
Ok(crate::brush_cmds::LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: None, points: self.points, surface, bounds })
Ok(crate::brush_cmds::LiveCommit { mixer: None, cmd: self.cmd, base: self.base, params: self.params, seed: None, points: self.points, surface, bounds })
}
/// Render more points; returns the rectangle that changed. Each changed area is redrawn from
@@ -1472,7 +1474,16 @@ impl LiveDab {
self.bounds = self.bounds.union(&dmg);
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
let surface = surf.clone();
Ok(crate::brush_cmds::LiveCommit { cmd: self.cmd, base, params: self.params, seed: None, points: self.points, surface, bounds: self.bounds })
Ok(crate::brush_cmds::LiveCommit {
mixer: None,
cmd: self.cmd,
base,
params: self.params,
seed: None,
points: self.points,
surface,
bounds: self.bounds,
})
}
}
@@ -1522,7 +1533,7 @@ impl<'a> AllLayers<'a> {
/// The brush every retouching command shares, plus the tool's own params.
macro_rules! brush_params {
($extra:literal) => {
concat!("{", r#""points":[[x,y,pressure?],…],"size":1..5000 px=tool size,"hardness":0..100=tool hardness,"opacity":1..100=100,"flow":1..100=100,"spacing":1..1000 (% of size)=Brush Settings spacing,"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target"#, $extra, "}")
concat!("{", r#""points":[[x,y,pressure?],…],"size":1..5000 px=tool size,"hardness":0..100=tool hardness,"opacity":1..100=100,"flow":1..100=100,"spacing":1..1000 (% of size)=Brush Settings spacing,"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target"#, $extra, "}")
};
}
@@ -1580,7 +1591,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Patch",
menu: &[],
shortcut: None,
params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"source|destination"="source","contentAware":bool=false (fill the selection content-aware from the dragged-to place; a background job),"structure":1..7=4 (content-aware: 3..7 copy the middle exactly, 1..2 re-synthesise more of it),"color":0..10=0 (content-aware: how far the copy's level adapts to the selection's surroundings),"sampleAllLayers":bool=false (content-aware),"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset"}"#,
params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"source|destination"="source","contentAware":bool=false (fill the selection content-aware from the dragged-to place; a background job),"structure":1..7=4 (content-aware: 3..7 copy the middle exactly, 1..2 re-synthesise more of it),"color":0..10=0 (content-aware: how far the copy's level adapts to the selection's surroundings),"sampleAllLayers":bool=false (content-aware),"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset"}"#,
enabled: patch::enabled,
run: patch::patch,
journal: true,
@@ -1590,7 +1601,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Content-Aware Move",
menu: &[],
shortcut: None,
params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"move|extend"="move","structure":1..7=4 (3..7 copy the content exactly but for a band about a patch wide along its edge; 2 widens the band, 1 re-synthesises the content too),"color":0..10=0 (0 keeps the content's level; higher values bring it towards its new place, by up to about 0.04 × color),"sampleAllLayers":bool=false,"scaleX":%=100,"scaleY":%=100 (Transform On Drop: 1..10000, negative flips),"rotate":deg=0 (Transform On Drop, clockwise; scale and rotation are about the selection's centre, before the offset; a transformed patch may reach past the canvas edge),"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset","mode","transformed"?} (a background job; the selection moves, and is transformed, with the content)"#,
params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"move|extend"="move","structure":1..7=4 (3..7 copy the content exactly but for a band about a patch wide along its edge; 2 widens the band, 1 re-synthesises the content too),"color":0..10=0 (0 keeps the content's level; higher values bring it towards its new place, by up to about 0.04 × color),"sampleAllLayers":bool=false,"scaleX":%=100,"scaleY":%=100 (Transform On Drop: 1..10000, negative flips),"rotate":deg=0 (Transform On Drop, clockwise; scale and rotation are about the selection's centre, before the offset; a transformed patch may reach past the canvas edge),"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset","mode","transformed"?} (a background job; the selection moves, and is transformed, with the content)"#,
enabled: content_aware_move::enabled,
run: content_aware_move::content_aware_move,
journal: true,
@@ -1679,6 +1690,18 @@ pub fn specs() -> Vec<CommandSpec> {
run: history_brush,
journal: true,
},
CommandSpec {
id: "paint.artHistoryBrush",
label: "Art History Brush",
menu: &[],
shortcut: None,
params: brush_params!(
r#","state":row (a History panel row, as history.setSource),"snapshot":id (a history.newSnapshot id; default: the History panel's source, as paint.historyBrush; a source of another canvas size is an error),"style":"tightShort|tightMedium|tightLong|looseMedium|looseLong|dab|tightCurl|looseCurl"="tightMedium","area":0..100=50,"tolerance":0..100=0"#
),
enabled: has_pixel_layer,
run: art_history::run,
journal: true,
},
]
}
@@ -0,0 +1,486 @@
//! Art History Brush: stylized, oriented dabs sampled from a history state.
use photocraft_algo::art_history::{self, Dab, MAX_COVERAGE, MAX_DABS, RNG_SEED, StampError, StampParams, Style};
use photocraft_color::BlendMode;
use photocraft_geom::Rect;
use photocraft_paint::MAX_BRUSH_SIZE;
use photocraft_paint::retouch::{Region, alpha_index};
use serde_json::{Value, json};
use super::*;
const CMD: &str = "paint.artHistoryBrush";
const MAX_POINTS: usize = 4096;
fn err(msg: impl Into<String>) -> EngineError {
bad(CMD, msg)
}
fn object(p: &Value) -> Result<&serde_json::Map<String, Value>> {
p.as_object().ok_or_else(|| err("expected an object"))
}
fn finite(v: Option<&Value>, _key: &str) -> Result<Option<f32>> {
match v {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| x as f32).ok_or_else(|| err("invalid number")).map(Some),
}
}
fn finite_key(p: &Value, key: &str, default: f32) -> Result<f32> {
match finite(p.get(key), key)? {
None => Ok(default),
Some(v) => Ok(v),
}
}
fn ranged(p: &Value, key: &str, default: f32, lo: f32, hi: f32) -> Result<f32> {
let v = finite_key(p, key, default)?;
if (lo..=hi).contains(&v) { Ok(v) } else { Err(err(format!("`{key}` must be a number {lo}..{hi}"))) }
}
fn points(p: &Value) -> Result<Vec<[f32; 2]>> {
let Some(arr) = p.get("points").and_then(Value::as_array) else {
return Err(err("no stroke points"));
};
if arr.is_empty() {
return Err(err("no stroke points"));
}
if arr.len() > MAX_POINTS {
return Err(err("too many points"));
}
let mut out = Vec::with_capacity(arr.len());
for q in arr {
let Some(a) = q.as_array() else {
return Err(err("invalid number"));
};
let (Some(x), Some(y)) = (a.first(), a.get(1)) else {
return Err(err("invalid number"));
};
let x = x.as_f64().filter(|v| v.is_finite()).ok_or_else(|| err("invalid number"))?;
let y = y.as_f64().filter(|v| v.is_finite()).ok_or_else(|| err("invalid number"))?;
if let Some(pr) = a.get(2)
&& pr.as_f64().filter(|v| v.is_finite()).is_none()
{
return Err(err("invalid number"));
}
out.push([x as f32, y as f32]);
}
if out.is_empty() {
return Err(err("no stroke points"));
}
Ok(out)
}
fn style_of(p: &Value) -> Result<Style> {
match p.get("style") {
None | Some(Value::Null) => Ok(Style::TightMedium),
Some(v) => {
let Some(s) = v.as_str() else {
return Err(err("unknown Art History style"));
};
Style::parse(s).ok_or_else(|| err("unknown Art History style"))
}
}
}
fn union_bounds(dabs: &[Dab]) -> Result<Rect> {
let mut r = Rect::EMPTY;
for d in dabs {
let (x0, y0, x1, y1) = art_history::dab_bounds(d).ok_or_else(|| err("stroke too large"))?;
r = r.union(&Rect::new(x0, y0, x1, y1));
}
if r.is_empty() {
return Err(err("no stroke points"));
}
Ok(r)
}
fn selection_mask(sel: Option<&photocraft_raster::Surface>, rect: Rect) -> Option<Vec<f32>> {
let sel = sel?;
let w = rect.width() as usize;
let h = rect.height() as usize;
let mut v = vec![0.0f32; w.saturating_mul(h)];
for y in rect.y0..rect.y1 {
for x in rect.x0..rect.x1 {
let i = ((y - rect.y0) as usize).saturating_mul(w).saturating_add((x - rect.x0) as usize);
if let Some(slot) = v.get_mut(i) {
*slot = sel.sample_channel(x, y, 0);
}
}
}
Some(v)
}
#[allow(clippy::too_many_arguments)]
fn stamp_region(
dest: &mut Region,
src: &Region,
dabs: &[Dab],
hardness: f32,
opacity: f32,
flow: f32,
tolerance: f32,
sel: Option<&[f32]>,
lock: bool,
alpha: Option<usize>,
) -> Result<u32> {
if dest.ch != src.ch {
return Err(err("source and destination do not match"));
}
let dest_w = dest.width();
let dest_h = dest.height();
let dest_origin = (dest.rect.x0, dest.rect.y0);
let src_w = src.width();
let src_h = src.height();
let src_origin = (src.rect.x0, src.rect.y0);
let ch = dest.ch;
let mut p = StampParams {
dest: &mut dest.data,
dest_w,
dest_h,
dest_origin,
src: &src.data,
src_w,
src_h,
src_origin,
ch,
dabs,
hardness,
opacity,
flow,
tolerance,
tile: 64,
selection: sel,
lock_transparent: lock,
alpha,
};
match art_history::stamp(&mut p) {
Ok(n) => Ok(n),
Err(StampError::Empty) => Err(err("no stroke points")),
Err(StampError::Mismatched) => Err(err("source and destination do not match")),
Err(StampError::TooLarge) => Err(err("stroke too large")),
}
}
/// Run `paint.artHistoryBrush`.
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
let _ = object(p)?;
if s.active().is_none() {
return Err(EngineError::NoDocument);
}
let pts = points(p)?;
if pts.len() > MAX_DABS {
return Err(err("too many points"));
}
let size = finite_key(p, "size", s.tools.brush.size)?;
if !size.is_finite() {
return Err(err("invalid number"));
}
if size <= 0.0 || size > MAX_BRUSH_SIZE {
return Err(err("stroke too large"));
}
let hardness = ranged(p, "hardness", s.tools.brush.hardness * 100.0, 0.0, 100.0)? / 100.0;
let opacity = ranged(p, "opacity", 100.0, 0.0, 100.0)? / 100.0;
let flow = ranged(p, "flow", 100.0, 0.0, 100.0)? / 100.0;
let area = ranged(p, "area", 50.0, 0.0, 100.0)?;
// Percent of the channel range; 0 (paint everywhere) is the default, as in Photoshop.
let tolerance = ranged(p, "tolerance", 0.0, 0.0, 100.0)? / 100.0;
let style = style_of(p)?;
// The stamp mixes the source in by coverage, so only Normal is right; other modes need a
// stroke buffer (blending each dab with the already-mixed result rings every dab edge).
if blend_param(p, CMD)? != BlendMode::Normal {
return Err(err("only the Normal mode is supported"));
}
let (stroke, id) = parse_brush(s, p, CMD)?;
let _ = stroke;
// The same source as the History Brush: the `state` or `snapshot` passed, else the History
// panel's source, refused after a canvas size change.
let src_surface = super::history_source(s, id, p, CMD)?;
// Truncating a huge row index only changes the seed.
let seed = RNG_SEED ^ p.get("state").and_then(Value::as_u64).map_or(0, |i| i as u32);
let dabs = art_history::place_dabs(&pts, size, area, style, seed).map_err(|e| match e {
StampError::TooLarge => err("stroke too large"),
_ => err("no stroke points"),
})?;
if dabs.is_empty() {
return Err(err("no stroke points"));
}
let bounds = union_bounds(&dabs)?;
// The stamp reads each dab's bounds plus one pixel for bilinear sampling,
// so only that margin counts against the coverage limit.
let outer = bounds.inflate(1);
let area_px = (outer.width() as u64).saturating_mul(outer.height() as u64);
if outer.is_empty() || area_px > MAX_COVERAGE {
return Err(err("stroke too large"));
}
// Nothing written (every dab under the tolerance, outside the selection or on locked
// transparency): a no-op stroke, with no history step.
let mut nothing_written = false;
let dmg = run_stroke(s, "Art History Brush", id, p, |_, surf, sel, lock| {
let fmt = surf.format();
let src = if src_surface.format() == fmt { src_surface.clone() } else { src_surface.convert(fmt) };
if src.channels() != surf.channels() {
return Err(err("source and destination do not match"));
}
let mut dest = Region::read(surf, outer);
let src_reg = Region::read(&src, outer);
if dest.ch != src_reg.ch || dest.width() != src_reg.width() || dest.height() != src_reg.height() {
return Err(err("source and destination do not match"));
}
let sel_mask = selection_mask(sel, outer);
let n = stamp_region(&mut dest, &src_reg, &dabs, hardness, opacity, flow, tolerance, sel_mask.as_deref(), lock, alpha_index(&fmt))?;
if n == 0 {
nothing_written = true;
return Err(err("the stroke changed no pixel"));
}
dest.write(surf);
Ok(bounds)
});
let dmg = match dmg {
Err(_) if nothing_written => return Ok(json!({ "damage": Value::Null })),
r => r?,
};
Ok(json!({ "damage": damage_json(dmg) }))
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_color::ColorMode;
use photocraft_doc::LayerContent;
use serde_json::json;
fn session(w: u32, h: u32, depth: u64, mode: &str) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": h, "depth": depth, "mode": mode})).unwrap();
s
}
fn rgba(s: &Session, x: i32, y: i32) -> [f32; 4] {
let d = s.active().unwrap();
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)
}
fn paint_layer(s: &mut Session, f: impl Fn(i32, i32) -> [f32; 4]) {
s.edit("setup", |doc, active| {
let b = doc.bounds();
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let fmt = surf.format();
let mut data = Vec::new();
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
data.extend(photocraft_raster::from_rgba(&fmt, f(x, y)));
}
}
surf.write_region(b, &data);
Ok(())
})
.unwrap();
}
fn stroke(s: &mut Session, extra: Value) -> std::result::Result<Value, EngineError> {
let mut p = json!({"points": [[8, 16], [40, 16]], "size": 12, "hardness": 100, "area": 50, "tolerance": 0, "style": "tightMedium"});
if let Some(map) = extra.as_object() {
for (k, v) in map {
p[k] = v.clone();
}
}
s.execute("paint.artHistoryBrush", p)
}
#[test]
fn restores_open_state_at_every_depth_and_undoes() {
for (depth, mode) in [(8u64, "rgb"), (16, "rgb"), (32, "rgb"), (16, "gray")] {
let mut s = session(48, 32, depth, mode);
paint_layer(&mut s, |_, _| [0.1, 0.8, 0.2, 1.0]);
s.execute("edit.fill", json!({"color": "#2030c0"})).unwrap();
let before = rgba(&s, 20, 16);
stroke(&mut s, json!({"state": 1})).unwrap();
let after = rgba(&s, 20, 16);
if mode == "gray" {
assert!(after[0] > before[0] + 0.1, "restored lighter gray {depth}: before {before:?} after {after:?}");
} else {
assert!(before[2] > 0.5, "{mode} {depth} {before:?}");
assert!(after[1] > after[2], "restored green {mode} {depth}: {after:?}");
}
let steps = s.active().unwrap().history.past_len();
stroke(&mut s, json!({"style": "looseCurl"})).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one mouse-up = one step");
assert!(s.undo());
assert!(s.undo());
let undone = rgba(&s, 20, 16);
for c in 0..3 {
assert!((undone[c] - before[c]).abs() < 0.05, "undo {mode} {depth}");
}
assert!(s.redo());
}
}
#[test]
fn default_tolerance_paints_a_moderate_history_edit() {
let mut s = session(48, 32, 8, "rgb");
paint_layer(&mut s, |_, _| [0.5, 0.5, 0.5, 1.0]);
paint_layer(&mut s, |_, _| [0.6, 0.6, 0.6, 1.0]);
let steps = s.active().unwrap().history.past_len();
let p = json!({"points": [[8, 16], [40, 16]], "size": 12, "hardness": 100, "style": "tightMedium", "state": 1});
s.execute("paint.artHistoryBrush", p).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
assert!(rgba(&s, 24, 16)[0] < 0.55, "{:?}", rgba(&s, 24, 16));
}
#[test]
fn stroke_under_the_tolerance_records_no_step() {
let mut s = session(48, 32, 8, "rgb");
paint_layer(&mut s, |_, _| [0.5, 0.5, 0.5, 1.0]);
paint_layer(&mut s, |_, _| [0.6, 0.6, 0.6, 1.0]);
let steps = s.active().unwrap().history.past_len();
let r = stroke(&mut s, json!({"state": 1, "tolerance": 50})).unwrap();
assert!(r["damage"].is_null(), "{r}");
assert_eq!(s.active().unwrap().history.past_len(), steps);
assert!((rgba(&s, 24, 16)[0] - 0.6).abs() < 0.01);
}
#[test]
fn large_brush_click_is_not_too_large() {
let mut s = session(2600, 2600, 8, "rgb");
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
for (style, size) in [("looseCurl", 2000), ("tightMedium", 2400)] {
let r = stroke(&mut s, json!({"state": 1, "points": [[1300, 1300]], "size": size, "style": style}));
assert!(r.is_ok(), "{style} size {size}: {r:?}");
}
}
#[test]
fn state_one_uses_that_snapshot() {
let mut s = session(40, 24, 8, "rgb");
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
stroke(&mut s, json!({"state": 1, "points": [[4, 12], [20, 12]], "size": 10})).unwrap();
let p = rgba(&s, 10, 12);
assert!(p[0] > 0.9 && p[1] < 0.2, "state 1 is red, got {p:?}");
}
/// With no `state`, the brush paints from the History panel's source, like the History Brush.
#[test]
fn paints_from_the_history_panel_source() {
let mut s = session(40, 24, 8, "rgb");
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
s.execute("history.setSource", json!({"state": 1})).unwrap();
s.execute("paint.artHistoryBrush", json!({"points": [[4, 12], [30, 12]], "size": 10, "hardness": 100})).unwrap();
let p = rgba(&s, 12, 12);
assert!(p[0] > 0.9 && p[1] < 0.2, "the source row is red, got {p:?}");
}
/// A source of another canvas size is refused, not painted shifted.
#[test]
fn a_source_of_another_canvas_size_is_refused() {
let mut s = session(40, 24, 8, "rgb");
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("image.canvasSize", json!({"width": 60, "height": 30, "anchor": "center"})).unwrap();
let steps = s.active().unwrap().history.past_len();
let e = stroke(&mut s, json!({"state": 1})).unwrap_err().to_string();
assert!(e.contains("canvas size"), "{e}");
assert_eq!(s.active().unwrap().history.past_len(), steps);
}
/// A stroke that writes nothing (wholly outside the selection, or on transparent pixels
/// with Lock Transparency) does nothing, as with the tolerance: no error, no History step.
#[test]
fn a_stroke_that_writes_nothing_is_a_quiet_no_op() {
let mut s = session(48, 32, 8, "rgb");
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap();
let steps = s.active().unwrap().history.past_len();
let r = stroke(&mut s, json!({"state": 1})).unwrap();
assert!(r["damage"].is_null(), "{r}");
assert_eq!(s.active().unwrap().history.past_len(), steps);
assert!(rgba(&s, 24, 16)[2] > 0.9, "still blue");
let mut s = session(48, 32, 8, "rgb");
s.execute("layer.new.layer", json!({})).unwrap();
s.edit("lock", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().locks.transparency = true;
Ok(())
})
.unwrap();
let steps = s.active().unwrap().history.past_len();
// Row 1: the new, transparent layer.
let r = stroke(&mut s, json!({"state": 1})).unwrap();
assert!(r["damage"].is_null(), "{r}");
assert_eq!(s.active().unwrap().history.past_len(), steps);
}
#[test]
fn only_the_normal_mode_paints() {
let mut s = session(48, 32, 8, "rgb");
paint_layer(&mut s, |_, _| [1.0, 1.0, 1.0, 1.0]);
paint_layer(&mut s, |_, _| [0.5, 0.5, 0.5, 1.0]);
let steps = s.active().unwrap().history.past_len();
// Multiply by a white source would leave the grey as it is, but blending each dab with the
// already-mixed result darkened every dab edge: refused until a stroke buffer exists.
assert!(stroke(&mut s, json!({"state": 1, "mode": "multiply"})).is_err());
assert_eq!(s.active().unwrap().history.past_len(), steps);
assert!((rgba(&s, 24, 16)[0] - 0.5).abs() < 0.01);
stroke(&mut s, json!({"state": 1, "mode": "normal"})).unwrap();
assert!(rgba(&s, 24, 16)[0] > 0.9, "{:?}", rgba(&s, 24, 16));
}
#[test]
fn bad_params_are_errors() {
let mut s = session(32, 24, 8, "rgb");
paint_layer(&mut s, |_, _| [0.2, 0.3, 0.4, 1.0]);
assert!(s.execute("paint.artHistoryBrush", json!([])).is_err());
assert!(s.execute("paint.artHistoryBrush", json!({})).is_err());
assert!(s.execute("paint.artHistoryBrush", json!({"points": []})).is_err());
assert!(
s.execute("paint.artHistoryBrush", json!({"points": [[0, 0]], "style": "nope"})).unwrap_err().to_string().contains("unknown Art History style")
);
assert!(s.execute("paint.artHistoryBrush", json!({"points": [[0, 0]], "state": 99})).is_err());
assert!(s.execute("paint.artHistoryBrush", json!({"points": [[0, 0]], "state": u64::MAX})).is_err());
assert!(s.execute("paint.artHistoryBrush", json!({"points": [[0, 0]], "area": -1})).is_err());
assert!(s.execute("paint.artHistoryBrush", json!({"points": [[0, 0]], "size": "nan"})).is_err());
let many: Vec<[f32; 2]> = (0..10_000).map(|i| [i as f32, 0.0]).collect();
assert!(s.execute("paint.artHistoryBrush", json!({"points": many})).unwrap_err().to_string().contains("too many points"));
assert!(s.execute("paint.artHistoryBrush", json!({"points": [[5, 5]], "size": 8000})).unwrap_err().to_string().contains("stroke too large"));
assert!(!Session::new().is_enabled("paint.artHistoryBrush"));
assert!(crate::commands::find("paint.artHistoryBrush").is_some());
}
#[test]
fn pixel_lock_and_type_layer_error() {
let mut s = session(24, 24, 8, "rgb");
s.execute("layer.setProps", json!({"locks": {"pixels": true}})).unwrap();
let e = stroke(&mut s, json!({})).unwrap_err().to_string();
assert!(e.contains("locked"), "{e}");
s.execute("layer.setProps", json!({"locks": {"pixels": false}})).unwrap();
s.execute("type.create", json!({"x": 8, "y": 16, "text": "Hi", "size": 12})).unwrap();
assert!(stroke(&mut s, json!({})).is_err());
let kind = s.active().and_then(|d| d.active_layer.and_then(|id| d.doc.layer(id))).map(|l| matches!(l.content, LayerContent::Text(_)));
assert_eq!(kind, Some(true));
}
#[test]
fn cmyk_does_not_panic() {
let mut s = session(32, 24, 8, "cmyk");
paint_layer(&mut s, |_, _| [0.2, 0.3, 0.4, 1.0]);
s.execute("edit.fill", json!({"color": "#404040"})).ok();
let _ = stroke(&mut s, json!({"tolerance": 0}));
assert_eq!(s.active().unwrap().doc.mode, ColorMode::Cmyk);
}
#[test]
fn command_is_registered() {
let spec = crate::commands::find("paint.artHistoryBrush").expect("registered");
assert_eq!(spec.label, "Art History Brush");
assert!(spec.params.contains("style"));
assert!(spec.journal);
assert!(!Session::new().is_enabled("paint.artHistoryBrush"));
let s = session(16, 16, 8, "rgb");
assert!(s.is_enabled("paint.artHistoryBrush"));
}
}
+7 -1
View File
@@ -690,6 +690,7 @@ fn retouch_commands_are_registered_and_need_a_pixel_layer() {
"paint.smudge",
"paint.historyBrush",
"paint.remove",
"paint.artHistoryBrush",
];
for id in ids {
let spec = crate::commands::find(id).unwrap_or_else(|| panic!("{id} missing"));
@@ -706,7 +707,7 @@ fn retouch_commands_are_registered_and_need_a_pixel_layer() {
// Paint target (#207): mask, alpha channel, Quick Mask
// ---------------------------------------------------------------------------------------------
const RETOUCH_IDS: [&str; 12] = [
const RETOUCH_IDS: [&str; 13] = [
"paint.cloneStamp",
"paint.patternStamp",
"paint.healingBrush",
@@ -719,6 +720,7 @@ const RETOUCH_IDS: [&str; 12] = [
"paint.smudge",
"paint.historyBrush",
"paint.remove",
"paint.artHistoryBrush",
];
/// Grey noise in 0.3..0.7 (no period along the clone offset used below).
@@ -769,6 +771,10 @@ fn retouch_params(id: &str, history_state: usize, target: Value) -> Value {
match id {
"paint.cloneStamp" | "paint.healingBrush" => p["offset"] = json!([-13, 3]),
"paint.historyBrush" => p["state"] = json!(history_state),
"paint.artHistoryBrush" => {
p["state"] = json!(history_state);
p["tolerance"] = json!(0);
}
"paint.sponge" => p["mode"] = json!("saturate"),
_ => {}
}
+1
View File
@@ -138,6 +138,7 @@ pub const TOOLS: &[(&str, &str, Option<&str>)] = &[
("tools.select.cloneStamp", "Clone Stamp Tool", Some("S")),
("tools.select.patternStamp", "Pattern Stamp Tool", Some("S")),
("tools.select.historyBrush", "History Brush Tool", Some("Y")),
("tools.select.artHistoryBrush", "Art History Brush Tool", Some("Y")),
("tools.select.blur", "Blur Tool", None),
("tools.select.sharpen", "Sharpen Tool", None),
("tools.select.smudge", "Smudge Tool", None),
+34
View File
@@ -644,6 +644,40 @@ fn smart_mut(doc: &mut Document, id: LayerId) -> Result<&mut SmartObject> {
}
}
/// Whether `l`'s filter mask can be painted (`"target":"filterMask"`): a smart object with smart
/// filters. Its filter mask need not exist yet: painting starts it white (Reveal All), the mask
/// Photoshop adds with the first smart filter.
pub(crate) fn filter_mask_paintable(l: &Layer) -> std::result::Result<(), String> {
match &l.content {
LayerContent::Smart(sm) if !sm.smart_filters.is_empty() => Ok(()),
LayerContent::Smart(_) => Err(format!("smart object \"{}\" has no smart filters, so no filter mask", l.name)),
other => Err(format!("the active layer is {} {} layer, not a smart object", other.article(), other.kind_name())),
}
}
/// The filter mask surface of smart object `id` for painting into, made white (Reveal All) first
/// when the object has smart filters but no filter mask yet. The caller re-renders the object
/// afterwards ([`refresh_after_filter_mask_edit`]).
pub(crate) fn filter_mask_surface(doc: &mut Document, id: LayerId) -> Result<&mut Surface> {
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
filter_mask_paintable(l).map_err(EngineError::Other)?;
let sm = smart_mut_of(l)?;
Ok(&mut sm.filter_mask.get_or_insert_with(LayerMask::reveal_all).surface)
}
/// Re-renders smart object `id` after its filter mask was painted, filled or filtered, so the
/// stroke shows (where the mask is black the smart filters don't). Part of the same history step.
pub(crate) fn refresh_after_filter_mask_edit(doc: &mut Document, id: LayerId) -> Result<()> {
if matches!(doc.layer(id).map(|l| &l.content), Some(LayerContent::Smart(_))) { refresh_or_fail(doc, id) } else { Ok(()) }
}
fn smart_mut_of(l: &mut Layer) -> Result<&mut SmartObject> {
match &mut l.content {
LayerContent::Smart(sm) => Ok(sm),
_ => Err(other("the layer is not a smart object")),
}
}
fn mask_from_selection(sel: &Surface) -> LayerMask {
LayerMask { surface: sel.clone(), enabled: true, linked: true, density: 1.0, feather: 0.0 }
}
+1
View File
@@ -1,5 +1,6 @@
use super::*;
mod filter_mask_paint;
mod selection;
mod stack_cache;
@@ -0,0 +1,201 @@
//! FILE-215-3: painting a smart object's filter mask (`"target":"filterMask"`) hides the smart
//! filters where it is black, in one undo step, and the painted mask is saved.
use super::*;
/// A vertical black stroke over x = 8 into the filter mask, as the Brush tool sends it.
fn stroke(s: &mut Session, target: Option<&str>) -> Result<Value> {
let mut p = json!({"points": [[8, 2], [8, 46]], "size": 8, "hardness": 1, "color": "#000000"});
if let Some(t) = target {
p["target"] = json!(t);
}
s.execute("paint.stroke", p)
}
#[test]
fn painting_the_filter_mask_hides_the_filters_there_in_one_undo_step() {
for depth in DEPTHS {
let mut s = session(depth);
paint(&mut s);
let original = flat(&s);
convert(&mut s);
// Without smart filters there is no filter mask to paint.
let before = s.active().unwrap().doc.clone();
let steps = s.active().unwrap().history.past_len();
assert!(matches!(stroke(&mut s, Some("filterMask")), Err(EngineError::Disabled(..))), "depth {depth}");
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
assert_eq!(s.active().unwrap().history.past_len(), steps);
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
let blurred = flat(&s);
assert!(active_smart(&s).filter_mask.is_none());
let steps = s.active().unwrap().history.past_len();
stroke(&mut s, Some("filterMask")).unwrap();
let mask = active_smart(&s).filter_mask.expect("painting starts a white filter mask");
assert_eq!((mask.value(8, 20), mask.value(30, 20)), (0.0, 1.0), "depth {depth}");
let px = flat(&s);
let at = |v: &Vec<[f32; 4]>, x: i32, y: i32| v[(y * W + x) as usize];
assert!(max_diff(&[at(&px, 8, 20)], &[at(&original, 8, 20)]) < 1e-6, "depth {depth}: black hides the blur");
assert_eq!(at(&px, 30, 20), at(&blurred, 30, 20), "depth {depth}: white keeps it");
assert!(active_smart(&s).smart_filters.len() == 1, "the stroke is no smart filter");
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one step");
s.execute("edit.undo", json!({})).unwrap();
assert!(active_smart(&s).filter_mask.is_none());
assert_eq!(flat(&s), blurred, "depth {depth}: undo brings the blur back");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(flat(&s), px);
// The painted mask is saved: .pcraft and PSD give it back.
let doc = s.active().unwrap().doc.clone();
let id = s.active().unwrap().active_layer.unwrap();
let back = photocraft_format::load_from_bytes(&photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap()).unwrap();
assert_eq!(smart(&back, id).unwrap().filter_mask, smart(&doc, id).unwrap().filter_mask, "depth {depth}: .pcraft");
let psd = photocraft_io::export(&doc, "psd", &Default::default()).unwrap();
let back = photocraft_io::import("x.psd", &psd.bytes).unwrap().document;
let sm = back.walk().into_iter().find_map(|(_, _, l)| if let LayerContent::Smart(sm) = &l.content { Some(sm.clone()) } else { None }).unwrap();
let (mut a, mut b) = (Vec::new(), Vec::new());
sm.filter_mask.as_ref().unwrap().values_into(doc.bounds(), &mut a);
mask.values_into(doc.bounds(), &mut b);
assert_eq!(a, b, "depth {depth}: PSD");
}
}
/// ⌥-click on the filter mask thumbnail shows it; painting then goes into it without a target.
#[test]
fn a_shown_filter_mask_takes_the_paint() {
let mut s = session(8);
paint(&mut s);
convert(&mut s);
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
let before = s.active().unwrap().channel_view.clone();
assert!(s.execute("view.layerMask", json!({"mode": "gray", "mask": "filter"})).is_err(), "no filter mask yet");
assert_eq!(s.active().unwrap().channel_view, before);
stroke(&mut s, Some("filterMask")).unwrap();
let v = s.execute("view.layerMask", json!({"mode": "gray", "mask": "filter"})).unwrap();
assert_eq!(v["mode"], "gray");
assert!(s.active().unwrap().channel_view.shown_layer_mask(&s.active().unwrap().doc).is_some());
s.execute("paint.stroke", json!({"points": [[30, 2], [30, 46]], "size": 8, "hardness": 1, "color": "#000000"})).unwrap();
let mask = active_smart(&s).filter_mask.unwrap();
assert_eq!(mask.value(30, 20), 0.0, "the stroke went into the filter mask");
let d = s.active().unwrap();
assert!(matches!(d.doc.layer(d.active_layer.unwrap()).unwrap().content, LayerContent::Smart(_)), "still a smart object");
// Bad params are refused, not a panic.
let before = s.active().unwrap().channel_view.clone();
assert!(s.execute("view.layerMask", json!({"mask": 3})).is_err());
assert_eq!(s.active().unwrap().channel_view, before);
}
#[test]
fn copying_the_filter_mask_never_copies_the_layer_mask() {
for depth in DEPTHS {
let mut s = session(depth);
paint(&mut s);
convert(&mut s);
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
stroke(&mut s, Some("filterMask")).unwrap();
for layer_mask in [false, true] {
if layer_mask {
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
}
let before = s.active().unwrap().doc.clone();
let steps = s.active().unwrap().history.past_len();
let result = s.execute("edit.copy", json!({"target": "filterMask"})).unwrap();
assert_eq!(result["target"], "filterMask");
let clip = s.clipboard.as_ref().unwrap();
assert_eq!(clip.surface.rgba(8, 20), [0.0, 0.0, 0.0, 1.0]);
assert_eq!(clip.surface.rgba(30, 20), [1.0; 4]);
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
assert_eq!(s.active().unwrap().history.past_len(), steps);
}
}
}
#[test]
fn an_explicit_layer_checks_filter_mask_eligibility() {
let mut s = session(8);
paint(&mut s);
let id = convert(&mut s);
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
s.execute("shape.create", json!({"kind": "rect", "rect": [1, 1, 20, 20], "fill": "#ff0000"})).unwrap();
let active = s.active().unwrap().active_layer;
s.execute("paint.cloneStamp", json!({"layer": id, "target": "filterMask", "source": [8, 8], "points": [[20, 20]], "size": 4})).unwrap();
assert_eq!(s.active().unwrap().active_layer, active);
// A shape has no filter mask: rejection must precede recoloring its fill.
let before = s.active().unwrap().doc.clone();
let steps = s.active().unwrap().history.past_len();
assert!(s.execute("edit.fillForeground", json!({"layer": active.unwrap().0, "target": "filterMask"})).is_err());
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
assert_eq!(s.active().unwrap().history.past_len(), steps);
}
/// With its filter mask targeted, the commands that don't edit a filter mask act on the smart
/// object as without the target: Edit › Transform flips it, and Convert for Smart Filters stays
/// disabled (it would nest the smart object).
#[test]
fn the_filter_mask_target_leaves_the_smart_object_commands_alone() {
let mut s = session(8);
paint(&mut s);
convert(&mut s);
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
stroke(&mut s, Some("filterMask")).unwrap();
let steps = s.active().unwrap().history.past_len();
assert!(
matches!(s.execute("filter.convertForSmartFilters", json!({"target": "filterMask"})), Err(EngineError::Disabled(..))),
"the layer is already a smart object"
);
assert_eq!(s.active().unwrap().history.past_len(), steps);
let before = active_smart(&s).transform;
s.execute("edit.transform.flipHorizontal", json!({"target": "filterMask"})).unwrap();
assert_ne!(active_smart(&s).transform, before, "the smart object flips");
// A shown filter mask doesn't send them the target either.
s.execute("view.layerMask", json!({"mode": "gray", "mask": "filter"})).unwrap();
assert_eq!(crate::channel_cmds::inject_target(&s, "edit.transform.flipHorizontal", json!({})), json!({}));
assert_eq!(crate::channel_cmds::inject_target(&s, "filter.convertForSmartFilters", json!({})), json!({}));
assert_eq!(crate::channel_cmds::inject_target(&s, "paint.stroke", json!({}))["target"], "filterMask");
// Curves on the shown filter mask edits that gray plane, not the layer mask or the colours.
let curve = crate::channel_cmds::isolated_curve_channel(s.active().unwrap()).expect("a gray target");
assert_eq!(curve.gray, Some(crate::channel_cmds::GraySurface::FilterMask));
let flipped = active_smart(&s).transform;
s.execute("edit.transform.flipHorizontal", json!({})).unwrap();
assert_ne!(active_smart(&s).transform, flipped, "the smart object flips again");
// Moving the smart object doesn't re-render its filters as a filter-mask edit.
let p = json!({"dx": 2, "target": "filterMask"});
assert_eq!(crate::channel_cmds::filter_mask_layer(&s, "layer.translate", &p), None);
assert!(crate::channel_cmds::filter_mask_layer(&s, "paint.stroke", &p).is_some());
// Every other routed command edits it and re-renders the object: Equalize too, though it
// takes no layer mask target by default.
for cmd in ["image.adjustments.shadowsHighlights", "plugin.run", "filter.blur.average"] {
assert!(crate::channel_cmds::filter_mask_layer(&s, cmd, &p).is_some(), "{cmd}");
}
let steps = s.active().unwrap().history.past_len();
s.execute("image.adjustments.equalize", json!({"target": "filterMask"})).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
assert_eq!(active_smart(&s).smart_filters.len(), 1, "no new smart filter: the filter mask was equalized");
}
/// Gaussian Blur with the filter mask targeted softens the mask's edge (the usual way to feather
/// it), re-renders the smart object, and adds no second smart filter.
#[test]
fn blurring_the_filter_mask_feathers_it() {
for depth in DEPTHS {
let mut s = session(depth);
paint(&mut s);
convert(&mut s);
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
stroke(&mut s, Some("filterMask")).unwrap();
let hard = active_smart(&s).filter_mask.unwrap();
let edge = (9..W).find(|&x| hard.value(x, 20) == 1.0).expect("white right of the stroke");
let px = flat(&s);
let steps = s.active().unwrap().history.past_len();
s.execute("filter.blur.gaussianBlur", json!({"radius": 3, "target": "filterMask"})).unwrap();
let soft = active_smart(&s).filter_mask.unwrap();
let v = soft.value(edge, 20);
assert!(v > 0.0 && v < 1.0, "depth {depth}: the mask edge is soft, got {v}");
assert_eq!(soft.value(30, 20), 1.0, "depth {depth}: far from the stroke stays white");
assert_eq!(active_smart(&s).smart_filters.len(), 1, "depth {depth}: no second smart filter");
assert_ne!(flat(&s), px, "depth {depth}: the smart object is re-rendered");
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "depth {depth}: one step");
}
}
+2
View File
@@ -194,6 +194,8 @@ pub fn lone_target<'a>(doc: &'a Document, layer: Option<LayerId>, p: &Value) ->
let m = l.mask.as_ref().ok_or_else(|| EngineError::Other("layer has no mask".into()))?;
Ok((!m.linked).then_some(&m.surface))
}
// A filter mask is not transformed on its own: the smart object transforms, as in Photoshop.
Target::FilterMask => Ok(None),
Target::Alpha(i) => doc
.channels
.get(i)
+48 -10
View File
@@ -163,6 +163,11 @@ pub(crate) fn color(v: &Value) -> Option<Color> {
_ => None,
}
}
// A stored `Color` (mode + components), so a CMYK or Lab gradient stop survives a
// read-modify-write through the shape swatches.
Value::Object(_) => serde_json::from_value::<Color>(v.clone())
.ok()
.filter(|c| c.c.iter().all(|x| x.is_finite()) && c.alpha.is_finite() && (0.0..=1.0).contains(&c.alpha)),
_ => None,
}
}
@@ -247,15 +252,21 @@ pub fn path_json(p: &Path) -> Value {
fn fill_json(f: &Fill) -> Value {
match f {
Fill::Solid(c) => json!(hex(c)),
Fill::Gradient { stops, angle, scale, style, reverse, .. } => json!({ "gradient": {
Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align } => json!({ "gradient": {
"stops": stops.iter().map(|(t, c)| json!([t, hex(c)])).collect::<Vec<_>>(),
"angle": angle, "scale": scale * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse } }),
"angle": angle, "scale": scale * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse,
"opacityStops": opacity_stops, "midpoints": midpoints, "offset": [offset.0, offset.1], "dither": dither, "align": align } }),
Fill::Pattern { name, scale, .. } => json!({ "pattern": name, "scale": scale * 100.0 }),
}
}
/// `"#rrggbb"` / `[r,g,b,a]` / `{"gradient":{"stops":[[t,"#hex"],…],"angle","scale","style","reverse"}}` /
/// `{"pattern":name,"scale"}`. `null` → `Ok(None)` (no fill).
/// The most colour stops, opacity stops or midpoints a gradient fill takes, as the gradient stop
/// edits (`gradient.fill.stop`, `gradient.presets.stop`) cap them.
const MAX_GRADIENT_STOPS: usize = 1024;
/// `"#rrggbb"` / `[r,g,b,a]` / `{"gradient":{"stops":[[t,"#hex"|[r,g,b,a]],…],"angle","scale","style","reverse",
/// "opacityStops":[[t,0..1],…],"midpoints":[0..1,…],"offset":[x,y],"dither","align"}}` / `{"pattern":name,"scale"}`.
/// `null` → `Ok(None)` (no fill).
fn parse_fill(v: &Value) -> std::result::Result<Option<Fill>, String> {
if v.is_null() {
return Ok(None);
@@ -264,9 +275,14 @@ fn parse_fill(v: &Value) -> std::result::Result<Option<Fill>, String> {
return Ok(Some(Fill::Solid(c)));
}
if let Some(g) = v.get("gradient") {
let stops: Vec<(f32, Color)> = g
.get("stops")
.and_then(Value::as_array)
// Stop lists are capped as `gradient.fill.stop` caps them: more is an error, not a cut.
let list = |key: &str, what: &str| match g.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Array(a)) if a.len() > MAX_GRADIENT_STOPS => Err(format!("too many {what} (max {MAX_GRADIENT_STOPS})")),
Some(Value::Array(a)) => Ok(Some(a)),
Some(_) => Err(format!("gradient `{key}` must be an array")),
};
let stops: Vec<(f32, Color)> = list("stops", "colour stops")?
.map(|a| a.iter().filter_map(|s| Some((s.get(0)?.as_f64()? as f32, color(s.get(1)?)?))).collect())
.unwrap_or_else(|| vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]);
let style = match g.get("style").and_then(Value::as_str).unwrap_or("linear") {
@@ -276,13 +292,35 @@ fn parse_fill(v: &Value) -> std::result::Result<Option<Fill>, String> {
"diamond" => GradientStyle::Diamond,
_ => GradientStyle::Linear,
};
return Ok(Some(Fill::gradient(
let mut fill = Fill::gradient(
stops,
f64p(g, "angle").unwrap_or(90.0) as f32,
f64p(g, "scale").unwrap_or(100.0) as f32 / 100.0,
style,
g.get("reverse").and_then(Value::as_bool).unwrap_or(false),
)));
);
// The rest of a gradient, so a read-modify-write (the shape swatches) loses nothing.
if let Fill::Gradient { opacity_stops, midpoints, offset, dither, align, .. } = &mut fill {
let unit = |v: &Value| v.as_f64().map(|n| n.clamp(0.0, 1.0) as f32);
if let Some(a) = list("opacityStops", "opacity stops")? {
*opacity_stops = a.iter().filter_map(|s| Some((unit(s.get(0)?)?, unit(s.get(1)?)?))).collect();
opacity_stops.sort_by(|a, b| a.0.total_cmp(&b.0));
}
if let Some(a) = list("midpoints", "midpoints")? {
*midpoints = a.iter().filter_map(unit).collect();
}
if let Some(o) = g.get("offset") {
let at = |i: usize| o.get(i).and_then(Value::as_f64).map(|n| n.clamp(-1000.0, 1000.0) as f32);
*offset = (at(0).ok_or("gradient offset must be [x, y]")?, at(1).ok_or("gradient offset must be [x, y]")?);
}
if let Some(d) = g.get("dither").and_then(Value::as_bool) {
*dither = d;
}
if let Some(a) = g.get("align").and_then(Value::as_bool) {
*align = a;
}
}
return Ok(Some(fill));
}
if let Some(name) = v.get("pattern").and_then(Value::as_str) {
return Ok(Some(Fill::Pattern {
@@ -1756,7 +1794,7 @@ pub(crate) fn leak(s: String) -> &'static str {
pub fn specs() -> Vec<CommandSpec> {
let stroke = r##""stroke":{"width":px,"color":"#rrggbb"|fill,"opacity":0..100,"align":"inside|center|outside","cap":"butt|round|square","join":"miter|round|bevel","miterLimit":n,"dashes":[multiples of width],"dashOffset":n}|null"##;
let fill = r##""fill":"#rrggbb"|[r,g,b,a]|{"gradient":{"stops":[[t,"#hex"]],"angle":deg,"scale":%,"style":"linear|radial|angle|reflected|diamond","reverse":bool}}|{"pattern":name}|null (no fill)"##;
let fill = r##""fill":"#rrggbb"|[r,g,b,a]|{"gradient":{"stops":[[t,"#hex"|[r,g,b,a]|stored Color]],"angle":deg,"scale":%,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"opacityStops":[[t,0..1]]?,"midpoints":[0..1]?,"offset":[x,y]?,"dither":bool?,"align":bool?}}|{"pattern":name}|null (no fill)"##;
vec![
spec!(
"shape.create",
+68
View File
@@ -27,15 +27,83 @@ fn stroke_validation_rejects_hostile_values_without_editing_history() {
json!({"dashOffset": 1e100}),
json!({"width": "4"}),
json!({"width": 0.00001, "dashes": [0.01, 0.01]}),
json!({"color": {"gradient": {"offset": [0.5]}}}),
json!({"color": {"gradient": {"offset": "centre"}}}),
// Stop lists past the gradient stop edits' 1024 cap are refused, not cut.
json!({"color": {"gradient": {"stops": vec![json!([0.5, "#ff0000"]); 1025]}}}),
json!({"color": {"gradient": {"opacityStops": vec![json!([0.5, 1.0]); 1025]}}}),
json!({"color": {"gradient": {"midpoints": vec![0.5; 1025]}}}),
// A stop list that is not an array is an error, not the default black-to-white.
json!({"color": {"gradient": {"stops": "red"}}}),
json!({"color": {"gradient": {"opacityStops": {}}}}),
json!({"color": {"gradient": {"midpoints": 1}}}),
];
for stroke in invalid {
assert!(s.execute("shape.edit", json!({"layer": id, "stroke": stroke})).is_err(), "{stroke}");
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
assert!(s.execute("shape.create", json!({"rect": [20, 20, 50, 30], "stroke": stroke})).is_err(), "{stroke}");
assert_eq!(s.active().unwrap().doc, before, "{stroke}: a rejected create adds no layer");
assert_eq!(s.active().unwrap().history.past_len(), history, "{stroke}: nor a history state");
}
assert!(s.execute("shape.edit", json!({"layer": id, "rect": [1e100, 0, 20, 20]})).is_err());
assert_eq!(s.active().unwrap().doc, before);
// The cap itself is accepted.
let at_cap = json!({"color": {"gradient": {"opacityStops": vec![json!([0.5, 1.0]); 1024], "midpoints": vec![0.5; 1024]}}});
s.execute("shape.edit", json!({"layer": id, "stroke": at_cap})).unwrap();
}
/// A gradient stop given as a stored `Color` keeps its colour model: a CMYK stop stays CMYK.
#[test]
fn gradient_stops_take_a_stored_color_and_keep_its_model() {
let mut s = session(100, 80, 8);
let id = s.execute("shape.create", json!({"rect": [20, 20, 50, 30], "stroke": {"width": 6}})).unwrap()["layer"].as_u64().unwrap();
let cmyk = Color { mode: photocraft_doc::ColorMode::Cmyk, c: [0.1, 0.2, 0.3, 0.9], alpha: 0.75 };
let stops = json!([[0.0, cmyk], [1.0, "#ffffff"]]);
s.execute("shape.edit", json!({"layer": id, "stroke": {"color": {"gradient": {"stops": stops}}}})).unwrap();
let doc = &s.active().unwrap().doc;
let LayerContent::Shape(sh) = &doc.layer(LayerId(id)).unwrap().content else { panic!("a shape layer") };
let Fill::Gradient { stops, .. } = &sh.stroke.as_ref().unwrap().paint else { panic!("a gradient stroke") };
assert_eq!(stops[0], (0.0, cmyk));
assert_eq!(stops[1], (1.0, Color::WHITE), "hex stops still parse as before");
// A stored colour that isn't finite or has alpha past 1 is not a colour: the stop is dropped,
// as an unparseable hex stop is.
let bad = json!({"mode": "Cmyk", "c": [0.1, 0.2, 0.3, 0.9], "alpha": 2.0});
s.execute("shape.edit", json!({"layer": id, "stroke": {"color": {"gradient": {"stops": [[0.0, bad], [1.0, "#000000"]]}}}})).unwrap();
let doc = &s.active().unwrap().doc;
let LayerContent::Shape(sh) = &doc.layer(LayerId(id)).unwrap().content else { panic!("a shape layer") };
let Fill::Gradient { stops, .. } = &sh.stroke.as_ref().unwrap().paint else { panic!("a gradient stroke") };
assert_eq!(stops.as_slice(), [(1.0, Color::BLACK)]);
}
/// A gradient stroke keeps every gradient field through `shape.edit`, and `shape.info` reports
/// them, so the shape swatches can change one setting without dropping the rest.
#[test]
fn gradient_stroke_round_trips_every_gradient_field() {
let mut s = session(100, 80, 16);
let id = s.execute("shape.create", json!({"rect": [20, 20, 50, 30], "stroke": {"width": 6}})).unwrap()["layer"].as_u64().unwrap();
let gradient = json!({"gradient": {
"stops": [[0.0, [0.25, 0.5, 0.75, 1.0]], [1.0, [1.0, 0.125, 0.0, 0.5]]],
"angle": 30.0, "scale": 50.0, "style": "radial", "reverse": true,
"opacityStops": [[1.0, 0.25], [0.0, 1.0]], "midpoints": [0.25], "offset": [0.125, -0.25], "dither": true, "align": false,
}});
let r = s.execute("shape.edit", json!({"layer": id, "stroke": {"color": gradient}})).unwrap();
let Some(Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align }) = shape(&s, id).stroke.map(|st| st.paint)
else {
panic!("a gradient stroke")
};
assert_eq!(stops, vec![(0.0, Color::rgba(0.25, 0.5, 0.75, 1.0)), (1.0, Color::rgba(1.0, 0.125, 0.0, 0.5))]);
assert_eq!((angle, scale, style, reverse), (30.0, 0.5, GradientStyle::Radial, true));
assert_eq!(opacity_stops, vec![(0.0, 1.0), (1.0, 0.25)], "opacity stops are sorted");
assert_eq!((midpoints, offset, dither, align), (vec![0.25], (0.125, -0.25), true, false));
let g = &r["stroke"]["fill"]["gradient"];
assert_eq!((g["style"].as_str(), g["midpoints"].clone(), g["align"].as_bool()), (Some("radial"), json!([0.25]), Some(false)));
// A partial gradient keeps the defaults it always had.
s.execute("shape.edit", json!({"layer": id, "stroke": {"color": {"gradient": {"stops": [[0, "#000000"], [1, "#ffffff"]]}}}})).unwrap();
let Some(Fill::Gradient { opacity_stops, midpoints, offset, dither, align, .. }) = shape(&s, id).stroke.map(|st| st.paint) else {
panic!("a gradient stroke")
};
assert_eq!((opacity_stops, midpoints, offset, dither, align), (Vec::new(), Vec::new(), (0.0, 0.0), false, true));
}
#[test]
+250 -40
View File
@@ -3,7 +3,7 @@
//! ([`photocraft_algo::vanishing`]). The edit lands on the active pixel layer, or on a new layer
//! with `"newLayer": true` (Photoshop's advice for non-destructive Vanishing Point work).
use photocraft_algo::vanishing::{Edge, PlaneCheck, Scene, VpPlane, clone_stroke, paste};
use photocraft_algo::vanishing::{Edge, PlaneCheck, Scene, VpPlane, clone_dabs, paste};
use photocraft_doc::{Document, Layer, LayerContent, LayerId};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
@@ -13,6 +13,11 @@ use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
pub const VP: &str = "filter.vanishingPoint";
/// Most planes a scene holds.
pub const MAX_PLANES: usize = 64;
/// Most clone strokes, and dabs over all of them, one run paints.
pub const MAX_CLONE_STROKES: usize = 4096;
pub const MAX_CLONE_DABS: usize = 200_000;
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: VP.into(), msg: msg.into() }
@@ -29,6 +34,9 @@ pub fn scene(p: &Value, canvas: Rect) -> Result<Scene> {
.and_then(Value::as_array)
.filter(|a| !a.is_empty())
.ok_or_else(|| bad("pass `planes`: [{\"corners\":[[x,y]×4]}, {\"from\":0,\"edge\":\"top\",\"angle\":90}]"))?;
if planes.len() > MAX_PLANES {
return Err(bad(format!("at most {MAX_PLANES} planes")));
}
let center = [(canvas.x0 + canvas.x1) as f64 / 2.0, (canvas.y0 + canvas.y1) as f64 / 2.0];
let corners = |v: &Value| -> Option<[[f64; 2]; 4]> {
let a = v.get("corners")?.as_array()?;
@@ -67,6 +75,12 @@ pub fn scene(p: &Value, canvas: Rect) -> Result<Scene> {
};
let angle = pl.get("angle").and_then(Value::as_f64).unwrap_or(90.0);
let depth = pl.get("depth").and_then(Value::as_f64).unwrap_or(1.0);
if !angle.is_finite() || !(0.0..=360.0).contains(&angle) {
return Err(bad(format!("plane {i}: `angle` must be 0..360 degrees")));
}
if !depth.is_finite() || depth <= 0.0 || depth > 1000.0 {
return Err(bad(format!("plane {i}: `depth` must be above 0 and at most 1000")));
}
sc.tear_off(from, edge, angle, depth).ok_or_else(|| bad(format!("plane {i} would lie behind the camera")))?;
}
if sc.planes.iter().any(|p| p.homography().is_none()) {
@@ -75,6 +89,88 @@ pub fn scene(p: &Value, canvas: Rect) -> Result<Scene> {
Ok(sc)
}
/// One stroke of `"clone"`.
#[derive(Clone, Debug, PartialEq)]
pub struct CloneSpec {
pub source: [f64; 2],
pub points: Vec<[f64; 2]>,
pub size: f64,
pub hardness: f64,
pub opacity: f64,
}
/// Reads `"clone"`: finite points, at most [`MAX_CLONE_STROKES`] strokes and
/// [`MAX_CLONE_DABS`] dabs in all.
pub fn clone_specs(p: &Value) -> Result<Vec<CloneSpec>> {
let Some(v) = p.get("clone").filter(|v| !v.is_null()) else { return Ok(Vec::new()) };
let strokes = v.as_array().ok_or_else(|| bad("`clone` is a list of strokes"))?;
if strokes.len() > MAX_CLONE_STROKES {
return Err(bad(format!("at most {MAX_CLONE_STROKES} clone strokes")));
}
let finite = |q: [f64; 2]| q.iter().all(|v| v.is_finite() && v.abs() < 1e7);
let num = |c: &Value, k: &str, default: f64, lo: f64, hi: f64| -> Result<f64> {
match c.get(k) {
None | Some(Value::Null) => Ok(default),
Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(lo, hi)).ok_or_else(|| bad(format!("clone `{k}` must be a number"))),
}
};
let mut dabs = 0usize;
let mut out = Vec::with_capacity(strokes.len());
for (k, c) in strokes.iter().enumerate() {
let source = c.get("source").and_then(pt).filter(|q| finite(*q)).ok_or_else(|| bad(format!("clone {k}: `source` [x,y]")))?;
let raw = c.get("points").and_then(Value::as_array).ok_or_else(|| bad(format!("clone {k}: `points` [[x,y], …]")))?;
dabs = dabs.saturating_add(raw.len());
if dabs > MAX_CLONE_DABS {
return Err(bad(format!("at most {MAX_CLONE_DABS} clone points in all")));
}
let points =
raw.iter().map(|q| pt(q).filter(|q| finite(*q))).collect::<Option<Vec<_>>>().ok_or_else(|| bad(format!("clone {k}: every point is [x,y]")))?;
out.push(CloneSpec {
source,
points,
size: num(c, "size", 40.0, 1.0, 2000.0)?,
hardness: num(c, "hardness", 50.0, 0.0, 100.0)? / 100.0,
opacity: num(c, "opacity", 100.0, 0.0, 100.0)? / 100.0,
});
}
Ok(out)
}
/// Paints clone stroke `c` on `surf` inside `canvas`, or, with `work` (the image it samples), on
/// `work` and copies the pixels it changed to `surf`. Returns the dabs painted.
pub fn clone_into(sc: &Scene, surf: &mut Surface, work: Option<&mut Surface>, c: &CloneSpec, canvas: Rect) -> usize {
let Some(first) = c.points.first() else { return 0 };
let Some(work) = work else {
return clone_dabs(sc, surf, c.source, *first, &c.points, c.size, c.hardness, c.opacity as f32, Some(canvas)).0;
};
let before = work.clone();
// The dabs are cut to the canvas before they touch `work` or `surf`: points up to 1e7 px off
// it would otherwise allocate tiles there (and reading their union back, without bound).
let (dabs, area) = clone_dabs(sc, work, c.source, *first, &c.points, c.size, c.hardness, c.opacity as f32, Some(canvas));
copy_changed(surf, &before, work, area.intersect(&canvas));
dabs
}
/// Copies to `dst` the pixels of `area` where `after` differs from `before` (same format).
pub fn copy_changed(dst: &mut Surface, before: &Surface, after: &Surface, area: Rect) {
if area.is_empty() {
return;
}
let n = dst.format().channels();
let (nw, ow, mut cur) = (after.read_region(area), before.read_region(area), dst.read_region(area));
for ((c, a), b) in cur.chunks_exact_mut(n).zip(nw.chunks_exact(n)).zip(ow.chunks_exact(n)) {
if a != b {
c.copy_from_slice(a);
}
}
dst.write_region(area, &cur);
}
/// The image a `"newLayer"` run's clone strokes sample: the visible document.
pub fn visible_image(doc: &Document) -> Surface {
crate::file_cmds::flattened(doc, doc.pixel_format())
}
fn has_layer(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
match d.active_layer.and_then(|id| d.doc.layer(id)).map(|l| &l.content) {
@@ -84,8 +180,16 @@ fn has_layer(s: &Session) -> std::result::Result<(), String> {
}
}
/// Pastes `src` (content in `rect`) onto plane `plane` of `sc` over `surf`, as the command's
/// `"paste"` does. Returns the area the paste covers.
pub fn paste_onto(sc: &Scene, surf: &mut Surface, plane: usize, src: &Surface, rect: Rect, at: [f64; 2], width: f64) -> Result<Rect> {
let Some(out) = paste(sc, plane, src, rect, at, width)? else { return Ok(Rect::EMPTY) };
over(surf, &out);
Ok(out.content_bounds())
}
/// Composites `top` over `dst` (premultiplied source-over), same format.
fn over(dst: &mut Surface, top: &Surface) {
pub fn over(dst: &mut Surface, top: &Surface) {
let b = top.content_bounds();
if b.is_empty() {
return;
@@ -134,20 +238,31 @@ fn vanishing_point(s: &mut Session, p: &Value) -> Result<Value> {
let width = item.get("width").and_then(Value::as_f64).unwrap_or(0.5).clamp(0.01, 10.0);
pastes.push((plane, surf, rect, at, width));
}
let clones: Vec<Value> = p.get("clone").and_then(Value::as_array).cloned().unwrap_or_default();
let clones = clone_specs(p)?;
let new_layer = p.get("newLayer").and_then(Value::as_bool).unwrap_or(false);
// The layer the mode was opened on (the UI passes it: other layers can be picked meanwhile).
let layer = crate::commands::explicit_layer(s, p)?;
let mut dabs = 0usize;
let target = s.edit("Vanishing Point", |doc: &mut Document, active| {
if let Some(l) = layer
&& doc.layer(l).is_none()
{
return Err(EngineError::NoLayer(l));
}
let on = layer.or(*active);
let id = if new_layer {
let l = Layer::raster(doc.next_layer_name("Vanishing Point"), doc.pixel_format());
let nid = doc.insert_above(*active, l);
let nid = doc.insert_above(on, l);
*active = Some(nid);
nid
} else {
active.ok_or_else(|| EngineError::Other("no active layer".into()))?
// The painted layer becomes active, so Fade and a colour-channel target follow it.
let id = on.ok_or_else(|| EngineError::Other("no active layer".into()))?;
*active = Some(id);
id
};
// A new layer clones from the visible image; otherwise from the layer itself.
let base: Option<Surface> = new_layer.then(|| crate::file_cmds::flattened(doc, doc.pixel_format()));
let base: Option<Surface> = new_layer.then(|| visible_image(doc));
let locks = doc.effective_locks(id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if locks.all || locks.pixels {
@@ -155,40 +270,16 @@ fn vanishing_point(s: &mut Session, p: &Value) -> Result<Value> {
}
let surf = l.surface_mut().ok_or_else(|| EngineError::Other("Vanishing Point needs a pixel layer".into()))?;
for (plane, src, rect, at, width) in &pastes {
if let Some(out) = paste(&sc, *plane, src, *rect, *at, *width)? {
over(surf, &out);
}
paste_onto(&sc, surf, *plane, src, *rect, *at, *width)?;
}
for (k, c) in clones.iter().enumerate() {
let source = c.get("source").and_then(pt).ok_or_else(|| bad(format!("clone {k}: `source` [x,y]")))?;
let points: Vec<[f64; 2]> = c.get("points").and_then(Value::as_array).map(|a| a.iter().filter_map(pt).collect()).unwrap_or_default();
let size = c.get("size").and_then(Value::as_f64).unwrap_or(40.0).clamp(1.0, 2000.0);
let hardness = c.get("hardness").and_then(Value::as_f64).unwrap_or(50.0).clamp(0.0, 100.0) / 100.0;
let opacity = (c.get("opacity").and_then(Value::as_f64).unwrap_or(100.0).clamp(0.0, 100.0) / 100.0) as f32;
// Sample from the original pixels (merged with what's been pasted).
let mut work = match &base {
Some(b) => {
let mut w = b.clone();
over(&mut w, surf);
w
}
None => surf.clone(),
};
let before = work.clone();
dabs += clone_stroke(&sc, &mut work, source, &points, size, hardness, opacity);
// Copy back only what the stroke changed.
let b = work.content_bounds().union(&before.content_bounds());
if !b.is_empty() {
let (nw, ow) = (work.read_region(b), before.read_region(b));
let n = surf.format().channels();
let mut cur = surf.read_region(b);
for i in 0..cur.len() / n {
if nw[i * n..(i + 1) * n] != ow[i * n..(i + 1) * n] {
cur[i * n..(i + 1) * n].copy_from_slice(&nw[i * n..(i + 1) * n]);
}
}
surf.write_region(b, &cur);
}
// A new layer samples the visible image (merged with what's been pasted) and takes only
// the pixels the strokes change; otherwise the strokes paint the layer itself.
let mut work = base.map(|mut w| {
over(&mut w, surf);
w
});
for c in &clones {
dabs += clone_into(&sc, surf, work.as_mut(), c, canvas);
}
surf.prune();
Ok(id)
@@ -211,7 +302,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Vanishing Point…",
menu: &["Filter"],
shortcut: Some("Cmd+Alt+V"),
params: r##"{"planes":[{"corners":[[x,y]×4]} | {"from":plane,"edge":"top|right|bottom|left","angle":deg=90,"depth":1}],"focalLength":px=0,"paste":[{"plane":0,"layer":id? (else the clipboard),"at":[u,v]=[0.25,0.25],"width":0..1=0.5}],"clone":[{"source":[x,y],"points":[[x,y]],"size":px=40,"hardness":0..100=50,"opacity":0..100=100}],"newLayer":bool=false}"##,
params: r##"{"planes":[{"corners":[[x,y]×4]} | {"from":plane,"edge":"top|right|bottom|left","angle":deg=90,"depth":1}],"focalLength":px=0,"paste":[{"plane":0,"layer":id? (else the clipboard),"at":[u,v]=[0.25,0.25],"width":0..1=0.5}],"clone":[{"source":[x,y],"points":[[x,y]],"size":px=40,"hardness":0..100=50,"opacity":0..100=100}],"newLayer":bool=false,"layer":id|name? (else the active layer)}"##,
enabled: has_layer,
run: vanishing_point,
journal: true,
@@ -282,4 +373,123 @@ mod tests {
}
assert!(s.execute(VP, json!({"planes": [{"corners": square}]})).is_ok());
}
#[test]
fn rejects_hostile_clone_and_tear_off_params() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 200, "depth": 32})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let floor = json!({"corners": [[60, 60], [140, 60], [190, 190], [10, 190]]});
let before = s.active().unwrap().doc.clone();
let steps = s.active().unwrap().history.past_len();
for torn in [json!({"from": 0, "angle": 1e9}), json!({"from": 0, "depth": -1}), json!({"from": 0, "depth": 1e300})] {
let e = s.execute(VP, json!({"planes": [floor, torn]})).unwrap_err().to_string();
assert!(e.contains("plane 1"), "{e}");
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
assert_eq!(s.active().unwrap().history.past_len(), steps);
}
let many: Vec<[f64; 2]> = vec![[100.0, 150.0]; MAX_CLONE_DABS + 1];
for clone in [
json!([{"points": [[100, 150]]}]),
json!([{"source": [100, 180], "points": [[100, 150], "x"]}]),
json!([{"source": [100, 180], "points": [[100, 150]], "size": "big"}]),
json!([{"source": [100, 180], "points": many}]),
json!({"source": [100, 180]}),
] {
let label: String = clone.to_string().chars().take(80).collect();
assert!(s.execute(VP, json!({"planes": [floor], "clone": clone, "newLayer": true})).is_err(), "{label}");
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
assert_eq!(s.active().unwrap().history.past_len(), steps);
}
let planes: Vec<Value> = std::iter::repeat_n(floor.clone(), MAX_PLANES + 1).collect();
assert!(s.execute(VP, json!({"planes": planes})).is_err());
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
assert_eq!(s.active().unwrap().history.past_len(), steps);
// A clone on a torn-off wall, at 32 bits, sampling the floor.
let r = s
.execute(VP, json!({"planes": [floor, {"from": 0, "edge": "top", "angle": 90, "depth": 0.5}], "clone": [{"source": [100, 150], "points": [[100, 50], [104, 50]], "size": 8}]}))
.unwrap();
assert_eq!(r["planes"].as_array().unwrap().len(), 2);
assert!(r["dabs"].as_u64().unwrap() >= 1, "{r}");
}
#[test]
fn new_layer_clone_stays_on_the_canvas() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color": "#40c040"})).unwrap();
// A red stripe where the first dab samples, so cloning it changes pixels.
s.edit("stripe", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(140, 80, 160, 120), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
let planes = json!([{"corners": [[0, 0], [200, 0], [200, 200], [0, 200]]}]);
// The second dab lies left of the canvas and samples the canvas 50 px to its right; the
// third is far off it (its box joined with the first's once asked for petabytes).
for points in [json!([[100, 100], [-30, 100]]), json!([[100, 100], [-30, 100], [9_000_000, 9_000_000]])] {
let r = s.execute(VP, json!({"planes": planes, "newLayer": true, "clone": [{"source": [150, 100], "points": points, "size": 20}]})).unwrap();
let l = s.active().unwrap().doc.layer(LayerId(r["layer"].as_u64().unwrap())).unwrap().surface().unwrap();
let b = l.content_bounds();
assert!(!b.is_empty() && Rect::new(0, 0, 200, 200).contains_rect(&b), "{b:?}");
assert!(l.rgba(100, 100)[0] > 0.9, "{:?}", l.rgba(100, 100));
s.undo();
}
}
/// Without a new layer the strokes paint the layer itself, and only inside the canvas, as in
/// Photoshop: a dab off it painted pixels there (and allocated tiles for every dab, up to
/// 1e7 px away).
#[test]
fn layer_clone_stays_on_the_canvas() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color": "#40c040"})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
let planes = json!([{"corners": [[0, 0], [200, 0], [200, 200], [0, 200]]}]);
// The second dab lies left of the canvas and samples the canvas 50 px to its right; the
// third is far off it.
let points = json!([[100, 100], [-30, 100], [9_000_000, 9_000_000]]);
let r = s.execute(VP, json!({"planes": planes, "clone": [{"source": [150, 100], "points": points, "size": 20}]})).unwrap();
assert_eq!(r["dabs"].as_u64(), Some(1), "{r}");
let b = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds();
assert!(Rect::new(0, 0, 200, 200).contains_rect(&b), "{b:?}");
}
#[test]
fn layer_param_paints_that_layer_not_the_active_one() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
s.execute("layer.new.layer", json!({"name": "floor"})).unwrap();
s.execute("edit.fill", json!({"color": "#40c040"})).unwrap();
s.edit("stripe", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(140, 80, 160, 120), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
let floor = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "other"})).unwrap();
let other = s.active().unwrap().active_layer.unwrap();
let planes = json!([{"corners": [[0, 0], [200, 0], [200, 200], [0, 200]]}]);
let clone = json!([{"source": [150, 100], "points": [[100, 100]], "size": 20}]);
let surf = |s: &Session, id| s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().clone();
let r = s.execute(VP, json!({"planes": planes, "clone": clone, "layer": floor.0})).unwrap();
assert_eq!(r["layer"].as_u64(), Some(floor.0));
assert!(surf(&s, floor).rgba(100, 100)[0] > 0.9, "{:?}", surf(&s, floor).rgba(100, 100));
assert!(surf(&s, other).content_bounds().is_empty(), "the active layer stays untouched");
s.undo();
// A new layer goes right above the named layer.
let r = s.execute(VP, json!({"planes": planes, "clone": clone, "layer": floor.0, "newLayer": true})).unwrap();
let d = &s.active().unwrap().doc;
let ids: Vec<u64> = d.layers.iter().map(|l| l.id.0).collect();
let at = |id: u64| ids.iter().position(|&i| i == id).unwrap();
assert_eq!(at(r["layer"].as_u64().unwrap()), at(floor.0) + 1, "{ids:?}");
s.undo();
let before = s.active().unwrap().doc.clone();
assert!(s.execute(VP, json!({"planes": planes, "clone": clone, "layer": 999_999})).is_err());
assert!(s.execute(VP, json!({"planes": planes, "clone": clone, "layer": 999_999, "newLayer": true})).is_err());
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
}
}
@@ -157,3 +157,62 @@ fn bad_params_and_unfillable_crops_fail_cleanly() {
assert_eq!(s.active().unwrap().history.past_len(), past, "{p}");
}
}
/// `doc` with its left 16 columns red, then cropped to x = 16 keeping the cropped pixels: the red
/// lies hidden past the canvas's left edge.
fn hidden_red(background: &str) -> Session {
let mut s = doc("rgb", 8, background);
s.execute("tools.setColors", json!({"foreground": "#ff0000", "background": "#000000"})).unwrap();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 48})).unwrap();
s.execute("edit.fill", json!({"contents": "foreground"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute("image.crop", json!({"x": 16, "y": 0, "width": 48, "height": 48, "deleteCroppedPixels": false})).unwrap();
assert_eq!(size(&s), (48, 48));
s
}
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
/// A straight frame pulled back out over pixels a Delete Cropped Pixels off crop hid fills over
/// them from the canvas, as Photoshop does with Delete Cropped Pixels off or on (#8060).
#[test]
fn extending_crop_fills_over_the_hidden_pixels() {
for background in ["transparent", "white"] {
for delete in [false, true] {
let mut s = hidden_red(background);
let green = layer(&s).rgba(24, 24);
crop(&mut s, json!({"x": -24, "y": 0, "width": 72, "height": 48, "deleteCroppedPixels": delete}), true).unwrap();
assert_eq!(size(&s), (72, 48));
let surf = layer(&s);
for y in 0..48 {
for x in 0..24 {
let px = surf.rgba(x, y);
assert!(close(px, green, 0.06), "{background} delete {delete}: ({x}, {y}) is filled from the canvas, not the hidden red: {px:?}");
}
assert!(close(surf.rgba(40, y), green, 1e-6), "{background} delete {delete}: the canvas is unchanged");
}
}
}
}
/// A turned frame that reaches over the Background's hidden pixels shows them, not a fill
/// from the canvas (#8060).
#[test]
fn rotated_crop_keeps_the_hidden_pixels() {
let mut s = hidden_red("white");
// A diamond on the canvas's left edge: its left half lies over the hidden red, all of it inside
// the turned canvas (so nothing takes the background colour).
crop(&mut s, json!({"x": -10, "y": 14, "width": 20, "height": 20, "angle": 45, "deleteCroppedPixels": false}), true).unwrap();
assert_eq!(size(&s), (20, 20));
let (w, h) = size(&s);
let surf = layer(&s);
let mut red = 0;
for y in 0..h as i32 {
for x in 0..w as i32 {
let px = surf.rgba(x, y);
assert!(px[0] > 0.5 && px[3] > 0.99, "no background colour or transparency at ({x}, {y}): {px:?}");
red += usize::from(close(px, RED, 0.06));
}
}
assert!(red >= 100, "the hidden red shows in the turned frame: {red} red pixels");
}
+3
View File
@@ -14,6 +14,9 @@ photocraft-color = { workspace = true }
photocraft-raster = { workspace = true }
photocraft-doc = { workspace = true }
photocraft-compose = { workspace = true }
# Camera Raw's per-pixel stages (`camera_raw.rs`) take their constants from the CPU develop.
photocraft-algo = { workspace = true }
bytemuck = { workspace = true }
# Same major version as eframe's wgpu so the app's device can be shared.
wgpu = { version = "30", default-features = false, features = ["std", "wgsl"] }
log = { workspace = true }
+116
View File
@@ -0,0 +1,116 @@
//! Camera Raw's preview develop on the 858×572 proxy of a 6000×4000 image: the CPU develop
//! against the GPU pass (upload, render, readback), for per-pixel settings.
//! `cargo run --release -p photocraft-gpu --example bench_camera_raw [-- --quick]`
use std::time::Instant;
use photocraft_algo::camera_raw::{CameraRaw, Wheel, develop, per_pixel, white_black_points};
use photocraft_gpu::CameraRawDevelop;
fn median(mut v: Vec<f64>) -> f64 {
v.sort_by(f64::total_cmp);
v[v.len() / 2]
}
fn time(runs: usize, mut f: impl FnMut()) -> f64 {
f();
median(
(0..runs)
.map(|_| {
let t = Instant::now();
f();
t.elapsed().as_secs_f64() * 1e3
})
.collect(),
)
}
/// [`time`] for a call that can fail: its first error ends the benchmark instead of being timed.
fn try_time<E>(runs: usize, mut f: impl FnMut() -> Result<(), E>) -> Result<f64, E> {
let mut failed = None;
let ms = time(runs, || {
if failed.is_none() {
failed = f().err();
}
});
failed.map_or(Ok(ms), Err)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let runs = if std::env::args().any(|a| a == "--quick") { 5 } else { 40 };
let (w, h) = (858usize, 572usize);
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
let options = wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() };
let adapter = pollster::block_on(instance.request_adapter(&options))?;
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?;
let info = adapter.get_info();
println!("adapter: {} ({:?}), proxy {w}x{h}, median of {runs}", info.name, info.backend);
let built = Instant::now();
let mut pass = CameraRawDevelop::new(&device)?;
println!("pipeline build: {:.1} ms", built.elapsed().as_secs_f64() * 1e3);
let src: Vec<[f32; 4]> = (0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32 / w as f32, (i / w) as f32 / h as f32);
[0.1 + 0.8 * x, 0.2 + 0.6 * y, 0.6 - 0.5 * x * y, 1.0]
})
.collect();
let scale = w as f32 / 6000.0;
let mut hsl = [0.0; 8];
hsl[1] = 30.0;
hsl[5] = -40.0;
let sets = [
(
"Basic (WB, Exposure, Contrast, Whites, Blacks)",
CameraRaw { temperature: 20.0, exposure: 0.4, contrast: 25.0, whites: 30.0, blacks: -20.0, pixel_scale: scale, ..Default::default() },
),
(
"every per-pixel stage",
CameraRaw {
temperature: 20.0,
tint: 5.0,
exposure: 0.4,
contrast: 25.0,
whites: 30.0,
blacks: -20.0,
vibrance: 30.0,
saturation: 10.0,
curve_lights: 20.0,
point_curve: vec![[0.0, 8.0], [128.0, 140.0], [255.0, 250.0]],
hsl_hue: hsl,
hsl_sat: hsl,
hsl_lum: hsl,
grade_shadows: Wheel { hue: 210.0, sat: 30.0, lum: 0.0 },
grade_highlights: Wheel { hue: 40.0, sat: 20.0, lum: 5.0 },
vignette_amount: -30.0,
grain_amount: 25.0,
pixel_scale: scale,
..Default::default()
},
),
];
for (name, p) in &sets {
let cpu = time(runs, || {
let mut px = src.clone();
develop(&mut px, w, h, p, false);
});
let points_ms = time(runs, || {
std::hint::black_box(white_black_points(&src, w, h, p));
});
let points = white_black_points(&src, w, h, p);
let stages = per_pixel(p, w, h, false, points).ok_or("not per pixel")?;
let size = [w as u32, h as u32];
let upload = try_time(runs, || pass.develop(&device, &queue, None, &src, None, size, &stages).map(drop))?;
let cached = try_time(runs, || pass.develop(&device, &queue, None, &src, Some(1), size, &stages).map(drop))?;
println!("{name}:");
println!(" CPU develop {cpu:7.2} ms");
println!(" GPU, source uploaded every time {upload:7.2} ms");
println!(" GPU, source cached (slider drag) {cached:7.2} ms");
println!(" Whites/Blacks points on the CPU {points_ms:7.2} ms (cached per WB/Exposure/Contrast in the dialog)");
}
let shadows = CameraRaw { shadows: 50.0, pixel_scale: scale, ..Default::default() };
let cpu = time(runs.min(10), || {
let mut px = src.clone();
develop(&mut px, w, h, &shadows, false);
});
println!("for reference, Shadows +50 (CPU only): {cpu:.2} ms");
Ok(())
}
+418
View File
@@ -0,0 +1,418 @@
//! Camera Raw's per-pixel develop on the GPU, for the dialog's interactive preview (#6916).
//!
//! One fragment pass (`camera_raw.wgsl`) runs the stages that read only their own pixel: white
//! balance and exposure, Contrast without Texture or Clarity, Whites and Blacks, Vibrance and
//! Saturation, the tone curves, the Color Mixer, Color Grading, the vignette and grain. Their
//! constants and tables come from [`photocraft_algo::camera_raw::per_pixel`], which the CPU
//! develop builds its own from, so both evaluate the same maths; the curves and the Whites and
//! Blacks rows are rows of one `R32Float` texture. Settings with a spatial stage (Dehaze, Shadows
//! and Highlights, Texture, Clarity, Detail) have no [`PerPixel`] and stay on the CPU.
//!
//! The pipeline is built on demand ([`CameraRawDevelop::new`]), not with the compositor (#6419).
//! It renders into `Rgba32Float`; [`CameraRawDevelop::supported`] says whether the adapter can.
use photocraft_algo::camera_raw::{CURVE_LUT, PerPixel, VignetteStyle, WB_STEPS};
use crate::Unsupported;
/// The pass's WGSL source.
pub const CAMERA_RAW_SHADER: &str = include_str!("camera_raw.wgsl");
const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
/// `Params` in the shader: 19 rows of 16 bytes.
const PARAMS_SIZE: u64 = 19 * 16;
/// Rows of the table texture: parametric, master, red, green, blue curves; Whites, Blacks.
const LUT_ROWS: u32 = 7;
const LUT_WIDTH: u32 = CURVE_LUT as u32;
const BYTES_PER_PIXEL: u32 = 16;
struct Image {
key: Option<u64>,
size: [u32; 2],
texture: wgpu::Texture,
view: wgpu::TextureView,
}
impl Image {
fn new(device: &wgpu::Device, label: &str, size: [u32; 2], usage: wgpu::TextureUsages) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
Image { key: None, size, texture, view }
}
}
/// The Camera Raw preview pass and its textures (reused while the size stays).
pub struct CameraRawDevelop {
pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
params: wgpu::Buffer,
luts: wgpu::Texture,
luts_view: wgpu::TextureView,
source: Option<Image>,
target: Option<Image>,
#[cfg(not(target_arch = "wasm32"))]
readback: Option<wgpu::Buffer>,
}
impl CameraRawDevelop {
/// Whether `adapter` can render the pass's `Rgba32Float` output.
pub fn supported(adapter: &wgpu::Adapter) -> bool {
adapter.get_texture_format_features(FORMAT).allowed_usages.contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
}
/// Builds the pipeline. A shader or pipeline the driver can't build is an `Err`, so the
/// caller keeps the CPU develop (where errors only arrive asynchronously, on WebGPU, creation
/// is assumed to have worked).
pub fn new(device: &wgpu::Device) -> Result<Self, Unsupported> {
match crate::first_error(device, || Self::build(device)) {
(dev, None) => Ok(dev),
(_, Some(e)) => Err(Unsupported(format!("couldn't build the Camera Raw pipeline: {e}"))),
}
}
fn build(device: &wgpu::Device) -> Self {
let module = device
.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_camera_raw"), source: wgpu::ShaderSource::Wgsl(CAMERA_RAW_SHADER.into()) });
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("pc_camera_raw"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(PARAMS_SIZE),
},
count: None,
},
crate::tex_entry(1),
crate::tex_entry(2),
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("pc_camera_raw"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("pc_camera_raw"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() },
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
targets: &[Some(wgpu::ColorTargetState { format: FORMAT, blend: None, write_mask: wgpu::ColorWrites::ALL })],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
});
let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("pc_camera_raw_params"),
size: PARAMS_SIZE,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let luts = device.create_texture(&wgpu::TextureDescriptor {
label: Some("pc_camera_raw_luts"),
size: wgpu::Extent3d { width: LUT_WIDTH, height: LUT_ROWS, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let luts_view = luts.create_view(&Default::default());
CameraRawDevelop {
pipeline,
layout,
params,
luts,
luts_view,
source: None,
target: None,
#[cfg(not(target_arch = "wasm32"))]
readback: None,
}
}
/// Records the develop of `source` (`w × h` straight RGBA, display-encoded) with `stages`
/// into `encoder` and returns the `Rgba32Float` texture it renders. The source is uploaded
/// unless the last one had the same `key` and size (`None` always uploads).
#[allow(clippy::too_many_arguments)]
pub fn encode(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
source: &[[f32; 4]],
key: Option<u64>,
size: [u32; 2],
stages: &PerPixel,
) -> Result<&wgpu::Texture, Unsupported> {
check_size(device, source, size)?;
let [w, h] = size;
let usage = wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST;
let src = match self.source.take() {
Some(s) if s.size == size => s,
_ => Image::new(device, "pc_camera_raw_source", size, usage),
};
let src = self.source.insert(src);
if key.is_none() || src.key != key {
queue.write_texture(
wgpu::TexelCopyTextureInfo { texture: &src.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
bytemuck::cast_slice(source),
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * BYTES_PER_PIXEL), rows_per_image: Some(h) },
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
src.key = key;
}
queue.write_buffer(&self.params, 0, &params(size, stages));
queue.write_texture(
wgpu::TexelCopyTextureInfo { texture: &self.luts, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
bytemuck::cast_slice(&tables(stages)),
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(LUT_WIDTH * 4), rows_per_image: Some(LUT_ROWS) },
wgpu::Extent3d { width: LUT_WIDTH, height: LUT_ROWS, depth_or_array_layers: 1 },
);
let target = match self.target.take() {
Some(t) if t.size == size => t,
_ => Image::new(device, "pc_camera_raw_target", size, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC),
};
let target = self.target.insert(target);
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("pc_camera_raw"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: self.params.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&src.view) },
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self.luts_view) },
],
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("pc_camera_raw"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &target.view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store },
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
Ok(&target.texture)
}
/// Develops `source` and reads the result back (blocking; native only, the web has no
/// blocking wait). GPU errors and a lost device are `Err`; `health`, when the device is
/// watched, records a loss.
#[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::too_many_arguments)]
pub fn develop(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
health: Option<&crate::DeviceHealth>,
source: &[[f32; 4]],
key: Option<u64>,
size: [u32; 2],
stages: &PerPixel,
) -> Result<Vec<[f32; 4]>, Unsupported> {
if let Some(f) = health.and_then(crate::DeviceHealth::fault) {
return Err(Unsupported(f.to_string()));
}
let (result, error) = crate::first_error(device, || self.develop_inner(device, queue, health, source, key, size, stages));
match error {
Some(e) => {
// A half-done upload must not be taken for the cached source next time.
self.source = None;
Err(Unsupported(format!("GPU Camera Raw develop failed: {e}")))
}
None => result,
}
}
#[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::too_many_arguments)]
fn develop_inner(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
health: Option<&crate::DeviceHealth>,
source: &[[f32; 4]],
key: Option<u64>,
size: [u32; 2],
stages: &PerPixel,
) -> Result<Vec<[f32; 4]>, Unsupported> {
check_size(device, source, size)?;
let [w, h] = size;
let row = (w * BYTES_PER_PIXEL).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let bytes = u64::from(row) * u64::from(h);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_camera_raw") });
self.encode(device, queue, &mut encoder, source, key, size, stages)?;
let Some(target) = self.target.as_ref().map(|t| &t.texture) else { return Err(Unsupported("no Camera Raw target".into())) };
let buffer = match self.readback.take() {
Some(b) if b.size() == bytes => b,
_ => device.create_buffer(&wgpu::BufferDescriptor {
label: Some("pc_camera_raw_readback"),
size: bytes,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
}),
};
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo { texture: target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
wgpu::TexelCopyBufferInfo { buffer: &buffer, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: Some(h) } },
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
let index = queue.submit([encoder.finish()]);
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
match health {
Some(hl) => {
if !hl.wait(device, Some(index)) {
return Err(Unsupported(hl.fault().map_or_else(|| "GPU device lost".into(), |f| f.to_string())));
}
}
None => {
device.poll(wgpu::PollType::Wait { submission_index: Some(index), timeout: None }).map_err(|e| Unsupported(format!("GPU wait failed: {e}")))?;
}
}
let mut out = Vec::with_capacity(source.len());
{
let data = buffer.slice(..).get_mapped_range().map_err(|e| Unsupported(format!("GPU readback failed: {e:?}")))?;
let width = w as usize * BYTES_PER_PIXEL as usize;
for y in 0..h as usize {
let start = y * row as usize;
let line = data.get(start..start + width).ok_or_else(|| Unsupported("short GPU readback".into()))?;
out.extend(bytemuck::pod_collect_to_vec::<u8, [f32; 4]>(line));
}
}
buffer.unmap();
self.readback = Some(buffer);
Ok(out)
}
}
fn check_size(device: &wgpu::Device, source: &[[f32; 4]], [w, h]: [u32; 2]) -> Result<(), Unsupported> {
let max = device.limits().max_texture_dimension_2d;
if w == 0 || h == 0 || w > max || h > max {
return Err(Unsupported(format!("a {w}×{h} Camera Raw preview doesn't fit the GPU's {max} px textures")));
}
if source.len() as u64 != u64::from(w) * u64::from(h) {
return Err(Unsupported(format!("{} Camera Raw preview pixels for {w}×{h}", source.len())));
}
Ok(())
}
/// Flags of `Params.flags`, in the shader's bit order.
fn flags(s: &PerPixel) -> u32 {
let wb = s.whites_blacks.as_ref();
let on = [
s.gains.is_some(),
s.contrast.is_some(),
wb.is_some_and(|w| w.whites.is_some()),
wb.is_some_and(|w| w.blacks.is_some()),
wb.is_some_and(|w| w.per_channel),
s.presence.is_some(),
s.parametric.is_some(),
s.point[0].is_some(),
s.point[1].is_some(),
s.point[2].is_some(),
s.point[3].is_some(),
s.hsl.is_some(),
s.grade.is_some(),
s.vignette.is_some(),
s.grain.is_some(),
s.clamp,
];
on.iter().enumerate().fold(0, |f, (bit, on)| if *on { f | 1 << bit } else { f })
}
/// The shader's `Params` for a `size` image.
fn params(size: [u32; 2], s: &PerPixel) -> Vec<u8> {
let g = s.gains.unwrap_or([1.0; 3]);
let presence = s.presence.unwrap_or_default();
let hsl = s.hsl.unwrap_or_default();
let grade = s.grade.as_ref();
let vig = s.vignette.as_ref();
let grain = s.grain.as_ref();
let mut out: Vec<u8> = Vec::with_capacity(PARAMS_SIZE as usize);
out.extend([size[0], size[1], flags(s)].iter().flat_map(|v| v.to_le_bytes()).chain(s.encoding.to_le_bytes()));
let mut f = |v: [f32; 4]| out.extend(v.iter().flat_map(|x| x.to_le_bytes()));
f([g[0], g[1], g[2], s.contrast.unwrap_or(0.0)]);
f([presence[0], presence[1], 0.0, 0.0]);
for row in hsl {
f([row[0], row[1], row[2], row[3]]);
f([row[4], row[5], row[6], row[7]]);
}
for k in 0..4 {
let (t, l) = grade.map_or(([0.0; 3], 0.0), |gr| (gr.tints[k], gr.lums[k]));
f([t[0], t[1], t[2], l]);
}
f(grade.map_or([0.0; 4], |gr| [gr.blend, gr.balance, 0.0, 0.0]));
let style = |v: VignetteStyle| match v {
VignetteStyle::HighlightPriority => 0.0,
VignetteStyle::ColorPriority => 1.0,
VignetteStyle::PaintOverlay => 2.0,
};
f(vig.map_or([0.0, 0.0, 0.0, 2.0], |v| [v.amount, v.highlights, style(v.style), v.power]));
f(vig.map_or([0.0, 0.0, 1.0, 1.0], |v| [v.centre[0], v.centre[1], v.axes[0], v.axes[1]]));
f(vig.map_or([0.0; 4], |v| [v.edges[0], v.edges[1], 0.0, 0.0]));
f(grain.map_or([1.0, 0.0, 0.0, 1.0], |gr| [gr.cell, gr.roughness, gr.amount, gr.scale]));
let seed = grain.map_or(0, |gr| gr.seed);
let fine = seed.wrapping_add(7);
out.extend([seed as u32, (seed >> 32) as u32, fine as u32, (fine >> 32) as u32].iter().flat_map(|v| v.to_le_bytes()));
out
}
/// The table texture's rows (see [`LUT_ROWS`]); rows of stages that are off stay zero.
fn tables(s: &PerPixel) -> Vec<f32> {
let mut t = vec![0.0f32; (LUT_WIDTH * LUT_ROWS) as usize];
let mut put = |row: usize, v: &[f32]| {
let start = row * LUT_WIDTH as usize;
let n = v.len().min(LUT_WIDTH as usize);
if let (Some(dst), Some(src)) = (t.get_mut(start..start + n), v.get(..n)) {
dst.copy_from_slice(src);
}
};
if let Some(l) = &s.parametric {
put(0, l);
}
for (k, l) in s.point.iter().enumerate() {
if let Some(l) = l {
put(1 + k, l);
}
}
if let Some(wb) = &s.whites_blacks {
if let Some(r) = &wb.whites {
put(5, r);
}
if let Some(r) = &wb.blacks {
put(6, r);
}
}
t
}
// The Whites and Blacks rows fit a table row.
const _: () = assert!(WB_STEPS <= CURVE_LUT);
+416
View File
@@ -0,0 +1,416 @@
// Camera Raw's per-pixel develop stages (`photocraft_algo::camera_raw::develop`, constants from
// `camera_raw::per_pixel`), one fragment per pixel. Each function mirrors the CPU code it is
// named after, in the same order of operations; `camera_raw.rs` holds the bindings.
struct Params {
size: vec2<u32>,
flags: u32,
// The document's transfer curve: 0 for sRGB's, else a power-law gamma.
encoding: f32,
// White balance gains (rgb), Contrast's strength (w).
gains: vec4<f32>,
// Vibrance, Saturation.
presence: vec4<f32>,
// Color Mixer bands 0-3 and 4-7: hue, saturation, luminance.
hsl: array<vec4<f32>, 6>,
// Color Grading wheels: tint (rgb), luminance (w).
tints: array<vec4<f32>, 4>,
// Blending, Balance.
grade: vec4<f32>,
// [amount, highlights, style, power], [centre, axes], [edges].
vignette: array<vec4<f32>, 3>,
// Cell, roughness, amount, full-resolution pixels per pixel.
grain: vec4<f32>,
// The two octaves' seeds as (low, high) words.
seeds: vec4<u32>,
}
@group(0) @binding(0) var<uniform> P: Params;
@group(0) @binding(1) var src: texture_2d<f32>;
// R32Float rows of `LUT_WIDTH`: parametric, master, red, green, blue curves; Whites, Blacks rows.
@group(0) @binding(2) var luts: texture_2d<f32>;
const GAINS: u32 = 0u;
const CONTRAST: u32 = 1u;
const WHITES: u32 = 2u;
const BLACKS: u32 = 3u;
const PER_CHANNEL: u32 = 4u;
const PRESENCE: u32 = 5u;
const PARAMETRIC: u32 = 6u;
const MASTER: u32 = 7u;
const RED: u32 = 8u;
const HSL: u32 = 11u;
const GRADE: u32 = 12u;
const VIGNETTE: u32 = 13u;
const GRAIN: u32 = 14u;
const CLAMP: u32 = 15u;
const CURVE_LUT: i32 = 1024;
const WB_STEPS: i32 = 33;
const TAU: f32 = 6.2831855;
struct VOut {
@builtin(position) pos: vec4<f32>,
}
@vertex
fn vs(@builtin(vertex_index) vi: u32) -> VOut {
let uv = vec2(f32((vi << 1u) & 2u), f32(vi & 2u));
return VOut(vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0));
}
fn on(flag: u32) -> bool {
return (P.flags & (1u << flag)) != 0u;
}
fn luma(c: vec3<f32>) -> f32 {
return 0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z;
}
fn smoothstep_(e0: f32, e1: f32, x: f32) -> f32 {
let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0);
return t * t * (3.0 - 2.0 * t);
}
fn rem_euclid(x: f32, m: f32) -> f32 {
let r = x % m;
return select(r, r + m, r < 0.0);
}
fn srgb_to_linear(v: f32) -> f32 {
if v <= 0.04045 {
return v / 12.92;
}
return pow((v + 0.055) / 1.055, 2.4);
}
fn linear_to_srgb(v: f32) -> f32 {
if v <= 0.0031308 {
return v * 12.92;
}
return 1.055 * pow(v, 1.0 / 2.4) - 0.055;
}
fn decode(v: f32) -> f32 {
if P.encoding > 0.0 {
return pow(max(v, 0.0), P.encoding);
}
return srgb_to_linear(v);
}
fn encode(v: f32) -> f32 {
if P.encoding > 0.0 {
return pow(max(v, 0.0), 1.0 / P.encoding);
}
return linear_to_srgb(v);
}
fn lut_at(row: i32, i: i32) -> f32 {
return textureLoad(luts, vec2(i, row), 0).r;
}
// `lut_eval`: linear inside 0..1, extended linearly outside (float documents).
fn lut_eval(row: i32, v: f32) -> f32 {
if v < 0.0 {
return v + lut_at(row, 0);
}
if v > 1.0 {
return lut_at(row, CURVE_LUT - 1) + (v - 1.0);
}
let x = v * f32(CURVE_LUT - 1);
let i = min(i32(x), CURVE_LUT - 2);
let t = x - f32(i);
let a = lut_at(row, i);
return a + (lut_at(row, i + 1) - a) * t;
}
// `whites_blacks::curve` on one row.
fn wb_curve(row: i32, v: f32) -> f32 {
if v >= 1.0 {
return 1.0;
}
let p = max(v, 0.0) * f32(WB_STEPS - 1);
let j = min(i32(p), WB_STEPS - 2);
let s = p - f32(j);
let a = lut_at(row, j);
return a + (lut_at(row, j + 1) - a) * s;
}
fn grey(v: f32) -> f32 {
var g = v;
if on(WHITES) {
g = wb_curve(5, g);
}
if on(BLACKS) {
g = wb_curve(6, g);
}
return g;
}
// `whites_blacks::apply`.
fn whites_blacks(q: vec3<f32>) -> vec3<f32> {
let lin = vec3(decode(q.x), decode(q.y), decode(q.z));
var out = q;
if on(PER_CHANNEL) {
for (var c = 0; c < 3; c++) {
let over = max(lin[c] - 1.0, 0.0);
out[c] = encode(srgb_to_linear(grey(linear_to_srgb(min(lin[c], 1.0)))) + over);
}
return out;
}
let y = max(luma(lin), 0.0);
if y <= 1e-7 {
return q;
}
let y2 = srgb_to_linear(grey(linear_to_srgb(min(y, 1.0)))) + max(y - 1.0, 0.0);
let k = y2 / y;
for (var c = 0; c < 3; c++) {
out[c] = encode(lin[c] * k);
}
return out;
}
fn rgb_to_hsv(c: vec3<f32>) -> vec3<f32> {
let mx = max(max(c.x, c.y), c.z);
let mn = min(min(c.x, c.y), c.z);
let d = mx - mn;
var h = 0.0;
if d <= 1e-9 {
h = 0.0;
} else if mx == c.x {
h = 60.0 * rem_euclid((c.y - c.z) / d, 6.0);
} else if mx == c.y {
h = 60.0 * ((c.z - c.x) / d + 2.0);
} else {
h = 60.0 * ((c.x - c.y) / d + 4.0);
}
return vec3(h, select(0.0, d / mx, mx > 1e-9), mx);
}
fn hsv_to_rgb(hue: f32, s: f32, v: f32) -> vec3<f32> {
let h = rem_euclid(hue, 360.0) / 60.0;
let c = v * s;
let x = c * (1.0 - abs((h % 2.0) - 1.0));
var rgb: vec3<f32>;
switch u32(h) {
case 0u: { rgb = vec3(c, x, 0.0); }
case 1u: { rgb = vec3(x, c, 0.0); }
case 2u: { rgb = vec3(0.0, c, x); }
case 3u: { rgb = vec3(0.0, x, c); }
case 4u: { rgb = vec3(x, 0.0, c); }
default: { rgb = vec3(c, 0.0, x); }
}
let m = v - c;
return rgb + vec3(m);
}
const HSL_BANDS = array<f32, 8>(0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 270.0, 300.0);
fn band(k: i32, row: i32) -> f32 {
return P.hsl[row * 2 + k / 4][k % 4];
}
// `band_weights` folded into the three sums.
fn hsl_shift(h: f32) -> vec3<f32> {
var bands = HSL_BANDS;
var d = vec3(0.0);
for (var k = 0; k < 8; k++) {
let c = bands[k];
let prev = bands[(k + 7) % 8];
let next = bands[(k + 1) % 8];
let diff = ((h - c + 540.0) % 360.0) - 180.0;
let span = select(rem_euclid(c - prev, 360.0), rem_euclid(next - c, 360.0), diff >= 0.0);
let w = max(1.0 - abs(diff) / max(span, 1e-3), 0.0);
d += w * vec3(band(k, 0), band(k, 1), band(k, 2));
}
return d;
}
// `photo_util::hash01`: 64-bit words as (low, high).
fn mul_wide(a: u32, b: u32) -> vec2<u32> {
let a0 = a & 0xffffu;
let a1 = a >> 16u;
let b0 = b & 0xffffu;
let b1 = b >> 16u;
let p00 = a0 * b0;
let p01 = a0 * b1;
let p10 = a1 * b0;
let p11 = a1 * b1;
let mid = (p00 >> 16u) + (p01 & 0xffffu) + (p10 & 0xffffu);
return vec2((p00 & 0xffffu) | (mid << 16u), p11 + (p01 >> 16u) + (p10 >> 16u) + (mid >> 16u));
}
fn mul64(a: vec2<u32>, b: vec2<u32>) -> vec2<u32> {
let w = mul_wide(a.x, b.x);
return vec2(w.x, w.y + a.x * b.y + a.y * b.x);
}
fn shr64(z: vec2<u32>, n: u32) -> vec2<u32> {
return vec2((z.x >> n) | (z.y << (32u - n)), z.y >> n);
}
fn i64_(v: i32) -> vec2<u32> {
return vec2(bitcast<u32>(v), select(0u, 0xffffffffu, v < 0));
}
fn hash01(x: i32, y: i32, seed: vec2<u32>) -> f32 {
var z = mul64(i64_(x), vec2(0x7f4a7c15u, 0x9e3779b9u)) ^ mul64(i64_(y), vec2(0x27d4eb4fu, 0xc2b2ae3du)) ^ mul64(seed, vec2(0x9e3779f9u, 0x165667b1u));
z = mul64(z ^ shr64(z, 30u), vec2(0x1ce4e5b9u, 0xbf58476du));
z = mul64(z ^ shr64(z, 27u), vec2(0x133111ebu, 0x94d049bbu));
z = z ^ shr64(z, 31u);
return f32(z.y >> 8u) / 16777216.0;
}
fn value_noise(x: f32, y: f32, seed: vec2<u32>) -> f32 {
let xi = floor(x);
let yi = floor(y);
let tx = x - xi;
let ty = y - yi;
let sx = tx * tx * (3.0 - 2.0 * tx);
let sy = ty * ty * (3.0 - 2.0 * ty);
let ix = i32(xi);
let iy = i32(yi);
let h00 = hash01(ix, iy, seed);
let h10 = hash01(ix + 1, iy, seed);
let h01 = hash01(ix, iy + 1, seed);
let h11 = hash01(ix + 1, iy + 1, seed);
let top = h00 + (h10 - h00) * sx;
let bot = h01 + (h11 - h01) * sx;
return top + (bot - top) * sy;
}
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let p = vec2<i32>(floor(pos.xy));
let q = textureLoad(src, p, 0);
var c = q.rgb;
// 1. White balance and exposure.
if on(GAINS) {
for (var i = 0; i < 3; i++) {
c[i] = linear_to_srgb(max(srgb_to_linear(c[i]) * P.gains[i], 0.0));
}
}
// 3. Contrast (without Texture and Clarity).
if on(CONTRAST) {
let l0 = max(luma(c), 0.0);
let lc = min(l0, 1.0);
var lv = lc;
let xx = clamp(lv, 0.0, 1.0);
lv += -P.gains.w * sin(TAU * xx) / TAU;
lv = max(lv, 0.0) + (l0 - lc);
if l0 > 1e-5 {
c = c * (lv / l0);
} else {
c = c + vec3(lv - l0);
}
}
// 3b. Whites and Blacks.
if on(WHITES) || on(BLACKS) {
c = whites_blacks(c);
}
// 4. Vibrance and Saturation.
if on(PRESENCE) {
let l = luma(c);
let mx = max(max(c.x, c.y), c.z);
let mn = min(min(c.x, c.y), c.z);
let sat = select(0.0, (mx - mn) / mx, mx > 1e-6);
let hue = rgb_to_hsv(c).x;
let skin = clamp(1.0 - (abs(hue - 25.0) / 25.0), 0.0, 1.0) * 0.5;
let k = 1.0 + P.presence.y + P.presence.x * (1.0 - sat) * (1.0 - skin);
c = vec3(l) + (c - vec3(l)) * max(k, 0.0);
}
// 5. Tone curves.
if on(PARAMETRIC) {
c = vec3(lut_eval(0, c.x), lut_eval(0, c.y), lut_eval(0, c.z));
}
if on(MASTER) {
c = vec3(lut_eval(1, c.x), lut_eval(1, c.y), lut_eval(1, c.z));
}
for (var i = 0; i < 3; i++) {
if on(RED + u32(i)) {
c[i] = lut_eval(2 + i, c[i]);
}
}
// 6. Color Mixer.
if on(HSL) {
let hsv = rgb_to_hsv(max(c, vec3(0.0)));
let s = hsv.y;
if s > 1e-4 {
let d = hsl_shift(hsv.x);
let nh = hsv.x + d.x / 100.0 * 30.0;
let ns = clamp(s * (1.0 + d.y / 100.0), 0.0, 1.0);
var out = hsv_to_rgb(nh, ns, hsv.z);
let l0 = luma(c);
let l1 = luma(out);
if l1 > 1e-6 {
out = out * l0 / l1;
}
let k = exp2(d.z / 100.0 * 1.0 * s);
c = out * k;
}
}
// 7. Color Grading.
if on(GRADE) {
let l = clamp(luma(c), 0.0, 1.0);
let blend = P.grade.x;
let bal = P.grade.y;
let sh = 1.0 - smoothstep_(0.0, 0.5 + bal + blend, l);
let hi = smoothstep_(0.5 + bal - blend, 1.0, l);
let mid = max(1.0 - sh - hi, 0.0);
let masks = vec4(sh, mid, hi, 1.0);
for (var k = 0; k < 4; k++) {
c += masks[k] * P.tints[k].xyz;
if P.tints[k].w != 0.0 {
c = c * exp2(masks[k] * P.tints[k].w / 100.0 * 0.6);
}
}
}
// 9. Vignette.
if on(VIGNETTE) {
let amt = P.vignette[0].x;
let hl = P.vignette[0].y;
let style = u32(P.vignette[0].z);
let pw = P.vignette[0].w;
let centre = P.vignette[1].xy;
let axes = P.vignette[1].zw;
let dx = abs((f32(p.x) + 0.5 - centre.x) / axes.x);
let dy = abs((f32(p.y) + 0.5 - centre.y) / axes.y);
let r = pow(pow(dx, pw) + pow(dy, pw), 1.0 / pw);
let m = smoothstep_(P.vignette[2].x, P.vignette[2].y, r);
if m > 0.0 {
let l = clamp(luma(c), 0.0, 1.0);
if style == 2u {
let goal = select(1.0, 0.0, amt < 0.0);
let k = m * abs(amt);
c += (vec3(goal) - c) * k;
} else {
var k = exp2(amt * 2.0 * m);
if amt < 0.0 && hl > 0.0 {
let protect = smoothstep_(0.6, 1.0, l) * hl;
k += (1.0 - k) * protect;
}
if style == 1u {
let nl = l * k;
c += vec3(nl - l);
} else {
c = c * k;
}
}
}
}
// 9. Grain, in full-resolution coordinates.
if on(GRAIN) {
let cell = P.grain.x;
let rough = P.grain.y;
let fx = f32(p.x) * P.grain.w / cell;
let fy = f32(p.y) * P.grain.w / cell;
let n = value_noise(fx, fy, P.seeds.xy) * (1.0 - rough) + (value_noise(fx * 2.3, fy * 2.3, P.seeds.zw) - 0.0) * rough;
let l = clamp(luma(c), 0.0, 1.0);
let g = (n - 0.5) * 2.0 * P.grain.z * (0.4 + 2.4 * l * (1.0 - l));
c += vec3(g);
}
if on(CLAMP) {
c = clamp(c, vec3(0.0), vec3(1.0));
}
return vec4(c, q.a);
}
+5
View File
@@ -34,10 +34,14 @@
//! (Multichannel documents, patterns larger than the texture limit, uncached gradients with
//! coincident colour or opacity stops) returns [`Unsupported`];
//! callers fall back to the CPU compositor.
//!
//! [`CameraRawDevelop`] (`camera_raw.rs`) is a separate pass, not part of the compositor: Camera
//! Raw's per-pixel develop stages for the dialog's preview, built the first time the dialog opens.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod bounds;
mod camera_raw;
mod fx;
pub mod health;
pub mod plan;
@@ -54,6 +58,7 @@ use photocraft_doc::{DocId, Document, LayerContent, LayerId, Pattern};
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::{Surface, Tile};
pub use camera_raw::{CAMERA_RAW_SHADER, CameraRawDevelop};
pub use health::{DeviceHealth, Fault};
pub use plan::{Kernel, Plan, Role, Unsupported, plan};
pub use timing::{GpuTimer, GpuTiming};
+370
View File
@@ -0,0 +1,370 @@
//! Camera Raw's GPU preview pass against the CPU develop (the oracle), stage by stage, at 8, 16
//! and 32 bits. Skips, with a message, when there is no adapter or device or it can't render
//! `Rgba32Float`; with `WGPU_BACKEND` set those fail instead, as in `parity.rs`. A pipeline that
//! doesn't build on an adapter that can run it fails. The shader itself is validated without a
//! GPU ([`the_shader_validates_without_a_gpu`]), so a WGSL error fails on every runner.
use photocraft_algo::camera_raw::{CameraRaw, Wheel, develop, per_pixel, white_black_points};
use photocraft_gpu::{CameraRawDevelop, DeviceHealth, Fault};
/// Largest difference from the CPU develop, per channel. The pass evaluates the same
/// expressions in f32, so what remains is the GPU's own `pow`, `exp2` and `sin` (a few ulps) and
/// how they move a value across a table entry; measured at most about 1e-5 on NVIDIA (Vulkan,
/// DX12) and WARP. 1/4096 keeps a margin and stays well under one 8-bit step (the compositor's
/// tolerance is 1/255).
const TOL: f32 = 1.0 / 4096.0;
struct Gpu {
device: wgpu::Device,
queue: wgpu::Queue,
dev: CameraRawDevelop,
_lock: std::sync::MutexGuard<'static, ()>,
}
fn gpu() -> Option<Gpu> {
let lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let required = std::env::var_os("WGPU_BACKEND").is_some();
// `WGPU_BACKEND` picks the backend; `PHOTOCRAFT_TEST_FALLBACK_ADAPTER=1` the software one (WARP).
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
let fallback = std::env::var_os("PHOTOCRAFT_TEST_FALLBACK_ADAPTER").is_some();
let options = wgpu::RequestAdapterOptions { force_fallback_adapter: fallback, ..Default::default() };
let adapter = match pollster::block_on(instance.request_adapter(&options)) {
Ok(a) => a,
Err(e) => {
assert!(!required, "WGPU_BACKEND is set but has no adapter: {e}");
eprintln!("skipping Camera Raw GPU tests: no adapter ({e})");
return None;
}
};
let info = adapter.get_info();
if !CameraRawDevelop::supported(&adapter) {
assert!(!required, "WGPU_BACKEND is set but {} can't render Rgba32Float", info.name);
eprintln!("skipping Camera Raw GPU tests: adapter can't render Rgba32Float");
return None;
}
let (device, queue) = match pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) {
Ok(d) => d,
Err(e) => {
assert!(!required, "WGPU_BACKEND is set but {} has no device: {e}", info.name);
eprintln!("skipping Camera Raw GPU tests: no device ({e})");
return None;
}
};
eprintln!("Camera Raw GPU tests on {:?} ({})", info.backend, info.name);
// The adapter can run the pass: a pipeline that doesn't build is a bug in it, not a skip.
let dev = CameraRawDevelop::new(&device).unwrap_or_else(|e| panic!("the Camera Raw pipeline didn't build on {}: {}", info.name, e.0));
Some(Gpu { device, queue, dev, _lock: lock })
}
/// The Camera Raw shader parses and validates (naga, as wgpu does before any driver sees it),
/// with no adapter: the GPU tests below skip on runners without one.
#[test]
fn the_shader_validates_without_a_gpu() {
let src = photocraft_gpu::CAMERA_RAW_SHADER;
let module = wgpu::naga::front::wgsl::parse_str(src).unwrap_or_else(|e| panic!("{}", e.emit_to_string(src)));
wgpu::naga::valid::Validator::new(wgpu::naga::valid::ValidationFlags::all(), wgpu::naga::valid::Capabilities::empty())
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(src)));
assert!(!module.entry_points.is_empty());
}
#[derive(Clone, Copy, Debug)]
enum Depth {
Eight,
Sixteen,
Float,
}
const DEPTHS: [Depth; 3] = [Depth::Eight, Depth::Sixteen, Depth::Float];
fn hash(i: usize, c: u64) -> f32 {
let mut z = (i as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ c.wrapping_mul(0xC2B2_AE3D_27D4_EB4F);
z = (z ^ (z >> 29)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
(z >> 40) as f32 / (1u64 << 24) as f32
}
/// Gradients, noise and saturated patches (every Color Mixer band), quantized to `depth`. The
/// 32-bit image also has values above 1 and a few below 0.
fn image(w: usize, h: usize, depth: Depth) -> Vec<[f32; 4]> {
(0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32 / w as f32, (i / w) as f32 / h as f32);
let mut c = if (i / w).is_multiple_of(4) {
// A hue sweep at full and half saturation.
let hue = x * 6.0;
let f = |n: f32| (((hue + n) % 6.0 - 3.0).abs() - 1.0).clamp(0.0, 1.0);
let s = if (i / w).is_multiple_of(8) { 1.0 } else { 0.5 };
[f(0.0), f(4.0), f(2.0)].map(|v| (1.0 - s) * 0.6 + s * v)
} else {
[0.1 + 0.8 * x, 0.2 + 0.6 * y, 0.5 - 0.4 * x * y].map(|v| v + (hash(i, 1) - 0.5) * 0.15)
};
if let Depth::Float = depth {
c = c.map(|v| v * (1.0 + 1.5 * y) - 0.02 * hash(i, 2));
}
let q = |v: f32| match depth {
Depth::Eight => (v.clamp(0.0, 1.0) * 255.0).round() / 255.0,
Depth::Sixteen => (v.clamp(0.0, 1.0) * 32768.0).round() / 32768.0,
Depth::Float => v,
};
[q(c[0]), q(c[1]), q(c[2]), 1.0]
})
.collect()
}
/// Develops `p` both ways and returns the largest channel difference.
fn compare(g: &mut Gpu, p: &CameraRaw, depth: Depth) -> f32 {
let (w, h) = (171, 113);
let float = matches!(depth, Depth::Float);
let src = image(w, h, depth);
let mut cpu = src.clone();
develop(&mut cpu, w, h, p, float);
let points = white_black_points(&src, w, h, p);
let stages = per_pixel(p, w, h, float, points).unwrap_or_else(|| panic!("{p:?} has a spatial stage"));
let out = g.dev.develop(&g.device, &g.queue, None, &src, None, [w as u32, h as u32], &stages).unwrap();
assert_eq!(out.len(), cpu.len());
let mut worst = (0.0f32, 0usize);
for (i, (a, b)) in out.iter().zip(&cpu).enumerate() {
assert!(a.iter().all(|v| v.is_finite()), "{p:?} {depth:?}: non-finite GPU pixel {i}: {a:?}");
assert_eq!(a[3], b[3], "alpha is kept");
for c in 0..3 {
let d = (a[c] - b[c]).abs();
if d > worst.0 {
worst = (d, i);
}
}
}
let (d, i) = worst;
assert!(d <= TOL, "{p:?} {depth:?}: GPU {:?} vs CPU {:?} at pixel {i} (source {:?}), off by {d}", out[i], cpu[i], src[i]);
if !float {
assert!(out.iter().all(|q| q[..3].iter().all(|v| (0.0..=1.0).contains(v))), "{p:?} {depth:?}: clipped like the CPU");
}
d
}
fn check(name: &str, sets: &[CameraRaw]) {
let Some(mut g) = gpu() else { return };
for p in sets {
for depth in DEPTHS {
let d = compare(&mut g, p, depth);
eprintln!("{name} {depth:?}: max |GPU - CPU| = {d:.2e}");
}
}
}
#[test]
fn white_balance_and_exposure() {
check(
"white balance",
&[
CameraRaw { temperature: 40.0, tint: -25.0, ..Default::default() },
CameraRaw { temperature: -70.0, tint: 60.0, exposure: -1.2, ..Default::default() },
CameraRaw { exposure: 1.5, ..Default::default() },
],
);
}
#[test]
fn contrast_without_texture_or_clarity() {
check("contrast", &[CameraRaw { contrast: 60.0, ..Default::default() }, CameraRaw { contrast: -80.0, exposure: 0.4, ..Default::default() }]);
}
#[test]
fn whites_and_blacks() {
check(
"whites/blacks",
&[
CameraRaw { whites: 70.0, blacks: -45.0, ..Default::default() },
CameraRaw { whites: -60.0, blacks: 50.0, temperature: 20.0, ..Default::default() },
CameraRaw { whites: 30.0, ..Default::default() },
CameraRaw { blacks: -100.0, exposure: -0.5, ..Default::default() },
// ProPhoto's power-law encoding.
CameraRaw { whites: 50.0, blacks: -30.0, encoding_gamma: 1.8, ..Default::default() },
],
);
}
#[test]
fn tone_curves() {
check(
"tone curves",
&[
CameraRaw {
curve_highlights: -60.0,
curve_lights: 30.0,
curve_darks: 40.0,
curve_shadows: -20.0,
curve_splits: [20.0, 45.0, 80.0],
..Default::default()
},
CameraRaw { point_curve: vec![[0.0, 20.0], [64.0, 50.0], [192.0, 220.0], [255.0, 245.0]], ..Default::default() },
CameraRaw {
point_curve_red: vec![[0.0, 0.0], [128.0, 160.0], [255.0, 255.0]],
point_curve_green: vec![[0.0, 10.0], [255.0, 230.0]],
point_curve_blue: vec![[0.0, 0.0], [100.0, 70.0], [255.0, 255.0]],
..Default::default()
},
],
);
}
#[test]
fn vibrance_and_saturation() {
check(
"presence",
&[
CameraRaw { vibrance: 70.0, ..Default::default() },
CameraRaw { saturation: -60.0, ..Default::default() },
CameraRaw { vibrance: -40.0, saturation: 50.0, ..Default::default() },
],
);
}
#[test]
fn color_mixer() {
let ramp = |k: f32| std::array::from_fn(|i| ((i as f32 * 37.0 + k) % 200.0) - 100.0);
check(
"color mixer",
&[
CameraRaw { hsl_hue: ramp(0.0), ..Default::default() },
CameraRaw { hsl_sat: ramp(50.0), ..Default::default() },
CameraRaw { hsl_lum: ramp(120.0), ..Default::default() },
CameraRaw { hsl_hue: ramp(10.0), hsl_sat: ramp(80.0), hsl_lum: ramp(150.0), ..Default::default() },
],
);
}
#[test]
fn color_grading() {
check(
"color grading",
&[
CameraRaw { grade_shadows: Wheel { hue: 220.0, sat: 70.0, lum: 20.0 }, ..Default::default() },
CameraRaw {
grade_midtones: Wheel { hue: 120.0, sat: 30.0, lum: -15.0 },
grade_highlights: Wheel { hue: 40.0, sat: 50.0, lum: 10.0 },
grade_global: Wheel { hue: 300.0, sat: 15.0, lum: 5.0 },
grade_blending: 80.0,
grade_balance: -40.0,
..Default::default()
},
],
);
}
#[test]
fn vignette() {
check(
"vignette",
&[
CameraRaw { vignette_amount: -70.0, ..Default::default() },
CameraRaw { vignette_amount: -50.0, vignette_roundness: -60.0, vignette_feather: 20.0, vignette_highlights: 70.0, ..Default::default() },
CameraRaw {
vignette_amount: 60.0,
vignette_roundness: 80.0,
vignette_midpoint: 20.0,
vignette_style: "colorPriority".into(),
..Default::default()
},
CameraRaw { vignette_amount: -40.0, vignette_style: "paintOverlay".into(), ..Default::default() },
CameraRaw { vignette_amount: 40.0, vignette_style: "paintOverlay".into(), ..Default::default() },
],
);
}
#[test]
fn grain_in_full_resolution_coordinates() {
check(
"grain",
&[
CameraRaw { grain_amount: 60.0, seed: 3, ..Default::default() },
// A preview proxy: the noise lattice is sampled at full-resolution positions.
CameraRaw { grain_amount: 80.0, grain_size: 60.0, grain_roughness: 90.0, seed: u32::MAX, pixel_scale: 0.143, ..Default::default() },
CameraRaw { grain_amount: 25.0, grain_size: 0.0, grain_roughness: 0.0, seed: 77, pixel_scale: 0.5, ..Default::default() },
],
);
}
#[test]
fn every_per_pixel_stage_at_once() {
let mut hsl = [0.0; 8];
hsl[1] = 35.0;
hsl[5] = -50.0;
check(
"all stages",
&[CameraRaw {
temperature: 25.0,
tint: 10.0,
exposure: 0.3,
contrast: 25.0,
whites: 20.0,
blacks: -15.0,
vibrance: 30.0,
saturation: 10.0,
curve_darks: 20.0,
point_curve: vec![[0.0, 8.0], [255.0, 250.0]],
hsl_hue: hsl,
hsl_sat: hsl,
hsl_lum: hsl,
grade_shadows: Wheel { hue: 210.0, sat: 25.0, lum: 0.0 },
vignette_amount: -30.0,
grain_amount: 20.0,
pixel_scale: 0.2,
..Default::default()
}],
);
}
#[test]
fn identity_leaves_pixels_alone_and_spatial_settings_have_no_pass() {
let Some(mut g) = gpu() else { return };
let p = CameraRaw::default();
let src = image(64, 32, Depth::Float);
let stages = per_pixel(&p, 64, 32, false, None).unwrap();
let out = g.dev.develop(&g.device, &g.queue, None, &src, Some(1), [64, 32], &stages).unwrap();
assert_eq!(out, src, "the CPU develop returns early, unclipped");
for spatial in [
CameraRaw { dehaze: 10.0, ..Default::default() },
CameraRaw { shadows: 10.0, ..Default::default() },
CameraRaw { highlights: -10.0, ..Default::default() },
CameraRaw { texture: 10.0, ..Default::default() },
CameraRaw { clarity: 10.0, ..Default::default() },
CameraRaw { sharpen_amount: 10.0, ..Default::default() },
CameraRaw { noise_luminance: 10.0, ..Default::default() },
CameraRaw { noise_color: 10.0, ..Default::default() },
] {
assert!(spatial.spatial() && per_pixel(&spatial, 64, 32, false, Some((0.0, 1.0))).is_none(), "{spatial:?}");
}
// Whites and Blacks need the image's points.
assert!(per_pixel(&CameraRaw { whites: 10.0, ..Default::default() }, 64, 32, false, None).is_none());
}
#[test]
fn a_cached_source_is_reused_and_a_new_key_uploads() {
let Some(mut g) = gpu() else { return };
let p = CameraRaw { exposure: 0.5, ..Default::default() };
let stages = per_pixel(&p, 64, 32, false, None).unwrap();
let a = image(64, 32, Depth::Eight);
let b: Vec<[f32; 4]> = a.iter().map(|q| [q[2], q[0], q[1], 1.0]).collect();
let first = g.dev.develop(&g.device, &g.queue, None, &a, Some(7), [64, 32], &stages).unwrap();
// Same key: the uploaded source stays (callers key by the proxy's identity).
assert_eq!(g.dev.develop(&g.device, &g.queue, None, &b, Some(7), [64, 32], &stages).unwrap(), first);
let mut cpu = b.clone();
develop(&mut cpu, 64, 32, &p, false);
let other = g.dev.develop(&g.device, &g.queue, None, &b, Some(8), [64, 32], &stages).unwrap();
assert!(other.iter().zip(&cpu).all(|(x, y)| (0..3).all(|c| (x[c] - y[c]).abs() <= TOL)));
}
#[test]
fn bad_sizes_and_faulted_devices_are_errors() {
let Some(mut g) = gpu() else { return };
let stages = per_pixel(&CameraRaw { exposure: 0.5, ..Default::default() }, 4, 4, false, None).unwrap();
let px = vec![[0.5f32; 4]; 16];
for size in [[0, 4], [4, 0], [5, 4], [3, 3], [u32::MAX, 1]] {
assert!(g.dev.develop(&g.device, &g.queue, None, &px, None, size, &stages).is_err(), "{size:?}");
}
let health = DeviceHealth::new();
health.mark(Fault::Lost("test".into()));
assert!(g.dev.develop(&g.device, &g.queue, Some(&health), &px, None, [4, 4], &stages).is_err());
// And the pass still works afterwards.
assert!(g.dev.develop(&g.device, &g.queue, Some(&DeviceHealth::new()), &px, None, [4, 4], &stages).is_ok());
}
+1
View File
@@ -4,6 +4,7 @@
/// creates a device takes this lock and holds it until the device is dropped.
static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
mod camera_raw;
mod device_loss;
mod parity;
mod pipeline_errors;
+4
View File
@@ -56,3 +56,7 @@ photocraft-psd = { workspace = true, features = ["testgen"] }
photocraft-raw = { workspace = true, features = ["testgen"] }
photocraft-testkit = { workspace = true }
proptest = { workspace = true }
# Lossless JPEG XL encoder for the JPEG XL DNG preview test (tests only), as in photocraft-raw.
zune-jpegxl = { version = "=0.5.2", default-features = false, features = ["std"] }
zune-core = { workspace = true }
tiff = { version = "0.11", default-features = false, features = ["deflate", "lzw"] }
+52 -3
View File
@@ -3,8 +3,9 @@
//! `x:xmpmeta/rdf:RDF/rdf:Description`, as attributes or as child elements.
//!
//! Loading applies only the settings the file holds, over the current ones, as Camera Raw's
//! Load Settings does; settings PhotoCraft has no control for (lens corrections, calibration,
//! geometry, defringe, …) are ignored and named in the report. Saving writes every supported
//! Load Settings does; settings PhotoCraft has no control for (lens corrections, geometry,
//! defringe, …) are ignored and named in the report. The same reader fills the dialog from a
//! raw file's sidecar (`IMG_0001.xmp` beside `IMG_0001.CR2`), and the writer keeps it. Saving writes every supported
//! control, so loading the file in Photoshop reproduces the dialog.
//!
//! White balance: a Camera Raw Filter on a rendered image uses the relative
@@ -100,6 +101,14 @@ const SCALARS: &[(&str, Field, f32, f32)] = &[
("PostCropVignetteRoundness", |p| &mut p.vignette_roundness, -100.0, 100.0),
("PostCropVignetteFeather", |p| &mut p.vignette_feather, 0.0, 100.0),
("PostCropVignetteHighlightContrast", |p| &mut p.vignette_highlights, 0.0, 100.0),
// Calibration
("ShadowTint", |p| &mut p.shadow_tint, -100.0, 100.0),
("RedHue", |p| &mut p.calibration_hue[0], -100.0, 100.0),
("RedSaturation", |p| &mut p.calibration_sat[0], -100.0, 100.0),
("GreenHue", |p| &mut p.calibration_hue[1], -100.0, 100.0),
("GreenSaturation", |p| &mut p.calibration_sat[1], -100.0, 100.0),
("BlueHue", |p| &mut p.calibration_hue[2], -100.0, 100.0),
("BlueSaturation", |p| &mut p.calibration_sat[2], -100.0, 100.0),
];
type Curve = fn(&mut CameraRaw) -> &mut Vec<[f32; 2]>;
@@ -265,6 +274,25 @@ fn escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
/// The XMP toolkit [`save`] names on `x:xmpmeta`.
const TOOLKIT: &str = "PhotoCraft";
const META_NS: &str = "adobe:ns:meta/";
/// Did [`save`] write this file? True when its `x:xmpmeta` names PhotoCraft as the toolkit
/// (`x:xmptk`). Lightroom, Bridge and Camera Raw put their own toolkit there when they write a
/// file, so a sidecar another application touched since reads false. Never panics.
pub fn is_photocraft_xmp(bytes: &[u8]) -> bool {
if bytes.len() > MAX_XMP_BYTES {
return false;
}
let Ok(text) = std::str::from_utf8(bytes) else { return false };
let Ok(doc) = roxmltree::Document::parse(text.trim_start_matches('\u{feff}')) else { return false };
doc.descendants()
.find(|n| n.is_element() && n.tag_name().namespace() == Some(META_NS) && n.tag_name().name() == "xmpmeta")
.and_then(|n| n.attribute((META_NS, "xmptk")))
== Some(TOOLKIT)
}
/// Write `params` as a Camera Raw settings file Photoshop's Load Settings… reads.
pub fn save(params: &CameraRaw) -> String {
let mut p = params.clone();
@@ -357,7 +385,8 @@ mod tests {
assert_eq!(p.point_curve_red, vec![[0.0, 0.0], [128.0, 140.0], [255.0, 255.0]]);
assert_eq!(p.dehaze, 12.0);
// Lens vignetting, calibration and geometry have no counterpart.
assert_eq!(r.ignored, vec!["VignetteAmount".to_string(), "RedHue".to_string()]);
assert_eq!(r.ignored, vec!["VignetteAmount".to_string()], "the Calibration panel's keys load now");
assert_eq!(p.calibration_hue[0], -5.0);
}
#[test]
@@ -420,4 +449,24 @@ mod tests {
assert_eq!(p, before);
assert_eq!(r.ignored, vec!["Contrast2012".to_string(), "ToneCurvePV2012".to_string()]);
}
#[test]
fn is_photocraft_xmp_reads_the_toolkit_marker() {
let own = save(&CameraRaw { contrast: 15.0, ..CameraRaw::default() });
assert!(is_photocraft_xmp(own.as_bytes()));
let wrapped = format!("\u{feff}<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n{own}<?xpacket end=\"w\"?>");
assert!(is_photocraft_xmp(wrapped.as_bytes()), "an xpacket wrapper and a byte order mark");
assert!(!is_photocraft_xmp(FIXTURE), "Camera Raw's own toolkit");
for other in [
&b""[..],
b"<x:xmpmeta",
b"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>",
b"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" xmptk=\"PhotoCraft\"/>",
b"<x:xmpmeta xmlns:x=\"other:ns/\" x:xmptk=\"PhotoCraft\"/>",
b"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" x:xmptk=\"PhotoCraft 2\"/>",
b"\xff\xfe",
] {
assert!(!is_photocraft_xmp(other), "{}", String::from_utf8_lossy(other));
}
}
}
+133 -4
View File
@@ -4,15 +4,18 @@
//! one 13-byte record per extra channel: colour space (u16), four u16 colour components, opacity
//! (u16, 0..100) and kind (u8: 0 = colour indicates selected areas, 1 = masked ("protected")
//! areas, 2 = spot channel). Resource 1007 has the same records without the version and the spot
//! kind, each followed by a padding byte (14 bytes).
//! kind, each followed by a padding byte (14 bytes). Photoshop 27's TIFFs use the same 13-byte
//! 1077 records.
use photocraft_color::{Color, ColorMode};
use photocraft_color::{Color, ColorMode, SampleType};
use photocraft_doc::{AlphaChannel, ColorIndicates};
pub(crate) const DISPLAY_INFO: u16 = 1077;
pub(crate) const DISPLAY_INFO_OLD: u16 = 1007;
/// Quick Mask information: channel id (u16) and an "initially empty" flag (u8).
pub(crate) const QUICK_MASK_INFO: u16 = 1022;
/// Alpha channel identifiers (Photoshop 6): one big-endian u32 per extra channel, no count prefix.
pub(crate) const ALPHA_IDENTIFIERS: u16 = 1053;
const SPACE_RGB: u16 = 0;
const SPACE_HSB: u16 = 1;
@@ -20,6 +23,33 @@ const SPACE_CMYK: u16 = 2;
const SPACE_LAB: u16 = 7;
const SPACE_GRAY: u16 = 8;
/// Converts one spot channel's plane (native-endian samples) between the document's ink density
/// (1 = solid ink) and the way Photoshop stores a spot channel (0 = solid ink, the maximum =
/// bare paper; every PSD and TIFF Photoshop 27.11 saves stores it so). The conversion is its own
/// inverse, so it serves both open and save. Every reader and writer of Photoshop-polarity spot
/// planes goes through here: a change to the in-memory convention changes only this function.
/// Alpha channels are stored straight (the maximum = selected) and never come here.
pub(crate) fn photoshop_spot_plane(plane: &mut [u8], sample: SampleType) {
crate::pixels::invert_plane(plane, sample);
}
/// Resource 1053 for `count` channels: one big-endian u32 identifier each, with no length prefix
/// (Photoshop, ag-psd and psd-tools read u32s until the resource ends). Photoshop 27.11 numbers a
/// new document's channels 3, 4, 5, … in every colour mode (RGB, CMYK, Gray and Lab, in its PSDs
/// and TIFFs alike), so these do too. `transparency`: the merged image's transparency channel
/// comes first, with identifier 0 (Photoshop's transparent PSD lists `0, 3, 4, 5`).
pub(crate) fn alpha_identifiers(transparency: bool, count: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(count.saturating_add(1).saturating_mul(4));
if transparency {
out.extend_from_slice(&0u32.to_be_bytes());
}
for i in 0..count {
let id = 3u32.saturating_add(u32::try_from(i).unwrap_or(u32::MAX));
out.extend_from_slice(&id.to_be_bytes());
}
out
}
/// Resource 1077 for `channels` (in file order): a version, then 13 bytes per channel (colour
/// space, four components, opacity, kind). Every corpus file, Photoshop's included, uses 13;
/// 14-byte records (the older 1007 layout) shift every channel after the first.
@@ -46,12 +76,33 @@ pub(crate) fn display_info(channels: &[&AlphaChannel], transparency: bool) -> Ve
out
}
/// The records of a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version;
/// else the older 1007 with 14-byte records). A trailing partial record is left out.
fn display_records(data: &[u8], versioned: bool) -> std::slice::ChunksExact<'_, u8> {
let (body, size) = if versioned { (data.get(4..).unwrap_or(&[]), 13) } else { (data, 14) };
body.chunks_exact(size)
}
/// Which of the extra channels are spot channels (kind 2), after skipping `skip` records (the
/// merged transparency channel's), in the same records [`apply_display_info`] reads. A channel
/// without a record is not a spot.
pub(crate) fn spot_flags(data: &[u8], versioned: bool, skip: usize) -> Vec<bool> {
display_records(data, versioned).skip(skip).map(|rec| rec.get(12) == Some(&2)).collect()
}
/// Whether a DisplayInfo resource 1077 marks any channel as a spot (kind 2), in 13-byte records
/// (Photoshop's, and PhotoCraft's since 0.8.2) or in the 14-byte padded records PhotoCraft wrote
/// up to 0.7.0.
pub(crate) fn display_info_marks_spot(data: &[u8]) -> bool {
let body = data.get(4..).unwrap_or_default();
[13, 14].into_iter().any(|size| body.chunks_exact(size).any(|rec| rec.get(12) == Some(&2)))
}
/// Applies a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version; else the
/// older 1007 with 14-byte records) to the imported channels in order, after skipping `skip`
/// records (the merged transparency channel's).
pub(crate) fn apply_display_info(data: &[u8], versioned: bool, skip: usize, channels: &mut [AlphaChannel]) {
let (body, size) = if versioned { (data.get(4..).unwrap_or(&[]), 13) } else { (data, 14) };
for (c, rec) in channels.iter_mut().zip(body.chunks_exact(size).skip(skip)) {
for (c, rec) in channels.iter_mut().zip(display_records(data, versioned).skip(skip)) {
let u = |i: usize| u16::from_be_bytes([rec[i], rec[i + 1]]);
let comps = [u(2), u(4), u(6), u(8)];
let color = decode_color(u(0), comps);
@@ -149,6 +200,15 @@ mod tests {
assert!((back[0].opacity - 0.3).abs() < 1e-6);
assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas);
assert_eq!(back[1].spot, s.spot);
assert_eq!(spot_flags(&bytes, true, 0), [false, true]);
assert!(display_info_marks_spot(&bytes));
assert!(!display_info_marks_spot(&display_info(&[&a], true)), "alpha and transparency records only");
assert!(!display_info_marks_spot(&bytes[..10]));
// Photoshop 27 writes a 13-byte record (no pad): red spot, solidity 60.
let ps = [0, 0, 0, 1, 0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 0x3c, 2];
let mut spot = vec![AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))];
apply_display_info(&ps, true, 0, &mut spot);
assert_eq!(spot[0].spot.map(|(c, s)| (c.to_rgb(), (s * 100.0).round() as u16)), Some(([1.0, 0.0, 0.0], 60)));
// With the merged transparency's record first, the channels still get their own.
let with_t = display_info(&[&a, &s], true);
assert_eq!(with_t.len(), 4 + 3 * 13);
@@ -156,10 +216,79 @@ mod tests {
apply_display_info(&with_t, true, 1, &mut back);
assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas);
assert_eq!(back[1].spot, s.spot);
assert_eq!(spot_flags(&with_t, true, 1), [false, true]);
// Truncated data is ignored rather than misread.
let mut c = vec![AlphaChannel::new("x", Surface::new(PixelFormat::GRAY8))];
apply_display_info(&bytes[..10], true, 0, &mut c);
assert_eq!(c[0].opacity, 0.5);
assert!(spot_flags(&bytes[..10], true, 0).is_empty());
assert!(spot_flags(&bytes, true, 5).is_empty());
// 14 unpadded records are a multiple of 14 as well (14×13 = 13×14). Resource 1077
// still reads them as 13-byte records; the last spot's solidity must survive.
let mut many = Vec::new();
for i in 0..14 {
let mut ch = AlphaChannel::new(format!("{i}"), Surface::new(PixelFormat::GRAY8));
ch.spot = Some((Color::rgb(1.0, 0.0, 0.0), (i as f32) / 14.0));
many.push(ch);
}
let refs: Vec<&AlphaChannel> = many.iter().collect();
let packed = display_info(&refs, false);
assert_eq!(packed.len(), 4 + 14 * 13);
let mut got: Vec<AlphaChannel> = (0..14).map(|i| AlphaChannel::new(format!("{i}"), Surface::new(PixelFormat::GRAY8))).collect();
apply_display_info(&packed, true, 0, &mut got);
assert_eq!(got[13].spot.map(|(_, s)| (s * 100.0).round() as u16), many[13].spot.map(|(_, s)| (s * 100.0).round() as u16));
}
/// Resource 1053 as Photoshop 27.11 numbers a new document's channels: 3, 4, 5 in every colour
/// mode, with the merged transparency first as 0 (its transparent PSD lists `0, 3, 4, 5`).
#[test]
fn alpha_identifiers_are_numbered_as_photoshop_does() {
let ids = |transparency, count| alpha_identifiers(transparency, count).as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect::<Vec<_>>();
assert_eq!(ids(false, 3), [3, 4, 5]);
assert_eq!(ids(true, 3), [0, 3, 4, 5]);
assert_eq!(ids(false, 1), [3]);
assert!(ids(false, 0).is_empty());
assert_eq!(ids(true, 0), [0]);
assert_eq!(alpha_identifiers(false, 2), [0, 0, 0, 3, 0, 0, 0, 4]);
}
/// PhotoCraft 0.1.0 to 0.7.0 padded each 1077 record to 14 bytes, so a spot after the first
/// record sits where a 13-byte reader finds no kind 2; it is still recognised.
#[test]
fn a_spot_is_found_in_padded_records() {
let alpha = |kind: u8| [0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, kind, 0];
let mut old = 1u32.to_be_bytes().to_vec();
old.extend_from_slice(&alpha(1));
old.extend_from_slice(&alpha(0));
old.extend_from_slice(&alpha(2));
assert_eq!(spot_flags(&old, true, 0), [false, false, false], "13-byte records misread the padded ones");
assert!(display_info_marks_spot(&old));
old.truncate(old.len() - 14);
assert!(!display_info_marks_spot(&old), "two alpha records");
}
/// Photoshop's spot ink (0 = solid) and the document's density (1 = solid) at every depth,
/// both ways: the 25 % / 75 % columns of the Photoshop oracles store 191 / 64.
#[test]
fn photoshop_spot_plane_converts_both_ways() {
let mut p8 = vec![255u8, 64, 191, 0];
photoshop_spot_plane(&mut p8, SampleType::U8);
assert_eq!(p8, [0, 191, 64, 255]);
photoshop_spot_plane(&mut p8, SampleType::U8);
assert_eq!(p8, [255, 64, 191, 0]);
let mut p16: Vec<u8> = [65535u16, 16448, 0].iter().flat_map(|v| v.to_ne_bytes()).collect();
photoshop_spot_plane(&mut p16, SampleType::U16);
let back: Vec<u16> = p16.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes(*c)).collect();
assert_eq!(back, [0, 49087, 65535]);
let mut p32: Vec<u8> = [1.0f32, 0.25].iter().flat_map(|v| v.to_ne_bytes()).collect();
photoshop_spot_plane(&mut p32, SampleType::F32);
let back: Vec<f32> = p32.as_chunks::<4>().0.iter().map(|c| f32::from_ne_bytes(*c)).collect();
assert_eq!(back, [0.0, 0.75]);
// A trailing partial sample is left alone rather than misread.
let mut odd = vec![0u8, 0, 7];
photoshop_spot_plane(&mut odd, SampleType::U16);
assert_eq!(odd, [0xff, 0xff, 7]);
}
#[test]
+33 -5
View File
@@ -280,6 +280,7 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result<ImportResult,
bg
};
doc.layers.push(layer);
warnings.extend(crate::tiff_layers::channels_from_extras(&mut doc, img));
doc.icc_profile = img.icc.clone().map(Arc::new);
doc.metadata.exif = img.meta.exif.clone().map(Arc::new);
doc.metadata.xmp = img.meta.xmp.as_deref().map(Arc::from);
@@ -668,6 +669,12 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti
if format == Format::Tga {
img = tga_image(doc, img, opts.tga_bits, &mut warnings)?;
}
if format == Format::Dds {
img = dds_image(doc, img, &mut warnings)?;
}
if format == Format::Tiff {
warnings.extend(crate::tiff_layers::stamp_flat_channels(doc, &mut img));
}
encode_image(&img, format, opts, warnings)
}
@@ -706,7 +713,7 @@ fn tga_image(doc: &Document, img: Image, bits: Option<TgaBits>, warnings: &mut V
warnings.push("transparency composited over white; a 24-bit Targa has no alpha".into());
matte_over_white(&img)
}
(TgaBits::Bits32, [channel]) => tga_with_alpha_channel(doc, img, channel, warnings),
(TgaBits::Bits32, [channel]) => with_alpha_channel(doc, img, channel, "Targa", warnings),
(TgaBits::Bits32, alphas) => {
if !alphas.is_empty() {
warnings.push("Targa holds one alpha channel; with several, none was written (as in Photoshop)".into());
@@ -719,11 +726,32 @@ fn tga_image(doc: &Document, img: Image, bits: Option<TgaBits>, warnings: &mut V
}
}
/// A 32-bit Targa's image: the colours (composited over white where the layers are transparent)
/// and `channel`'s values as the alpha.
fn tga_with_alpha_channel(doc: &Document, img: Image, channel: &AlphaChannel, warnings: &mut Vec<String>) -> Result<Image, IoError> {
/// The image a DDS texture holds (#8563): as with a 32-bit Targa, the document's alpha channel
/// becomes the texture's alpha when it has exactly one (spot channels don't count), the colours
/// composited over white where the layers are transparent; BC1 keeps it as on/off, BC2 and BC3
/// in full. With none, the layers' transparency is the alpha as before; with several, none of
/// them is written. Grayscale textures keep their transparency.
fn dds_image(doc: &Document, img: Image, warnings: &mut Vec<String>) -> Result<Image, IoError> {
let alphas: Vec<&AlphaChannel> = doc.channels.iter().filter(|c| c.spot.is_none()).collect();
match alphas.as_slice() {
[] => Ok(img),
[channel] if img.layout().is_rgb() => with_alpha_channel(doc, img, channel, "texture", warnings),
[_] => {
warnings.push("a grayscale DDS keeps the layers' transparency; the alpha channel was not saved".into());
Ok(img)
}
_ => {
warnings.push("a DDS texture holds one alpha channel; with several, none was written".into());
Ok(img)
}
}
}
/// A 32-bit Targa's or a texture's image: the colours (composited over white where the layers are
/// transparent) and `channel`'s values as the alpha. `what` names the file in the warning.
fn with_alpha_channel(doc: &Document, img: Image, channel: &AlphaChannel, what: &str, warnings: &mut Vec<String>) -> Result<Image, IoError> {
let img = if img.layout().has_alpha() {
warnings.push("transparency composited over white; the alpha channel is the Targa's alpha".into());
warnings.push(format!("transparency composited over white; the alpha channel is the {what}'s alpha"));
matte_over_white(&img)?
} else {
img
+8
View File
@@ -21,6 +21,14 @@ pub fn sample_for_depth(depth: u16) -> SampleType {
}
}
/// Complements every native-endian sample of one plane (`max − v`, `1 − v` for float). A
/// trailing partial sample is left unchanged.
pub(crate) fn invert_plane(bytes: &mut [u8], s: SampleType) {
for c in bytes.chunks_exact_mut(s.bytes().max(1)) {
invert_sample(c, s);
}
}
fn invert_sample(bytes: &mut [u8], s: SampleType) {
match s {
SampleType::U8 => bytes[0] = 255 - bytes[0],
+152 -9
View File
@@ -1270,9 +1270,12 @@ pub(crate) fn pascal_names_resource(names: &[&str]) -> Vec<u8> {
let mut v = Vec::new();
for n in names {
let b = legacy_name(n);
v.push(b.len() as u8);
v.extend_from_slice(&b);
let n = b.len().min(255);
v.push(n as u8);
v.extend_from_slice(&b[..n]);
}
// No per-string pad: Photoshop's resource 1006 for "Spot Color 1" is 13 bytes
// (length byte + 12 characters). The 8BIM container pads an odd resource separately.
v
}
@@ -1824,7 +1827,11 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
};
for a in &extra {
let s = if a.surface.format() != ex.mask_fmt { a.surface.convert(ex.mask_fmt) } else { a.surface.clone() };
let bytes = s.to_interleaved(canvas);
let mut bytes = s.to_interleaved(canvas);
// Spot ink as Photoshop stores it (0 = solid); an alpha channel stays straight.
if a.spot.is_some() {
crate::channel_map::photoshop_spot_plane(&mut bytes, sample);
}
planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0));
}
let channels = (cc + usize::from(has_alpha) + extra.len()) as u16;
@@ -1856,6 +1863,9 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
resources.push(ImageResource::new(1006, pascal_names_resource(&names)));
resources.push(ImageResource::new(1045, unicode_names_resource(&names)));
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra, has_alpha)));
// Photoshop writes 1053 in every file with an extra channel. Its absence is how a file from
// an earlier PhotoCraft is told apart (`psd_import::earlier_photocraft_spot_planes`).
resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(has_alpha, extra.len())));
}
if let Some(id) = quick_mask_id {
let mut data = id.to_be_bytes().to_vec();
@@ -1907,7 +1917,13 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
if !ex.records.is_empty() && matches!(&key, b"Lr16" | b"Lr32" | b"Layr") {
continue;
}
global_blocks.push(global_block(block_signature(sig, key, version), key, &data));
let block = global_block(block_signature(sig, key, version), key, &data);
// A kept merged-transparency marker describes the opened file's composite. Over an opaque
// one it would make the first alpha channel read back as the transparency, and vanish.
if !has_alpha && block.is_merged_transparency_marker() {
continue;
}
global_blocks.push(block);
}
if let Some(txt2) = txt2 {
let mut tb = TaggedBlock::new(*b"Txt2", txt2.clone());
@@ -1932,6 +1948,12 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
if !keep_imported_resource(doc, *id, data) {
continue;
}
// 1053 is written afresh with the extra channels. A kept copy (from a Multichannel file, or
// a document saved by an earlier version) would be a second one, or list channels that
// are gone.
if *id == crate::channel_map::ALPHA_IDENTIFIERS {
continue;
}
let mut r = ImageResource::new(*id, data.to_vec());
r.name = legacy_name(name);
resources.push(r);
@@ -1948,10 +1970,10 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
resources.push(version_info_resource(real_merged));
let has_layers = !ex.records.is_empty();
// Photoshop writes a merged-transparency marker (`Mtrn`, `Mt16`, `Mt32`) before the layer
// block, and won't open a file whose `Lr32` comes first (#5068): the layer block goes right
// after the last kept marker, else first.
let index = global_blocks.iter().rposition(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32")).map_or(0, |i| i + 1);
// Photoshop writes a merged-transparency marker (`Mtrn`, `Mt16`, `Mt32`; `MTrn` in a TIFF)
// before the layer block, and won't open a file whose `Lr32` comes first (#5068): the layer
// block goes right after the last kept marker, else first.
let index = global_blocks.iter().rposition(TaggedBlock::is_merged_transparency_marker).map_or(0, |i| i + 1);
let placement = match (has_layers, sample) {
(true, SampleType::U16) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr16", padding: None },
(true, SampleType::F32) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr32", padding: None },
@@ -2059,6 +2081,100 @@ mod tests {
assert!(back.layers.len() == 1 && bg.name == "Background" && bg.locks.transparency && bg.locks.position, "{:?}", bg.locks);
}
/// A layered document with a spot and an alpha channel. Each channel holds 100 %, 25 %, 75 %
/// and 0 % in four columns, the layout of the Photoshop spot oracles.
fn spot_and_alpha_document() -> Document {
let mut doc = document(4, 1);
doc.layers.push(Layer::group("Group", vec![Layer::raster("Layer 1", doc.pixel_format())]));
let channel = |name: &str| {
let mut s = Surface::new(PixelFormat::GRAY8);
s.write_interleaved(doc.bounds(), &[255, 64, 191, 0]);
photocraft_doc::AlphaChannel::new(name, s)
};
let mut spot = channel("Spot Color 1");
spot.spot = Some((photocraft_doc::Color::rgb(0.0, 0.6, 1.0), 0.0));
let alpha = channel("Alpha 1");
doc.channels.extend([spot, alpha]);
doc
}
/// The extra planes after the colour channels (and the merged transparency) of `file`.
fn extra_planes(file: &PsdFile, count: usize) -> Vec<u8> {
let planes = file.decode_merged().unwrap();
let plane = (file.header.width * file.header.height) as usize * usize::from(file.header.depth / 8);
planes[planes.len() - count * plane..].to_vec()
}
/// Resource 1053's identifiers.
fn alpha_ids(file: &PsdFile) -> Option<Vec<u32>> {
file.resource(crate::channel_map::ALPHA_IDENTIFIERS).map(|r| r.data.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect())
}
/// A PSD stores spot ink as Photoshop's own PSDs do (0 = solid ink: the 100, 25, 75 and 0 %
/// columns are 0, 191, 64, 255) and an alpha channel straight (255 = selected). The global
/// layer mask stays empty and flag bit 3 stays on the layers only, not on the group's closing
/// divider: Photoshop opens those as they are, and 509 of the corpus's 721 Photoshop PSDs have
/// an empty mask too. Opening the file gives the same ink back.
#[test]
fn psd_stores_spot_ink_as_photoshop_does() {
let doc = spot_and_alpha_document();
let file = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap();
assert_eq!(extra_planes(&file, 2), [0, 191, 64, 255, 255, 64, 191, 0], "spot, then alpha");
assert_eq!(file.global_layer_mask.as_ref().map(|m| m.data.len()), Some(0));
let flags: Vec<u8> = file.layers().iter().map(|l| l.flags.0 & LayerFlags::BIT4_USEFUL).collect();
assert_eq!(flags, [0, LayerFlags::BIT4_USEFUL, LayerFlags::BIT4_USEFUL], "closing divider, layer, group");
let back = crate::import("spot.psd", &file.to_bytes().unwrap()).unwrap().document;
assert!(back.channels[0].spot.is_some() && back.channels[1].spot.is_none());
for c in &back.channels {
assert_eq!(c.surface.to_interleaved(back.bounds()), [255, 64, 191, 0], "{}", c.name);
}
}
/// Resource 1053 numbers the extra channels as Photoshop 27.11 does in a new document: 3, 4, 5
/// in every colour mode, after 0 for the merged transparency when there is one (its
/// transparent PSD lists `0, 3, 4, 5`). A Quick Mask is an extra channel too. Without extra
/// channels there is none.
#[test]
fn psd_alpha_identifiers_number_the_channels_as_photoshop_does() {
use photocraft_color::Color;
let size = photocraft_geom::Size::new(4, 1);
for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale, ColorMode::Lab] {
let mut doc = Document::with_background("bg", size, mode, SampleType::U8, Color::rgb(1.0, 1.0, 1.0));
assert_eq!(alpha_ids(&document_to_psd(&doc)), None, "{mode:?} without channels");
doc.channels = spot_and_alpha_document().channels;
let file = document_to_psd(&doc);
assert!(!file.merged_has_alpha(), "{mode:?}");
assert_eq!(alpha_ids(&file), Some(vec![3, 4]), "{mode:?}");
doc.quick_mask = Some(photocraft_doc::AlphaChannel::new("Quick Mask", Surface::new(PixelFormat::GRAY8)));
assert_eq!(alpha_ids(&document_to_psd(&doc)), Some(vec![3, 4, 5]), "{mode:?} with a Quick Mask");
}
// The empty layer leaves the composite transparent.
let file = document_to_psd(&spot_and_alpha_document());
assert!(file.merged_has_alpha());
assert_eq!(alpha_ids(&file), Some(vec![0, 3, 4]));
}
/// A save writes one fresh 1053 for the channels the document has. A Photoshop file's own is
/// not kept on open (it would be new metadata after every round trip), and one a document
/// carries anyway (kept by an earlier version) is never written: next to the fresh one it
/// would be a second, and without channels it would list channels that are gone. The
/// identifiers here, 3 and 2, are those of psd-tools' `cmyk-alpha-spot.psd` (Photoshop 24.7).
#[test]
fn a_kept_alpha_identifiers_resource_is_not_written() {
let kept = || (crate::channel_map::ALPHA_IDENTIFIERS, String::new(), std::sync::Arc::new([0, 0, 0, 3, 0, 0, 0, 2].to_vec()));
let mut file = document_to_psd(&spot_and_alpha_document());
file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS);
file.resources.push(ImageResource::new(kept().0, kept().2.to_vec()));
let mut opened = crate::import("ps.psd", &file.to_bytes().unwrap()).unwrap().document;
assert!(!opened.metadata.psd_resources.iter().any(|(id, ..)| *id == crate::channel_map::ALPHA_IDENTIFIERS));
opened.metadata.psd_resources.push(kept());
let again = PsdFile::from_bytes(&crate::export(&opened, "x.psd", &crate::ExportOptions::default()).unwrap().bytes).unwrap();
assert_eq!(again.resources.iter().filter(|r| r.id == crate::channel_map::ALPHA_IDENTIFIERS).count(), 1);
assert_eq!(alpha_ids(&again), Some(vec![0, 3, 4]));
opened.channels.clear();
assert_eq!(alpha_ids(&document_to_psd(&opened)), None, "no channels left");
}
#[test]
fn group_divider_id_wraps_without_colliding_with_imported_layer_ids() {
let mut doc = document(8, 8);
@@ -2085,7 +2201,12 @@ mod tests {
/// `transparentbg.psd` re-saved both ways). The layer block follows a kept marker.
#[test]
fn layer_block_follows_a_kept_merged_transparency_marker() {
for (depth, key, marker) in [(SampleType::F32, *b"Lr32", *b"Mt32"), (SampleType::U16, *b"Lr16", *b"Mt16"), (SampleType::F32, *b"Lr32", *b"Mtrn")] {
for (depth, key, marker) in [
(SampleType::F32, *b"Lr32", *b"Mt32"),
(SampleType::U16, *b"Lr16", *b"Mt16"),
(SampleType::F32, *b"Lr32", *b"Mtrn"),
(SampleType::F32, *b"Lr32", *b"MTrn"),
] {
let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth);
let mut s = Surface::new(doc.pixel_format());
s.fill_rect(photocraft_geom::Rect::new(1, 1, 5, 4), &[0.5, 0.2, 0.9, 1.0]);
@@ -2105,6 +2226,28 @@ mod tests {
assert!(matches!(saved.layer_info_placement, LayerInfoPlacement::GlobalBlock { index: 0, .. }));
}
/// A merged-transparency marker kept from the opened file describes that file's composite.
/// Saved with an opaque composite, it would make the first alpha channel read back as the
/// merged transparency, and the channel would be gone: the save drops it.
#[test]
fn an_opaque_save_drops_a_kept_merged_transparency_marker() {
for (depth, marker) in [(SampleType::U8, *b"MTrn"), (SampleType::U8, *b"Mtrn"), (SampleType::U16, *b"Mt16"), (SampleType::F32, *b"Mt32")] {
let case = format!("{depth:?} {}", String::from_utf8_lossy(&marker));
let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth);
let mut s = Surface::new(doc.pixel_format());
s.fill_rect(photocraft_geom::Rect::new(0, 0, 8, 6), &[0.5, 0.2, 0.9, 1.0]);
doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(s)));
doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", Surface::new(PixelFormat::GRAY8)));
doc.metadata.psd_global_blocks = vec![(*b"8BIM", marker, std::sync::Arc::new(Vec::new()))];
let file = document_to_psd(&doc);
assert!(!file.global_blocks.iter().any(TaggedBlock::is_merged_transparency_marker), "{case}");
assert!(!file.merged_has_alpha(), "{case}");
let back = crate::import("a.psd", &file.to_bytes().unwrap()).unwrap().document;
let names: Vec<&str> = back.channels.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["Alpha 1"], "{case}");
}
}
#[test]
fn unreadable_lr16_warns_on_import_and_is_not_saved_twice() {
// #2700: a 16-bit file whose Lr16 block claims five layers but holds none.
+357 -3
View File
@@ -430,10 +430,17 @@ impl Ctx<'_> {
})
.flatten();
if let Some(mut t) = parsed {
let mut path_error = None;
if let Some(txt2) = &self.txt2 {
photocraft_text::psd::apply_txt2(&mut t, &block.data, txt2);
// Type on a path keeps its path only in its `Txt2` frame (#7962).
path_error = photocraft_text::psd::apply_txt2_path(&mut t, &block.data, txt2).err();
photocraft_text::psd::apply_txt2_frame(&mut t, &block.data, txt2);
}
if let Some(e) = path_error {
let shape = if matches!(t.shape, photocraft_doc::text::TextShape::Box { .. }) { "paragraph" } else { "point" };
self.warn(format!("layer \"{name}\": its type path could not be read ({e}); it opens as {shape} text"));
}
// Photoshop's pixels stay until edited. Writers without type pixels are
// rendered by text_import::prepare instead of retaining an empty cache.
let cache = self.record_surface(rec, &name);
@@ -724,7 +731,7 @@ fn be_to_ne_plane(plane: &[u8], s: SampleType, n: usize) -> Vec<u8> {
interleave(&[Some(plane)], &[zero_sample(s)], n, s, &[false])
}
fn unicode_names(data: &[u8]) -> Vec<String> {
pub(crate) fn unicode_names(data: &[u8]) -> Vec<String> {
let mut out = Vec::new();
let mut at = 0;
while at + 4 <= data.len() {
@@ -876,6 +883,69 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
psd_to_document_decoded(file, ctl, None)
}
/// Whether `file` stores spot planes the way earlier PhotoCraft versions (releases 0.1.0 to
/// 0.16.0) wrote them: straight (the maximum = solid ink), the opposite of Photoshop, which
/// stores 0 = solid ink in every PSD and TIFF it saves. Their files are recognised by three
/// things together:
///
/// - DisplayInfo (1077) marks at least one spot channel;
/// - there is no Alpha Identifiers resource (1053), or one that lists fewer channels than the
/// file has ([`photoshop_identified_channels`]). Photoshop has written it, one identifier per
/// extra channel, in every file with an extra channel since version 6, and PhotoCraft writes it
/// now. No earlier PhotoCraft wrote one of its own, but each kept the 1053 of a Photoshop file
/// it opened (a transparent document's `[0]`, for one) and wrote it back unchanged, after the
/// channels added in it, which it stored straight;
/// - the version info (1057) is byte for byte what those versions wrote: writer "Adobe Photoshop"
/// with reader "Adobe Photoshop CS6", both NUL-terminated (0.1.0 to 0.7.0), or reader "Adobe
/// Photoshop 2026" without a NUL (0.8.2 on), with either merged-data flag.
///
/// Any other file is read in Photoshop's polarity. psd-tools' `cmyk-spot.psd`, for one, also has
/// spots and no 1053, but names psd-tools in 1057 and stores Photoshop's polarity. A Photoshop
/// file that an earlier PhotoCraft opened and saved again kept Photoshop's 1053 and its planes as
/// Photoshop stored them, so it is read in Photoshop's polarity too; when channels were added
/// there, only the planes after the ones the kept 1053 lists are read straight.
///
/// Keeping these files' ink is the only reason this exists: deleting it (and its one caller)
/// reads every file in Photoshop's polarity, at the cost of inverting the spot ink of PSDs saved
/// by those versions.
pub(crate) fn earlier_photocraft_spot_planes(file: &PsdFile) -> bool {
use photocraft_psd::descriptor::UnicodeString;
use photocraft_psd::resources::VersionInfo;
let marks_spot = file.resource(crate::channel_map::DISPLAY_INFO).is_some_and(|r| crate::channel_map::display_info_marks_spot(&r.data));
let identifies_every_channel = match file.header.color_mode.color_channels() {
Some(color) => {
let channels = file.header.channels.saturating_sub(color).saturating_sub(u16::from(file.merged_has_alpha()));
photoshop_identified_channels(file) >= usize::from(channels)
}
// Multichannel has no fixed number of colour channels to count from.
None => file.resource(crate::channel_map::ALPHA_IDENTIFIERS).is_some(),
};
if !marks_spot || identifies_every_channel {
return false;
}
let Some(r) = file.resource(ids::VERSION_INFO) else { return false };
// Byte for byte: a resource with anything after the fields is someone else's.
let Ok(v) = VersionInfo::from_bytes(&r.data) else { return false };
if v.to_bytes() != r.data {
return false;
}
let writers = [
(UnicodeString::new_nul("Adobe Photoshop"), UnicodeString::new_nul("Adobe Photoshop CS6")),
(UnicodeString::new("Adobe Photoshop"), UnicodeString::new("Adobe Photoshop 2026")),
];
v.version == 1 && v.file_version == 1 && writers.iter().any(|(writer, reader)| v.writer == *writer && v.reader == *reader)
}
/// How many extra channels, after the merged transparency, the Alpha Identifiers resource (1053)
/// lists: its identifiers other than 0, which Photoshop gives the merged transparency (first, and
/// in some files with no transparency plane at all). 0 without a 1053. In a file from an earlier
/// PhotoCraft ([`earlier_photocraft_spot_planes`]) these are the channels of the Photoshop file
/// it opened, stored as Photoshop stored them; the channels after them were added there.
pub(crate) fn photoshop_identified_channels(file: &PsdFile) -> usize {
file.resource(crate::channel_map::ALPHA_IDENTIFIERS).map_or(0, |r| r.data.as_chunks::<4>().0.iter().filter(|id| **id != [0; 4]).count())
}
/// Whether the import reads the merged composite: for a flattened or multichannel file (it *is*
/// the image), a file without layers, or extra alpha/spot channels behind the colour ones.
pub(crate) fn merged_needed(file: &PsdFile) -> bool {
@@ -965,6 +1035,10 @@ pub(crate) fn psd_to_document_decoded_integrity(
doc.clipping_path = Some(photocraft_doc::ClippingPath { name, flatness: 0.0 });
doc.metadata.psd_resources.push((r.id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone())));
}
// Resource 1053 is written afresh with the extra channels on every save, so a kept copy
// would be new metadata after each round trip. Multichannel has no such rewrite: the
// imported bytes are the channel ids, copied back while they still match.
crate::channel_map::ALPHA_IDENTIFIERS if !multichannel => {}
id if MAPPED_RESOURCES.contains(&id) => {}
id => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))),
}
@@ -1197,13 +1271,29 @@ pub(crate) fn psd_to_document_decoded_integrity(
// Photoshop lists every extra channel, the merged transparency first when there is one.
let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc));
let names = names.get(skip..).unwrap_or_default();
// Which planes hold spot ink in Photoshop's polarity (0 = solid): the DisplayInfo records
// the channel options are read from below, the transparency's first when `skip` is 1.
// A file from an earlier PhotoCraft stores them straight and is read as it was written,
// past the channels of the Photoshop file it opened, if any.
let straight_from = if earlier_photocraft_spot_planes(file) { photoshop_identified_channels(file) } else { usize::MAX };
let photoshop_spots = if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) {
crate::channel_map::spot_flags(&r.data, true, skip)
} else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) {
crate::channel_map::spot_flags(&r.data, false, skip)
} else {
Vec::new()
};
for (k, idx) in (first..usize::from(h.channels)).enumerate() {
let Some(p) = merged_plane(all, idx, plane) else {
cx.warn_unreadable_data(format!("merged extra channel {idx} is incomplete and was not imported"));
break;
};
let mut raw = be_to_ne_plane(p, depth, n);
if k < straight_from && photoshop_spots.get(k).copied().unwrap_or(false) {
crate::channel_map::photoshop_spot_plane(&mut raw, depth);
}
let mut s = Surface::new(cx.mask_fmt);
s.write_interleaved(canvas, &be_to_ne_plane(p, depth, n));
s.write_interleaved(canvas, &raw);
s.prune();
let name = names.get(k).cloned().unwrap_or_else(|| format!("Alpha {}", k + 1));
doc.channels.push(AlphaChannel::new(name, s));
@@ -1245,7 +1335,9 @@ pub(crate) fn psd_to_document_decoded_integrity(
#[cfg(test)]
mod tests {
use super::*;
use photocraft_psd::{Compression, PsdError, Version, testgen};
use photocraft_psd::descriptor::UnicodeString;
use photocraft_psd::resources::VersionInfo;
use photocraft_psd::{Compression, ImageData, ImageResource, PsdError, Version, testgen};
/// #5200: the Multichannel fallback checks its RGBA output against the budget before
/// allocating it (here a 2x2 image, 16 bytes, against caps of 15 and 16 bytes).
@@ -1349,4 +1441,266 @@ mod tests {
assert_eq!(merged_plane(&all, 2, 2), None);
assert_eq!(merged_plane(&all, usize::MAX, 2), None);
}
/// Resource 1057's data.
fn version_info(writer: UnicodeString, reader: UnicodeString, has_real_merged_data: bool) -> Vec<u8> {
VersionInfo { version: 1, has_real_merged_data, writer, reader, file_version: 1 }.to_bytes()
}
/// The 1057 PhotoCraft 0.1.0 to 0.7.0 wrote: Photoshop CS6's names, NUL-terminated.
fn version_info_cs6(has_real_merged_data: bool) -> Vec<u8> {
version_info(UnicodeString::new_nul("Adobe Photoshop"), UnicodeString::new_nul("Adobe Photoshop CS6"), has_real_merged_data)
}
/// The 1057 PhotoCraft writes since 0.8.2: Photoshop 2026's bytes, no NUL.
fn version_info_2026(has_real_merged_data: bool) -> Vec<u8> {
photocraft_psd::resources::version_info_resource(has_real_merged_data).data
}
/// An opaque 4 × 1 RGB document with the Photoshop spot oracles' channels in their order:
/// "Spot A", "Alpha 1" and "Spot B", each holding 100, 25, 75 and 0 % in four columns.
fn channels_document(depth: SampleType) -> Document {
let mut doc = Document::with_background("old", Size::new(4, 1), ColorMode::Rgb, depth, photocraft_color::Color::rgb(1.0, 1.0, 1.0));
let gray = PixelFormat::new(ColorMode::Grayscale, depth, false);
let canvas = doc.bounds();
let channel = |name: &str, spot: Option<(photocraft_color::Color, f32)>| {
let mut s = Surface::new(gray);
s.write_region(canvas, &[1.0, 0.25, 0.75, 0.0]);
AlphaChannel { spot, ..AlphaChannel::new(name, s) }
};
doc.channels.push(channel("Spot A", Some((photocraft_color::Color::rgb(0.0, 0.627, 1.0), 0.0))));
doc.channels.push(channel("Alpha 1", None));
doc.channels.push(channel("Spot B", Some((photocraft_color::Color::rgb(1.0, 0.0, 0.627), 0.5))));
doc
}
/// Each channel's values on an 8-bit scale.
fn ink(doc: &Document) -> Vec<Vec<u8>> {
doc.channels.iter().map(|c| c.surface.read_region(doc.bounds()).iter().map(|v| (v * 255.0).round() as u8).collect()).collect()
}
fn spots(doc: &Document) -> Vec<bool> {
doc.channels.iter().map(|c| c.spot.is_some()).collect()
}
/// `doc` (opaque, 8 or 16-bit) saved the way PhotoCraft 0.1.0 to 0.16.0 saved it: spot planes
/// straight (the maximum = solid ink), no 1053, and `version_info` as resource 1057. `padded`
/// lays DisplayInfo out in the 14-byte records of 0.1.0 to 0.7.0.
fn as_earlier_photocraft_saved(doc: &Document, version_info: Vec<u8>, padded: bool) -> Vec<u8> {
let mut file = crate::psd_export::document_to_psd(doc);
assert!(!file.merged_has_alpha());
file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS);
for r in &mut file.resources {
match r.id {
ids::VERSION_INFO => r.data = version_info.clone(),
crate::channel_map::DISPLAY_INFO if padded => {
let (version, records) = r.data.split_at(4);
r.data = version.iter().copied().chain(records.chunks(13).flat_map(|rec| rec.iter().copied().chain([0]))).collect();
}
_ => {}
}
}
let mut planes = file.decode_merged().unwrap();
let plane = planes.len() / usize::from(file.header.channels);
for (k, c) in doc.channels.iter().enumerate() {
if c.spot.is_some() {
// At 8 and 16 bits the complement of a sample is that of each of its bytes.
planes[(3 + k) * plane..(4 + k) * plane].iter_mut().for_each(|b| *b = !*b);
}
}
file.image_data = ImageData::encode(Compression::Raw, &planes, &file.header).unwrap();
file.to_bytes().unwrap()
}
/// PSDs that PhotoCraft 0.1.0 to 0.16.0 saved with spot channels store them straight. They
/// open with the ink they had in those versions (which read every plane straight), whichever
/// 1057 they carry. Saved again, they get Photoshop's polarity and a 1053, and reopen with the
/// same ink. (A 0.1.0 to 0.7.0 file's padded records misread every channel's options after
/// the first, here Spot B's kind, as those versions' successors always have; its ink is kept.)
#[test]
fn spot_planes_from_earlier_photocraft_keep_their_ink() {
for depth in [SampleType::U8, SampleType::U16] {
let doc = channels_document(depth);
let cases = [
("0.1.0 to 0.7.0", version_info_cs6(true), true),
("0.8.2 on", version_info_2026(true), false),
("0.8.2 on, Maximize Compatibility off", version_info_2026(false), false),
("0.1.0 to 0.7.0, 13-byte records", version_info_cs6(true), false),
];
for (what, version_info, padded) in cases {
let bytes = as_earlier_photocraft_saved(&doc, version_info, padded);
assert!(earlier_photocraft_spot_planes(&PsdFile::from_bytes(&bytes).unwrap()), "{what}");
let opened = crate::import("old.psd", &bytes).unwrap().document;
assert_eq!(ink(&opened), ink(&doc), "{depth:?} {what}: the ink earlier versions opened");
assert!(spots(&opened)[0], "{depth:?} {what}");
if !padded {
assert_eq!(spots(&opened), spots(&doc), "{depth:?} {what}");
}
let saved = crate::export(&opened, "old.psd", &crate::ExportOptions::default()).unwrap();
let file = PsdFile::from_bytes(&saved.bytes).unwrap();
assert!(file.resource(crate::channel_map::ALPHA_IDENTIFIERS).is_some() && !earlier_photocraft_spot_planes(&file), "{what}");
let again = crate::import("again.psd", &saved.bytes).unwrap().document;
assert_eq!(ink(&again), ink(&doc), "{depth:?} {what}: saved again");
assert_eq!(spots(&again), spots(&opened), "{depth:?} {what}: saved again");
}
}
}
/// A 0.1.0 to 0.7.0 file whose first extra channel is an alpha channel, spots after it. In its
/// 14-byte DisplayInfo records the spots' kind bytes sit where a 13-byte reader (main's, and
/// this one's) doesn't look, so only the padded reading finds a spot for the rule. Every plane
/// is then read straight, as those versions read it, and keeps its ink. The 13-byte reader
/// opens each channel's options from shifted bytes, as on main: here every channel opens as
/// an alpha channel. A spot of 2 % solidity puts a 2 where that reader looks for the kind, so
/// that channel opens as a spot: without the rule its plane would be inverted. Saved again and
/// reopened, every channel keeps its ink and kind.
#[test]
fn an_early_file_with_an_alpha_channel_first_keeps_its_ink() {
for solidity in [0.0, 0.02] {
let mut doc = channels_document(SampleType::U8);
doc.channels.swap(0, 1);
doc.channels[1].spot = Some((photocraft_color::Color::rgb(0.0, 0.627, 1.0), solidity));
let names: Vec<&str> = doc.channels.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["Alpha 1", "Spot A", "Spot B"]);
let bytes = as_earlier_photocraft_saved(&doc, version_info_cs6(true), true);
let file = PsdFile::from_bytes(&bytes).unwrap();
let info = &file.resource(crate::channel_map::DISPLAY_INFO).unwrap().data;
let misread = solidity > 0.0;
assert_eq!(crate::channel_map::spot_flags(info, true, 0), [false, misread, false], "{solidity}: 13-byte records");
assert!(earlier_photocraft_spot_planes(&file), "{solidity}: found in the 14-byte records");
let opened = crate::import("old.psd", &bytes).unwrap().document;
assert_eq!(ink(&opened), ink(&doc), "{solidity}: the ink 0.7.0 opened");
assert_eq!(spots(&opened), [false, misread, false], "{solidity}: kinds as main reads them");
let saved = crate::export(&opened, "old.psd", &crate::ExportOptions::default()).unwrap();
let again = crate::import("again.psd", &saved.bytes).unwrap().document;
assert_eq!(ink(&again), ink(&doc), "{solidity}: saved again");
assert_eq!(spots(&again), spots(&opened), "{solidity}: saved again");
}
}
/// A file with spots and no 1053 that no earlier PhotoCraft wrote is read in Photoshop's
/// polarity (0 = solid ink). psd-tools' `cmyk-spot.psd` is one: its 1057 names psd-tools as
/// writer and reader. So is a file with no 1057, with Photoshop CS6's names without the NUL,
/// or with bytes after PhotoCraft's.
#[test]
fn other_files_without_alpha_identifiers_use_photoshop_polarity() {
let doc = channels_document(SampleType::U8);
let mut trailing = version_info_2026(true);
trailing.extend([0, 0]);
let cases = [
("psd-tools", Some(version_info(UnicodeString::new("psd-tools 1.10.0"), UnicodeString::new("psd-tools 1.10.0"), true))),
("no 1057", None),
("CS6 without a NUL", Some(version_info(UnicodeString::new("Adobe Photoshop"), UnicodeString::new("Adobe Photoshop CS6"), true))),
("bytes after PhotoCraft's 1057", Some(trailing)),
];
for (what, info) in cases {
// Photoshop's polarity, as this version writes it, without 1053.
let mut file = crate::psd_export::document_to_psd(&doc);
file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS && r.id != ids::VERSION_INFO);
file.resources.extend(info.map(|d| ImageResource::new(ids::VERSION_INFO, d)));
let bytes = file.to_bytes().unwrap();
assert!(!earlier_photocraft_spot_planes(&PsdFile::from_bytes(&bytes).unwrap()), "{what}");
let opened = crate::import("x.psd", &bytes).unwrap().document;
assert_eq!(ink(&opened), ink(&doc), "{what}");
assert_eq!(spots(&opened), spots(&doc), "{what}");
}
}
/// The rule needs a spot in DisplayInfo and no 1053, besides PhotoCraft's 1057.
#[test]
fn the_earlier_photocraft_rule_needs_a_spot_and_no_alpha_identifiers() {
let mut doc = channels_document(SampleType::U8);
let old = PsdFile::from_bytes(&as_earlier_photocraft_saved(&doc, version_info_2026(true), false)).unwrap();
assert!(earlier_photocraft_spot_planes(&old));
let mut with_ids = old.clone();
with_ids.resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, 3)));
assert!(!earlier_photocraft_spot_planes(&with_ids), "a 1053");
let mut no_info = old.clone();
no_info.resources.retain(|r| r.id != crate::channel_map::DISPLAY_INFO);
assert!(!earlier_photocraft_spot_planes(&no_info), "no DisplayInfo");
doc.channels.retain(|c| c.spot.is_none());
let alpha_only = PsdFile::from_bytes(&as_earlier_photocraft_saved(&doc, version_info_2026(true), false)).unwrap();
assert!(!earlier_photocraft_spot_planes(&alpha_only), "alpha channels only");
}
/// `doc` as an earlier PhotoCraft saved a Photoshop file it had opened with the first `kept`
/// of `doc`'s channels: it wrote back the Photoshop file's 1053, `stale_ids`, unchanged and
/// those channels' planes as Photoshop stored them (it had read and written them straight),
/// and stored the channels added after them straight.
fn as_earlier_photocraft_resaved(doc: &Document, stale_ids: &[u32], kept: usize) -> Vec<u8> {
let mut file = crate::psd_export::document_to_psd(doc);
file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS && r.id != ids::VERSION_INFO);
file.resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, stale_ids.iter().flat_map(|id| id.to_be_bytes()).collect()));
file.resources.push(ImageResource::new(ids::VERSION_INFO, version_info_2026(true)));
let mut planes = file.decode_merged().unwrap();
let plane = planes.len() / usize::from(file.header.channels);
let first = usize::from(file.header.channels) - doc.channels.len();
for (k, c) in doc.channels.iter().enumerate().skip(kept) {
if c.spot.is_some() {
planes[(first + k) * plane..(first + k + 1) * plane].iter_mut().for_each(|b| *b = !*b);
}
}
file.image_data = ImageData::encode(Compression::Raw, &planes, &file.header).unwrap();
file.to_bytes().unwrap()
}
/// An earlier PhotoCraft kept the 1053 of a Photoshop file it opened and wrote it back
/// unchanged, so a spot channel added there sits behind a 1053 that doesn't list it: a
/// transparent Photoshop document's `[0]` (the white plate of a DTF print, added before
/// saving), the `[0, 3]` of one with a spot channel already, or a `[0]` kept through a
/// flatten that removed the transparency. The added spot is stored straight and keeps its
/// ink; the Photoshop file's own spot keeps Photoshop's polarity. Saved again, both reopen
/// the same.
#[test]
fn a_spot_added_behind_a_kept_alpha_identifiers_resource_keeps_its_ink() {
let transparent = |names: &[&str]| {
let mut doc = channels_document(SampleType::U8);
doc.layers = vec![Layer::raster("Layer 1", doc.pixel_format())];
doc.channels.retain(|c| names.contains(&c.name.as_str()));
doc
};
let mut flattened = channels_document(SampleType::U8);
flattened.channels.retain(|c| c.name == "Spot A");
let cases = [
("transparent, one spot added", transparent(&["Spot A"]), vec![0], 0),
("transparent, a Photoshop spot and one added", transparent(&["Spot A", "Spot B"]), vec![0, 3], 1),
("flattened, one spot added", flattened, vec![0], 0),
];
for (what, doc, stale_ids, kept) in cases {
let bytes = as_earlier_photocraft_resaved(&doc, &stale_ids, kept);
let file = PsdFile::from_bytes(&bytes).unwrap();
assert_eq!(file.merged_has_alpha(), what.starts_with("transparent"), "{what}");
assert!(earlier_photocraft_spot_planes(&file), "{what}");
let opened = crate::import("old.psd", &bytes).unwrap().document;
assert_eq!(ink(&opened), ink(&doc), "{what}: the ink earlier versions opened");
assert_eq!(spots(&opened), spots(&doc), "{what}");
let saved = crate::export(&opened, "old.psd", &crate::ExportOptions::default()).unwrap();
let again = crate::import("again.psd", &saved.bytes).unwrap().document;
assert_eq!(ink(&again), ink(&doc), "{what}: saved again");
}
}
/// A 1053 that lists every extra channel, as Photoshop and this version write it, keeps the
/// file in Photoshop's polarity, with PhotoCraft's (Photoshop 2026's) 1057 too: a transparent
/// file with a spot, and Photoshop's own `[0]` in a file without a transparency plane.
#[test]
fn a_complete_alpha_identifiers_resource_keeps_photoshop_polarity() {
let mut doc = channels_document(SampleType::U8);
doc.layers = vec![Layer::raster("Layer 1", doc.pixel_format())];
let bytes = as_earlier_photocraft_resaved(&doc, &[0, 3, 4, 5], 3);
let file = PsdFile::from_bytes(&bytes).unwrap();
assert!(file.merged_has_alpha());
assert!(!earlier_photocraft_spot_planes(&file));
assert_eq!(ink(&crate::import("ps.psd", &bytes).unwrap().document), ink(&doc));
let mut opaque = channels_document(SampleType::U8);
opaque.channels.clear();
let mut file = PsdFile::from_bytes(&as_earlier_photocraft_resaved(&opaque, &[0], 0)).unwrap();
file.resources.retain(|r| r.id != crate::channel_map::DISPLAY_INFO);
file.resources.push(ImageResource::new(
crate::channel_map::DISPLAY_INFO,
crate::channel_map::display_info(&channels_document(SampleType::U8).channels.iter().collect::<Vec<_>>(), false),
));
assert_eq!(photoshop_identified_channels(&file), 0, "0 is the transparency's");
assert!(!earlier_photocraft_spot_planes(&file), "no channel left unlisted");
}
}
+42 -15
View File
@@ -280,25 +280,52 @@ pub fn import_raw_with(name: &str, bytes: &[u8], opts: &DevelopOptions) -> Resul
let summary = format!("{DEVELOPED_NOTE} ({} demosaic, as-shot white balance)", opts.demosaic.id());
developed_document(name, bytes, format, dev, &summary)
}
Err(RawError::Unsupported(reason)) => match photocraft_raw::embedded_preview(bytes) {
Some(p) => {
let img = upright_preview(bytes, p.jpeg, &opts.limits)?;
let mut r = image_to_document(name, &img)?;
r.warnings.insert(
0,
format!(
"{format}: {reason} is not supported yet; opened the camera's embedded {}x{} JPEG preview instead (8-bit, not the raw sensor data)",
p.width, p.height
),
);
Ok(r)
}
None => Err(IoError::Raw(RawError::Unsupported(reason))),
},
Err(RawError::Unsupported(reason)) => {
let jpeg = photocraft_raw::embedded_preview(bytes);
// A DNG 1.7 converter keeps only its small thumbnail as JPEG and writes the larger
// previews as JPEG XL, like the raw data (#8125): open the largest of either kind.
let jpeg_area = jpeg.map_or(0, |p| u64::from(p.width) * u64::from(p.height));
let jxl = photocraft_raw::embedded_jxl_preview(bytes, &opts.limits).filter(|p| u64::from(p.width) * u64::from(p.height) > jpeg_area);
let (img, kind, (w, h)) = match (jxl, jpeg) {
(Some(p), _) => {
let size = (p.width, p.height);
(jxl_preview_image(bytes, p)?, "JPEG XL", size)
}
(None, Some(p)) => (upright_preview(bytes, p.jpeg, &opts.limits)?, "JPEG", (p.width, p.height)),
(None, None) => return Err(IoError::Raw(RawError::Unsupported(reason))),
};
let mut r = image_to_document(name, &img)?;
let depth = if img.sample_type() == SampleType::U8 { "8-bit" } else { "16-bit" };
r.warnings.insert(
0,
format!(
"{format}: {reason} is not supported yet; opened the camera's embedded {w}x{h} {kind} preview instead ({depth}, not the raw sensor data)"
),
);
Ok(r)
}
Err(e) => Err(IoError::Raw(e)),
}
}
/// A decoded JPEG XL preview as an image: in its stated colour space (sRGB when it states
/// none), turned upright by the raw's orientation (DNG previews are stored as the raw is).
fn jxl_preview_image(raw: &[u8], p: photocraft_raw::DecodedPreview) -> Result<Image, IoError> {
let img = if p.bits == 8 {
Image::from_u8(p.width, p.height, ChannelLayout::Rgb, p.rgb.iter().map(|&v| v.min(255) as u8).collect())?
} else {
let max = f64::from((1u32 << p.bits.clamp(1, 16)) - 1);
let rgb: Vec<u16> = p.rgb.iter().map(|&v| (f64::from(v).min(max) * 65535.0 / max).round() as u16).collect();
Image::from_u16(p.width, p.height, ChannelLayout::Rgb, &rgb)?
};
let icc = match p.color_space {
Some(PreviewColorSpace::AdobeRgb) => Some(Builtin::AdobeRgbCompat.profile().to_bytes().to_vec()),
Some(PreviewColorSpace::ProPhoto) => Some(Builtin::ProPhotoCompat.profile().to_bytes().to_vec()),
Some(PreviewColorSpace::Srgb) | None => None,
};
Ok(img.with_icc(icc).oriented(photocraft_raw::orientation(raw).unwrap_or(1))?)
}
#[cfg(test)]
mod tests {
use super::*;
+664 -17
View File
@@ -11,11 +11,13 @@
use std::sync::Arc;
use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType as CSample};
use photocraft_color::ColorMode;
use photocraft_doc::Document;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::{AlphaChannel, Document};
use photocraft_geom::Rect;
use photocraft_psd::resources::ids;
use photocraft_psd::tiff::{ByteOrder, ImageSourceData, resources_from_bytes, resources_to_bytes};
use photocraft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, ImageResource, ResolutionInfo};
use photocraft_raster::Surface;
use crate::flat::{csample, encode_image, image_to_document, layout_for, single_layer, try_buffer};
use crate::psd_export::PsdExportOptions;
@@ -101,13 +103,36 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result<I
CSample::F16 | CSample::F32 => 32,
};
let (w, h) = img.dimensions();
let channels = u16::try_from(img.layout().channels()).map_err(|_| IoError::Unsupported("too many channels".into()))?;
let mut data = planar_big_endian(&img, color_channels, mode == PsdMode::Cmyk)?;
let bps = img.sample_type().bytes();
let plane_len = (w as usize).saturating_mul(h as usize).saturating_mul(bps);
let mut added = 0usize;
for e in img.meta.extra_samples.iter().take(codecs::MAX_EXTRA_SAMPLES) {
match native_plane_to_be(&e.data, bps) {
Ok(be) if be.len() == plane_len => {
data.extend(be);
added += 1;
}
_ => warnings.push("an extra TIFF sample did not match the image and was dropped".into()),
}
}
let channels = u16::try_from(img.layout().channels().saturating_add(added)).map_err(|_| IoError::Unsupported("too many channels".into()))?;
let header = Header::new(isd.version, w, h, channels, depth, mode);
let data = planar_big_endian(&img, color_channels, mode == PsdMode::Cmyk)?;
let image_data = ImageData { compression: Compression::Raw, data };
let file = isd.into_psd(header, resources, image_data);
let mut file = isd.into_psd(header, resources, image_data);
// Empty `Patt` and the default filter mask are written so Photoshop's block list matches
// a file it saved. They carry nothing the document stores, so they are not kept: a
// round trip would otherwise grow `metadata.psd_global_blocks` on every open.
file.global_blocks.retain(|b| {
let empty_pattern = matches!(&b.key, b"Patt" | b"Pat2" | b"Pat3") && b.data.is_empty();
let default_filter_mask = &b.key == b"FMsk" && b.data.as_slice() == FILTER_MASK_DEFAULT;
!empty_pattern && !default_filter_mask
});
// The same nesting limit as opening a PSD (#2940).
crate::psd_import::check_group_depth(&file)?;
// Spot planes are read in Photoshop's polarity (0 = solid ink), as from a PSD. The importer's
// rule for PSDs from earlier PhotoCraft versions never applies: those versions stored no
// extra samples in a TIFF, and a TIFF with them carries 1053.
let (mut doc, w) = psd_to_document(&file);
warnings.extend(w);
doc.name = name.to_string();
@@ -200,19 +225,24 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr
}
let mut warnings = Vec::new();
let big = doc.size.width > 30_000 || doc.size.height > 30_000;
// The PSD exporter renders the composite we need for the TIFF itself: unmatted (the TIFF
// keeps straight alpha) and uncompressed (its planes are interleaved below).
// The PSD exporter renders the composite we need for the TIFF itself, uncompressed (its
// planes are interleaved below). A spot (or any user channel) is saved the way Photoshop's
// TIFF dialog does with "Save Transparency" off: the composite is colour plus those
// channels, matted on white, and each layer keeps its own transparency. With no user
// channel the composite stays unmatted, straight RGBA, which is what a plain transparent
// TIFF already round-trips as.
// Layer data is written PSD-style ("Adobe Photoshop Document Data Block", 4-byte lengths), as
// Photoshop writes it: Photoshop does not read the PSB-style header ("... Data V0002") in a
// TIFF and opens only the composite, as one locked Background. The exporter's size estimate
// counts layer planes uncompressed, so a document with many canvas-sized layers passes 2 GB
// while its real layer data is a small fraction of that; it is not used here. PSB-style data
// is written only past 30000 px, when asked for, or when a length really overflows 32 bits.
let user_channels = !doc.channels.is_empty() || doc.quick_mask.is_some();
let force_psb = opts.force_psb || big;
let psd_opts = PsdExportOptions {
force_psb,
size_limit_psb: false,
merged_matte: false,
merged_matte: user_channels,
merged_compression: Compression::Raw,
maximize_compatibility: true,
document_path: opts.document_path.clone(),
@@ -234,7 +264,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr
ctl.check().map_err(|_| IoError::Cancelled)?;
warnings.extend(w);
let order = if codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big };
let (layers, w) = match ImageSourceData::from_psd(&file).to_bytes(order) {
let (layers, w) = match tiff_image_source(doc, &file).to_bytes(order) {
Err(photocraft_psd::PsdError::LimitExceeded(_)) if !force_psb => {
let psb_opts = PsdExportOptions { force_psb: true, ..psd_opts };
let (psb, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psb_opts, ctl));
@@ -242,7 +272,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr
warnings.extend(w);
warnings.push("layer data exceeds 4 GB; written with PSB-sized lengths, which some readers do not open".into());
file = psb;
ImageSourceData::from_psd(&file).to_bytes(order)?
tiff_image_source(doc, &file).to_bytes(order)?
}
r => r?,
};
@@ -252,24 +282,60 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr
// The profile and XMP are written as TIFF tags, not as resources. EXIF stays a resource
// (1058), which the PSD importer reads back unchanged; a block rebuilt from the TIFF's own
// Exif directory keeps only the Exif, GPS and descriptive tags.
let resources: Vec<ImageResource> = file.resources.iter().filter(|r| !matches!(r.id, ids::ICC_PROFILE | ids::XMP)).cloned().collect();
let resources = resources_to_bytes(&resources)?;
let mut resources: Vec<ImageResource> = file.resources.iter().filter(|r| !matches!(r.id, ids::ICC_PROFILE | ids::XMP)).cloned().collect();
let fmt = doc.pixel_format();
let cc = fmt.mode.color_channels();
let has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha);
let channels = cc + usize::from(has_alpha);
let extra = usize::from(file.header.channels).saturating_sub(channels);
if extra > 0 {
warnings.push(format!("{extra} alpha channel(s) are not written to TIFF (one transparency channel only)"));
let extra_n = usize::from(file.header.channels).saturating_sub(channels);
let kept = extra_n.min(codecs::MAX_EXTRA_SAMPLES);
if extra_n > kept {
warnings.push(format!("{extra_n} extra channels; TIFF keeps the first {kept}"));
}
// With spot or alpha channels, the IFD image is colour + those channels. The merged
// transparency plane stays out of the IFD (see above); a file with no extra channels keeps
// it, so a plain transparent TIFF still opens with alpha.
let write_alpha = has_alpha && kept == 0;
// One identifier per extra sample actually stored. The PSD model's 1053 lists the merged
// transparency too, which the IFD leaves out next to extra samples, so it is not copied.
resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS);
if kept > 0 {
resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, kept)));
}
// Resource 1022 names a channel of the PSD merged image, which counts the transparency plane.
// The IFD below does not, so the id moves back by one.
if has_alpha
&& kept > 0
&& let Some(r) = resources.iter_mut().find(|r| r.id == crate::channel_map::QUICK_MASK_INFO)
&& r.data.len() >= 2
{
let id = u16::from_be_bytes([r.data[0], r.data[1]]).saturating_sub(1);
let be = id.to_be_bytes();
r.data[0] = be[0];
r.data[1] = be[1];
}
// The name and DisplayInfo resources list the merged transparency plane first. The IFD
// leaves that plane out, so the resources must too: Photoshop pairs each name with the
// next sample past the colour channels.
if has_alpha && !write_alpha {
drop_merged_transparency(&mut resources);
}
// Past MAX_EXTRA_SAMPLES the TIFF stores only the first `kept` channels: the name and
// DisplayInfo resources describe just those, and a Quick Mask among the rest is dropped.
if extra_n > kept {
keep_first_channels(&mut resources, kept, cc);
}
let resources = resources_to_bytes(&resources)?;
let ifd_channels = cc + usize::from(write_alpha);
let sample = csample(fmt.sample);
let (w, h) = (doc.size.width, doc.size.height);
let cmyk = fmt.mode == ColorMode::Cmyk;
let data = interleave(&file.image_data.data, w, h, channels, cc, sample, cmyk)?;
let data = interleave(&file.image_data.data, w, h, ifd_channels, cc, sample, cmyk)?;
let extras = native_extra_planes(&file.image_data.data, w, h, channels, kept, sample)?;
ctl.check().map_err(|_| IoError::Cancelled)?;
ctl.progress(TIFF_COMPOSITE_DONE);
let layout = layout_for(fmt.mode, has_alpha);
let layout = layout_for(fmt.mode, write_alpha);
let mut img = Image::from_raw(w, h, layout, sample, data)?;
img.icc = doc.icc_profile.as_ref().map(|i| i.to_vec());
img.meta = codecs::Metadata {
@@ -282,11 +348,180 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr
dpi: Some((doc.resolution_dpi, doc.resolution_dpi)),
photoshop_resources: Some(resources),
photoshop_layers: Some(layers),
extra_samples: extras,
..Default::default()
};
encode_image(&img, Format::Tiff, opts, warnings)
}
/// The layer data (`ImageSourceData`) of a layered TIFF saved from `file`, adjusted the way
/// Photoshop writes it.
fn tiff_image_source(doc: &Document, file: &photocraft_psd::PsdFile) -> ImageSourceData {
// Photoshop pairs alpha-name resources with samples past the colour channels and ignores
// the ExtraSamples kind. The merged transparency must not occupy that first slot when a
// spot is also stored. Layer transparency stays in each layer's channel −1.
let mut isd = ImageSourceData::from_psd(file);
let spot_extras = file.header.channels as usize > {
let cc = doc.pixel_format().mode.color_channels();
let alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha);
cc + usize::from(alpha)
};
// Positive layer count: the merged image's first extra sample is not transparency
// (Photoshop's own spot TIFF does this; a negative count would claim the spot plane is alpha).
if spot_extras && let Some(info) = &mut isd.layer_info {
info.merged_alpha = false;
}
// A merged-transparency marker kept from an opened file (Photoshop's `MTrn`) would make the
// first spot read back as transparency. The PSD export already drops it over an opaque
// composite; a transparent one keeps it there, but the TIFF leaves that sample out here.
if spot_extras {
isd.global_blocks.retain(|b| !b.is_merged_transparency_marker());
}
if let Some(info) = &mut isd.layer_info {
for layer in &mut info.layers {
// Photoshop sets flag bit 3 on every record it writes, group dividers included (299 of
// the 305 dividers in the corpus's Photoshop files). PhotoCraft's PSDs set it on
// layers only, unchanged here.
layer.flags.0 |= photocraft_psd::layer::LayerFlags::BIT4_USEFUL;
photoshop_layer_blocks(&mut layer.blocks);
}
}
// Photoshop reads a fixed 14-byte user-mask record from `LMsk` and an empty `Patt`
// before the filter mask. A zero-length `LMsk` is what makes it report that the
// layer data could not be read.
if let Some(mask) = &mut isd.global_layer_mask
&& mask.data.len() < 14
{
*mask = photocraft_psd::GlobalLayerMask::photoshop_default();
}
if !isd.global_blocks.iter().any(|b| matches!(&b.key, b"Patt" | b"Pat2" | b"Pat3")) {
isd.global_blocks.insert(0, photocraft_psd::TaggedBlock::new(*b"Patt", Vec::new()));
}
if !isd.global_blocks.iter().any(|b| &b.key == b"FMsk") {
isd.global_blocks.push(photocraft_psd::TaggedBlock::new(*b"FMsk", FILTER_MASK_DEFAULT.to_vec()));
}
isd
}
/// Removes the leading transparency entry from the channel-name resources (1006, 1045) and
/// from DisplayInfo (1077). Each is left unchanged when it is too short to hold that entry.
fn drop_merged_transparency(resources: &mut [ImageResource]) {
for r in resources {
r.data = match r.id {
1006 => drop_first_pascal(&r.data),
1045 => drop_first_unicode(&r.data),
id if id == crate::channel_map::DISPLAY_INFO => drop_first_display_record(&r.data),
_ => continue,
};
}
}
fn drop_first_pascal(data: &[u8]) -> Vec<u8> {
let Some(&n) = data.first() else { return data.to_vec() };
let end = 1usize.saturating_add(usize::from(n));
data.get(end..).unwrap_or(data).to_vec()
}
fn drop_first_unicode(data: &[u8]) -> Vec<u8> {
let Some(nbe) = data.get(..4) else { return data.to_vec() };
let n = u32::from_be_bytes([nbe[0], nbe[1], nbe[2], nbe[3]]) as usize;
let end = 4usize.saturating_add(n.saturating_mul(2));
data.get(end..).unwrap_or(data).to_vec()
}
/// Trims the channel-name resources (1006, 1045) and DisplayInfo (1077) to their first `kept`
/// entries, and drops Quick Mask info (1022) when its channel is not one of the `kept` extra
/// samples, which start at sample `first_extra`. A resource too short for `kept` entries is
/// left as it is.
fn keep_first_channels(resources: &mut Vec<ImageResource>, kept: usize, first_extra: usize) {
for r in resources.iter_mut() {
r.data = match r.id {
1006 => first_pascal_names(&r.data, kept),
1045 => first_unicode_names(&r.data, kept),
id if id == crate::channel_map::DISPLAY_INFO => first_display_records(&r.data, kept),
_ => continue,
};
}
let end = first_extra.saturating_add(kept);
resources.retain(|r| r.id != crate::channel_map::QUICK_MASK_INFO || r.data.get(..2).is_none_or(|b| usize::from(u16::from_be_bytes([b[0], b[1]])) < end));
}
fn first_pascal_names(data: &[u8], n: usize) -> Vec<u8> {
let mut at = 0usize;
for _ in 0..n {
let Some(&len) = data.get(at) else { return data.to_vec() };
at = at.saturating_add(1).saturating_add(usize::from(len));
}
data.get(..at).unwrap_or(data).to_vec()
}
fn first_unicode_names(data: &[u8], n: usize) -> Vec<u8> {
let mut at = 0usize;
for _ in 0..n {
let Some(b) = data.get(at..at.saturating_add(4)) else { return data.to_vec() };
let len = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize;
at = at.saturating_add(4).saturating_add(len.saturating_mul(2));
}
data.get(..at).unwrap_or(data).to_vec()
}
/// Resource 1077: a 4-byte version, then 13-byte records. Keeps the first `n` records.
fn first_display_records(data: &[u8], n: usize) -> Vec<u8> {
let end = 4usize.saturating_add(n.saturating_mul(13));
data.get(..end).unwrap_or(data).to_vec()
}
/// Resource 1077: a 4-byte version, then 13-byte records. Drops the first record.
fn drop_first_display_record(data: &[u8]) -> Vec<u8> {
const RECORD: usize = 13;
if data.len() < 4 + RECORD {
return data.to_vec();
}
let mut out = Vec::with_capacity(data.len() - RECORD);
out.extend_from_slice(&data[..4]);
out.extend_from_slice(&data[4 + RECORD..]);
out
}
/// Filter-mask block (`FMsk`) Photoshop writes next to a layered TIFF: the same red 50%
/// overlay as [`photocraft_psd::GlobalLayerMask::photoshop_default`], without the kind byte.
const FILTER_MASK_DEFAULT: &[u8] = &[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50];
/// Blocks Photoshop writes on every layer of a layered TIFF, when the layer does not
/// already carry them. Defaults match a plain pixel layer (clipped-as-group on, nothing
/// locked, no label).
fn photoshop_layer_blocks(blocks: &mut Vec<photocraft_psd::TaggedBlock>) {
let has = |k: &[u8; 4]| blocks.iter().any(|b| &b.key == k);
let mut add = Vec::new();
if !has(b"lnsr") {
add.push(photocraft_psd::TaggedBlock::name_source(*b"layr"));
}
if !has(b"clbl") {
add.push(photocraft_psd::TaggedBlock::blend_clipped_as_group(true));
}
if !has(b"infx") {
add.push(photocraft_psd::TaggedBlock::blend_interior_elements(false));
}
if !has(b"knko") {
add.push(photocraft_psd::TaggedBlock::knockout(0));
}
if !has(b"lspf") {
add.push(photocraft_psd::TaggedBlock::protection(0));
}
if !has(b"lclr") {
add.push(photocraft_psd::TaggedBlock::sheet_color(0));
}
let at = blocks
.iter()
.position(|b| &b.key == b"lyid")
.map(|i| i + 1)
.or_else(|| blocks.iter().position(|b| &b.key == b"luni").map(|i| i + 1))
.unwrap_or(blocks.len());
for (i, block) in add.into_iter().enumerate() {
blocks.insert(at.saturating_add(i), block);
}
}
/// Removes the blocks a document kept verbatim from a little-endian TIFF because their layout
/// is unknown ([`photocraft_psd::TaggedBlock::is_foreign_order`]) before `file` is written as a
/// PSD: their bytes are in the wrong order for a PSD and cannot be converted. Returns a warning
@@ -316,6 +551,418 @@ pub fn strip_foreign_order_blocks(file: &mut photocraft_psd::PsdFile) -> Vec<Str
warnings
}
/// Big-endian planar samples (a PSD merged-image plane) to native endian, or the reverse.
fn swap_plane(src: &[u8], bps: usize, to_be: bool) -> Result<Vec<u8>, IoError> {
if bps == 1 {
return Ok(src.to_vec());
}
if !matches!(bps, 2 | 4) || !src.len().is_multiple_of(bps) {
return Err(IoError::Unsupported("an extra channel plane is incomplete".into()));
}
let mut out = Vec::with_capacity(src.len());
for c in src.chunks_exact(bps) {
match (bps, to_be, c) {
(2, true, [a, b]) => {
let v = u16::from_ne_bytes([*a, *b]);
out.extend_from_slice(&v.to_be_bytes());
}
(2, false, [a, b]) => {
let v = u16::from_be_bytes([*a, *b]);
out.extend_from_slice(&v.to_ne_bytes());
}
(4, true, [a, b, c, d]) => {
let v = f32::from_ne_bytes([*a, *b, *c, *d]);
out.extend_from_slice(&v.to_be_bytes());
}
(4, false, [a, b, c, d]) => {
let v = f32::from_be_bytes([*a, *b, *c, *d]);
out.extend_from_slice(&v.to_ne_bytes());
}
_ => return Err(IoError::Unsupported("an extra channel plane is incomplete".into())),
}
}
Ok(out)
}
/// Native-endian extra-sample plane → big-endian, for the PSD merged image a layered TIFF rebuilds.
fn native_plane_to_be(src: &[u8], bps: usize) -> Result<Vec<u8>, IoError> {
swap_plane(src, bps, true)
}
/// Extra planes of a PSD merged image (`skip` colour-and-alpha planes already consumed), as
/// native-endian TIFF extra samples. A spot plane is already Photoshop ink (0 = solid, max =
/// paper); an alpha plane is already straight. Neither is inverted again here.
fn native_extra_planes(planes: &[u8], w: u32, h: u32, skip: usize, count: usize, sample: CSample) -> Result<Vec<codecs::ExtraSample>, IoError> {
let bps = sample.bytes();
let n = (w as usize).checked_mul(h as usize).ok_or_else(|| IoError::Unsupported("image size overflows".into()))?;
let plane = n.checked_mul(bps).ok_or_else(|| IoError::Unsupported("image size overflows".into()))?;
let mut out = Vec::with_capacity(count);
for i in 0..count {
let at = skip.checked_add(i).and_then(|k| k.checked_mul(plane)).ok_or_else(|| IoError::Unsupported("image size overflows".into()))?;
let Some(src) = planes.get(at..at.saturating_add(plane)) else {
return Err(IoError::Unsupported("the merged image is missing an extra channel".into()));
};
if src.len() != plane {
return Err(IoError::Unsupported("the merged image is missing an extra channel".into()));
}
out.push(codecs::ExtraSample { kind: 0, data: swap_plane(src, bps, false)? });
}
Ok(out)
}
/// One grayscale channel as native-endian samples in `img`'s sample type.
fn channel_plane(channel: &AlphaChannel, canvas: Rect, sample: CSample) -> Result<Vec<u8>, String> {
let depth = match sample {
CSample::U8 => SampleType::U8,
CSample::U16 => SampleType::U16,
CSample::F16 | CSample::F32 => SampleType::F32,
};
let fmt = PixelFormat::new(ColorMode::Grayscale, depth, false);
let owned;
let surface = if channel.surface.format() == fmt {
&channel.surface
} else {
owned = channel.surface.convert(fmt);
&owned
};
let mut bytes = surface.to_interleaved(canvas);
let n = (canvas.width() as usize).saturating_mul(canvas.height() as usize);
let expect = n.saturating_mul(depth.bytes());
if bytes.len() != expect {
return Err(format!("channel \"{}\" does not cover the canvas", channel.name));
}
if channel.spot.is_some() {
crate::channel_map::photoshop_spot_plane(&mut bytes, depth);
}
if sample == CSample::F16 {
let mut out = Vec::with_capacity(n.saturating_mul(2));
for arr in bytes.as_chunks::<4>().0 {
let v = f32::from_ne_bytes(*arr);
out.extend_from_slice(&codecs::f16::from_f32(v).to_ne_bytes());
}
return Ok(out);
}
Ok(bytes)
}
/// Composites straight alpha over white and drops it. Photoshop's TIFF save with
/// "Save Transparency" off stores that matte in the colour samples and keeps transparency
/// only inside each layer. Fully opaque pixels are copied unchanged, so they stay identical
/// to a composite that was quantized before the matte. `false` when there is no alpha or
/// the buffer is not one sample per channel.
fn matte_and_drop_alpha(img: &mut Image) -> bool {
if !img.layout().has_alpha() {
return false;
}
let (w, h) = img.dimensions();
let sample = img.sample_type();
let dst = img.layout().without_alpha();
let bps = sample.bytes();
let keep = dst.channels().saturating_mul(bps);
let stride = img.layout().channels().saturating_mul(bps);
if stride == 0 || keep == 0 || bps == 0 || stride <= keep || !img.data().len().is_multiple_of(stride) {
return false;
}
let cmyk = img.layout().is_cmyk();
let white_byte: u8 = if cmyk { 0 } else { 255 };
let white_u16: u16 = if cmyk { 0 } else { u16::MAX };
let white_f: f32 = if cmyk { 0.0 } else { 1.0 };
// Lab's white is L* 100 with a* and b* 0, which PhotoCraft holds mid-range (`(0 + 128) / 255`).
let lab = img.layout().is_lab();
let mut out = Vec::with_capacity(img.data().len() / stride * keep);
for px in img.data().chunks_exact(stride) {
let Some(color) = px.get(..keep) else { return false };
let Some(alpha) = px.get(keep..keep.saturating_add(bps)) else { return false };
let ok = if lab {
let (Some(l), Some(ab)) = (color.get(..bps), color.get(bps..)) else { return false };
matte_pixel(sample, l, alpha, white_byte, white_u16, white_f, &mut out) && matte_pixel(sample, ab, alpha, 128, 128 * 257, 128.0 / 255.0, &mut out)
} else {
matte_pixel(sample, color, alpha, white_byte, white_u16, white_f, &mut out)
};
if !ok {
return false;
}
}
let icc = img.icc.clone();
let meta = img.meta.clone();
let warnings = img.warnings.clone();
let Ok(mut next) = Image::from_raw(w, h, dst, sample, out) else { return false };
next.icc = icc;
next.meta = meta;
next.warnings = warnings;
*img = next;
true
}
/// One pixel of [`matte_and_drop_alpha`]. Opaque samples are copied; a clear pixel becomes
/// `white`; anything in between is `c·a + white·(1−a)` in normalized space.
fn matte_pixel(sample: CSample, color: &[u8], alpha: &[u8], white_byte: u8, white_u16: u16, white_f: f32, out: &mut Vec<u8>) -> bool {
match sample {
CSample::U8 => {
let Some(&a) = alpha.first() else { return false };
if a == 255 {
out.extend_from_slice(color);
} else if a == 0 {
out.resize(out.len().saturating_add(color.len()), white_byte);
} else {
let af = f32::from(a) / 255.0;
for &c in color {
let m = crate::pixels::matte(f32::from(c) / 255.0, af, white_f);
out.push((m.clamp(0.0, 1.0) * 255.0).round() as u8);
}
}
}
CSample::U16 => {
let Some(a) = ne_u16(alpha) else { return false };
if a == u16::MAX {
out.extend_from_slice(color);
} else if a == 0 {
for _ in 0..color.len() / 2 {
out.extend_from_slice(&white_u16.to_ne_bytes());
}
} else {
let af = f32::from(a) / 65535.0;
for pair in color.as_chunks::<2>().0 {
let c = u16::from_ne_bytes(*pair);
let m = crate::pixels::matte(f32::from(c) / 65535.0, af, white_f);
out.extend_from_slice(&((m.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes());
}
}
}
CSample::F32 => {
let Some(a) = ne_f32(alpha) else { return false };
if a == 1.0 {
out.extend_from_slice(color);
} else if a == 0.0 || !a.is_finite() {
for _ in 0..color.len() / 4 {
out.extend_from_slice(&white_f.to_ne_bytes());
}
} else {
let af = a.clamp(0.0, 1.0);
for quad in color.as_chunks::<4>().0 {
let m = crate::pixels::matte(f32::from_ne_bytes(*quad), af, white_f);
out.extend_from_slice(&m.to_ne_bytes());
}
}
}
CSample::F16 => {
let Some(bits) = ne_u16(alpha) else { return false };
let a = codecs::f16::from_bits(bits).to_f32();
if a == 1.0 {
out.extend_from_slice(color);
} else if a == 0.0 || !a.is_finite() {
let w = codecs::f16::from_f32(white_f).to_bits().to_ne_bytes();
for _ in 0..color.len() / 2 {
out.extend_from_slice(&w);
}
} else {
let af = a.clamp(0.0, 1.0);
for pair in color.as_chunks::<2>().0 {
let c = codecs::f16::from_bits(u16::from_ne_bytes(*pair)).to_f32();
let m = codecs::f16::from_f32(crate::pixels::matte(c, af, white_f)).to_bits();
out.extend_from_slice(&m.to_ne_bytes());
}
}
}
}
true
}
fn ne_u16(b: &[u8]) -> Option<u16> {
let arr: [u8; 2] = b.get(..2)?.try_into().ok()?;
Some(u16::from_ne_bytes(arr))
}
fn ne_f32(b: &[u8]) -> Option<f32> {
let arr: [u8; 4] = b.get(..4)?.try_into().ok()?;
Some(f32::from_ne_bytes(arr))
}
/// Writes alpha and spot channels (and a Quick Mask) into a flat TIFF: extra samples in the
/// IFD, plus Photoshop resources for the name, ink colour and solidity. At most
/// [`codecs::MAX_EXTRA_SAMPLES`] are kept. A TIFF with no extra channels is left unchanged.
/// When any are written, the composite's own transparency is left out of the IFD: readers pair
/// the channel resources with every sample past the colour channels, in order.
pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image) -> Vec<String> {
let total = doc.channels.len().saturating_add(usize::from(doc.quick_mask.is_some()));
if total == 0 {
return Vec::new();
}
let mut warnings = Vec::new();
let (w, h) = img.dimensions();
if w != doc.size.width || h != doc.size.height {
warnings.push("extra channels were not written: the image size differs from the document".into());
return warnings;
}
let max = codecs::MAX_EXTRA_SAMPLES;
let mut picked: Vec<&AlphaChannel> = doc.channels.iter().take(max).collect();
if let Some(q) = &doc.quick_mask
&& picked.len() < max
{
picked.push(q);
}
if total > picked.len() {
warnings.push(format!("{total} extra channels; TIFF keeps the first {}", picked.len()));
}
let canvas = Rect::new(0, 0, w as i32, h as i32);
let mut planes = Vec::with_capacity(picked.len());
for ch in &picked {
match channel_plane(ch, canvas, img.sample_type()) {
Ok(data) => planes.push(data),
Err(msg) => {
warnings.push(msg);
break;
}
}
}
picked.truncate(planes.len());
if picked.is_empty() {
return warnings;
}
let names: Vec<&str> = picked.iter().map(|c| c.name.as_str()).collect();
let quick_last = matches!((picked.last(), doc.quick_mask.as_ref()), (Some(p), Some(q)) if std::ptr::eq(*p, q));
let color_channels = img.layout().color_channels();
// Save Transparency off: the colour samples are the composite over white, and the
// alpha plane is not an extra sample (it would be named as the first spot). The
// composite transparency is gone after this; say so, because the user did not pick
// that option themselves.
if img.layout().has_alpha() {
if !matte_and_drop_alpha(img) {
warnings.push("extra channels were not written: the composite transparency could not be separated from them".into());
return warnings;
}
warnings.push(
"transparency composited over white; a TIFF with alpha or spot channels stores them as extra samples and drops image transparency (Save Transparency off)"
.into(),
);
}
let quick_id = if quick_last { u16::try_from(color_channels.saturating_add(picked.len()).saturating_sub(1)).ok() } else { None };
match channel_resources(img.meta.photoshop_resources.as_deref(), &names, &picked, quick_id) {
Ok(bytes) => img.meta.photoshop_resources = Some(bytes),
Err(e) => warnings.push(format!("spot channel options were not written to the TIFF: {e}")),
}
img.meta.extra_samples = planes.into_iter().map(|data| codecs::ExtraSample { kind: 0, data }).collect();
warnings
}
/// Existing Photoshop resources with the channel-name, display-info and quick-mask blocks replaced.
fn channel_resources(existing: Option<&[u8]>, names: &[&str], channels: &[&AlphaChannel], quick_id: Option<u16>) -> Result<Vec<u8>, IoError> {
let mut resources = match existing.filter(|b| !b.is_empty()) {
Some(b) => resources_from_bytes(b)?.0,
None => Vec::new(),
};
resources.retain(|r| {
!matches!(r.id, 1006 | 1045 | crate::channel_map::DISPLAY_INFO | crate::channel_map::QUICK_MASK_INFO | crate::channel_map::ALPHA_IDENTIFIERS)
});
resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(names)));
resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(names)));
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(channels, false)));
resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, names.len())));
if let Some(id) = quick_id {
let mut data = id.to_be_bytes().to_vec();
data.push(0);
resources.push(ImageResource::new(crate::channel_map::QUICK_MASK_INFO, data));
}
Ok(resources_to_bytes(&resources)?)
}
/// Extra samples of a flat TIFF become alpha / spot channels. Layered TIFFs rebuild those
/// through the PSD importer instead, so this is only for a file with no layer tag.
pub(crate) fn channels_from_extras(doc: &mut Document, img: &Image) -> Vec<String> {
if img.meta.extra_samples.is_empty() {
return Vec::new();
}
let mut warnings = Vec::new();
let (w, h) = img.dimensions();
if w != doc.size.width || h != doc.size.height {
warnings.push("extra TIFF samples were dropped: their size differs from the image".into());
return warnings;
}
let parsed = img.meta.photoshop_resources.as_deref().filter(|b| !b.is_empty()).and_then(|b| resources_from_bytes(b).ok());
let none = Vec::new();
let resources = parsed.as_ref().map(|(r, _)| r.as_slice()).unwrap_or(&none);
let all_names = resources.iter().find(|r| r.id == 1045).map(|r| crate::psd_import::unicode_names(&r.data)).unwrap_or_default();
// As in the PSD importer: Photoshop lists the image transparency first ("Transparency", with
// its own display record), ahead of the channels the extra samples hold.
let skip = usize::from(img.layout().has_alpha() && all_names.len() == img.meta.extra_samples.len().saturating_add(1));
let names = all_names.get(skip..).unwrap_or_default();
let spots = if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO) {
crate::channel_map::spot_flags(&r.data, true, skip)
} else if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO_OLD) {
crate::channel_map::spot_flags(&r.data, false, skip)
} else {
Vec::new()
};
let start = doc.channels.len();
let n_px = (w as usize).saturating_mul(h as usize);
for (k, e) in img.meta.extra_samples.iter().take(codecs::MAX_EXTRA_SAMPLES).enumerate() {
let spot = spots.get(k).copied().unwrap_or(false);
match mask_from_plane(&e.data, w, h, n_px, img.sample_type(), doc.depth, spot) {
Ok(surface) => {
let name = names.get(k).cloned().filter(|s| !s.is_empty()).unwrap_or_else(|| format!("Alpha {}", k + 1));
doc.channels.push(AlphaChannel::new(name, surface));
}
Err(msg) => warnings.push(msg),
}
}
if doc.channels.len() == start {
return warnings;
}
{
let slice = &mut doc.channels[start..];
if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO) {
crate::channel_map::apply_display_info(&r.data, true, skip, slice);
} else if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO_OLD) {
crate::channel_map::apply_display_info(&r.data, false, skip, slice);
}
}
let first = img.layout().color_channels().saturating_add(usize::from(img.layout().has_alpha()));
if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::QUICK_MASK_INFO)
&& r.data.len() >= 2
&& let Some(k) = usize::from(u16::from_be_bytes([r.data[0], r.data[1]])).checked_sub(first)
&& start.saturating_add(k) < doc.channels.len()
{
doc.quick_mask = Some(doc.channels.remove(start + k));
}
warnings
}
/// Native-endian extra-sample bytes → a grayscale surface at the document depth.
/// `photoshop_spot`: the sample is a spot channel in Photoshop's polarity (0 = solid ink).
fn mask_from_plane(data: &[u8], w: u32, h: u32, n: usize, from: CSample, depth: SampleType, photoshop_spot: bool) -> Result<Surface, String> {
let rect = Rect::new(0, 0, w as i32, h as i32);
let mut bytes = match (from, depth) {
(CSample::U8, SampleType::U8) | (CSample::U16, SampleType::U16) | (CSample::F32, SampleType::F32) => {
if data.len() != n.saturating_mul(from.bytes()) {
return Err("an extra TIFF sample did not match the image and was dropped".into());
}
data.to_vec()
}
(CSample::F16, SampleType::F32) => {
if data.len() != n.saturating_mul(2) {
return Err("an extra TIFF sample did not match the image and was dropped".into());
}
let mut out = Vec::with_capacity(n.saturating_mul(4));
for arr in data.as_chunks::<2>().0 {
let v = codecs::f16::from_bits(u16::from_ne_bytes(*arr)).to_f32();
out.extend_from_slice(&v.to_ne_bytes());
}
out
}
_ => return Err(format!("an extra TIFF sample ({from:?}) does not match the document depth and was dropped")),
};
if photoshop_spot {
crate::channel_map::photoshop_spot_plane(&mut bytes, depth);
}
if bytes.len() != n.saturating_mul(depth.bytes()) {
return Err("an extra TIFF sample did not match the image and was dropped".into());
}
let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false));
s.write_interleaved(rect, &bytes);
s.prune();
Ok(s)
}
#[cfg(test)]
mod tests {
use photocraft_doc::{Color, Document, Fill, FillCache, Layer, LayerContent};
+1 -1
View File
@@ -154,7 +154,7 @@ const MIXED: Source = Source { label: "io corpus", dir: "corpus/psd", group_dept
/// 226 vs the merged image + 10 vs the thumbnail (Advanced Blending knockouts: +7).
const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-tools", group_depth: 0, pass_floor: 249, roundtrip_floor: 308 };
/// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus),
/// Our Photoshop-authored oracles (258 files; https://github.com/storytold/photocraft-corpus),
/// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/<mode><bits>`).
const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 190, roundtrip_floor: 258 };
+36
View File
@@ -1191,3 +1191,39 @@ fn a_stereo_png_keeps_its_stereo_mark_on_save() {
let plain = export(&single(ColorMode::Rgb, SampleType::U8, false), "png", &ExportOptions::default()).unwrap();
assert_eq!(photocraft_codecs::png_stereo(&plain.bytes), None);
}
/// #8563: a DDS export writes the document's alpha channel as the texture's alpha, as a 32-bit
/// Targa does: exactly in an uncompressed texture, to 4 bits in BC2, interpolated in BC3, and on
/// or off in BC1. With several alpha channels none is written, with a warning.
#[test]
fn dds_writes_the_alpha_channel_as_the_texture_alpha() {
use photocraft_codecs::DdsCompression;
let d = with_alpha_channel(single(ColorMode::Rgb, SampleType::U8, false), "Alpha 1");
for compression in DdsCompression::ALL {
let mut opts = ExportOptions::default();
opts.encode.dds.compression = compression;
let r = export(&d, "a.dds", &opts).unwrap();
let img = photocraft_codecs::decode(&r.bytes).expect("decode dds");
assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Rgba, "{compression:?}");
let (w, _) = img.dimensions();
let tolerance = match compression {
DdsCompression::None => 1.0,
DdsCompression::Bc2 => 9.0,
_ => 18.0,
};
for (i, px) in img.data().as_chunks::<4>().0.iter().enumerate() {
let x = i as u32 % w;
let want = alpha_value(x) * 255.0;
if compression == DdsCompression::Bc1 && x == 4 {
continue; // half opacity sits on BC1's on/off threshold
}
assert!((f32::from(px[3]) - want).abs() <= tolerance, "{compression:?} pixel {i}: alpha {} want {want}", px[3]);
}
}
// Two alpha channels: Photoshop's rule for Targa, none of them is written.
let two = with_alpha_channel(d.clone(), "Alpha 2");
let r = export(&two, "a.dds", &ExportOptions::default()).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("several")), "{:?}", r.warnings);
let img = photocraft_codecs::decode(&r.bytes).expect("decode dds");
assert!(!img.layout().has_alpha() || img.data().as_chunks::<4>().0.iter().all(|p| p[3] == 255));
}
@@ -43,7 +43,7 @@ fn resave_keeps_a_name_marked_merged_transparency() {
// transparency named in the alpha channel names.
let mut ps = ours.clone();
ps.layer_info.as_mut().expect("layers").merged_alpha = false;
ps.global_blocks.retain(|b| !matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32"));
ps.global_blocks.retain(|b| !b.is_merged_transparency_marker());
assert!(!ps.merged_has_alpha(), "{case}: nothing marks it yet");
let name: Vec<u8> = [0u8, 0, 0, 13].into_iter().chain("Transparency\0".encode_utf16().flat_map(u16::to_be_bytes)).collect();
ps.resources.retain(|r| !matches!(r.id, 1006 | 1045));
+95
View File
@@ -22,6 +22,28 @@ fn dng_opens_as_16_bit_prophoto() {
assert!(r.warnings.iter().any(|w| w.contains("DNG") && w.contains("ProPhoto")), "{:?}", r.warnings);
}
/// #8125: a lossy DNG's floating-point LinearRaw data, JPEG XL-compressed, is developed (not
/// replaced by a preview). The test codestream is an integer one the reader takes as floats.
#[test]
fn floating_point_jpeg_xl_dng_is_developed() {
use photocraft_raw::testgen::DngStorage;
use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions};
let (w, h) = (24, 16);
let data: Vec<u16> = scene(w, h).iter().flat_map(|p| p.map(|v| (v * 20000.0) as u16)).collect();
let bytes: Vec<u8> = data.iter().flat_map(|v| v.to_ne_bytes()).collect();
let mut segment = Vec::new();
zune_jpegxl::JxlSimpleEncoder::new(&bytes, EncoderOptions::new(w, h, ColorSpace::RGB, BitDepth::Sixteen)).encode(&mut segment).unwrap();
let mut spec = DngSpec::cfa(w, h, data);
spec.samples = 3;
spec.white = 0;
spec.storage = DngStorage::JxlStrips { rows: h };
spec.jxl_segments = vec![segment];
spec.raw_extra = vec![(339, Val::Short(vec![3; 3]))];
let r = import("lossy.dng", &spec.build()).unwrap();
assert_eq!((r.document.size.width, r.document.size.height, r.document.depth), (24, 16, SampleType::U16));
assert!(r.warnings.iter().all(|w| !w.contains("preview")), "{:?}", r.warnings);
}
/// #3497: a monochrome (one-sample LinearRaw) DNG, as VueScan writes film scans, opens as a
/// 16-bit RGB document (neutral grey) instead of failing as unsupported.
#[test]
@@ -194,6 +216,79 @@ fn unsupported_raw_falls_back_to_the_embedded_preview() {
assert!(r.warnings.first().is_some_and(|w| w.contains("Nikon compressed NEF") && w.contains("embedded")), "{:?}", r.warnings);
}
/// A DNG 1.7 laid out as converters write one with JPEG XL raw data: an 8×6 JPEG thumbnail in
/// IFD0, raw data this reader refuses (here floating-point LinearRaw), and a 40×30 preview
/// compressed with JPEG XL, stored as the raw is (IFD0 Orientation 6).
fn dng_with_jxl_preview() -> Vec<u8> {
use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions};
let thumb = Image::from_u8(8, 6, ChannelLayout::Rgb, vec![90; 8 * 6 * 3]).unwrap();
let thumb = photocraft_codecs::encode(&thumb, Format::Jpeg, &EncodeOptions::default()).unwrap();
let (w, h) = (40usize, 30usize);
let rgb: Vec<u8> = (0..w * h).flat_map(|i| [(i % w * 6) as u8, (i / w * 8) as u8, 120]).collect();
let mut preview = Vec::new();
zune_jpegxl::JxlSimpleEncoder::new(&rgb, EncoderOptions::new(w, h, ColorSpace::RGB, BitDepth::Eight)).encode(&mut preview).unwrap();
let mut t = TiffBuilder::default();
let raw_strip = t.blob(vec![0; 4 * 4 * 3 * 2]);
let raw = t.ifd(vec![
(254, Val::Long(vec![0])),
(256, Val::Long(vec![4])),
(257, Val::Long(vec![4])),
(258, Val::Short(vec![16, 16, 16])),
(259, Val::Short(vec![1])),
(262, Val::Short(vec![34892])),
(273, Val::Blobs(vec![raw_strip])),
(277, Val::Short(vec![3])),
(278, Val::Long(vec![4])),
(279, Val::Long(vec![4 * 4 * 3 * 2])),
(339, Val::Short(vec![3, 3, 3])),
]);
let preview_len = preview.len() as u32;
let preview_strip = t.blob(preview);
let preview = t.ifd(vec![
(254, Val::Long(vec![1])),
(256, Val::Long(vec![w as u32])),
(257, Val::Long(vec![h as u32])),
(258, Val::Short(vec![8, 8, 8])),
(259, Val::Short(vec![52546])),
(262, Val::Short(vec![2])),
(273, Val::Blobs(vec![preview_strip])),
(277, Val::Short(vec![3])),
(278, Val::Long(vec![h as u32])),
(279, Val::Long(vec![preview_len])),
(50970, Val::Long(vec![2])),
]);
let thumb_len = thumb.len() as u32;
let thumb_strip = t.blob(thumb);
let ifd0 = t.ifd(vec![
(254, Val::Long(vec![1])),
(256, Val::Long(vec![8])),
(257, Val::Long(vec![6])),
(258, Val::Short(vec![8, 8, 8])),
(259, Val::Short(vec![7])),
(262, Val::Short(vec![6])),
(271, Val::Ascii("Synthetic".into())),
(273, Val::Blobs(vec![thumb_strip])),
(274, Val::Short(vec![6])),
(277, Val::Short(vec![3])),
(278, Val::Long(vec![6])),
(279, Val::Long(vec![thumb_len])),
(330, Val::Ifds(vec![raw, preview])),
(50706, Val::Byte(vec![1, 7, 0, 0])),
]);
t.chain = vec![ifd0];
t.build()
}
/// #8125: a DNG whose raw data isn't supported opens its full JPEG XL preview, upright, not
/// the small JPEG thumbnail.
#[test]
fn unsupported_dng_falls_back_to_its_jpeg_xl_preview() {
let r = import("photo.dng", &dng_with_jxl_preview()).unwrap();
assert_eq!((r.document.size.width, r.document.size.height), (30, 40), "the 40×30 preview, turned upright: {:?}", r.warnings);
let w = r.warnings.first().map_or("", String::as_str);
assert!(w.contains("floating-point") && w.contains("40x30 JPEG XL preview"), "{:?}", r.warnings);
}
#[test]
fn unsupported_raw_without_preview_is_a_clear_error() {
let mut cr3 = vec![0, 0, 0, 24];
+101
View File
@@ -169,6 +169,107 @@ fn corpus_photoshop_txt2_survives_an_unedited_save() {
println!("Txt2 kept byte-for-byte in {checked} Photoshop type files");
}
/// #7962: Photoshop's type on a path (`Txt2` frames, EngineData calls it a text box) opens on its
/// path, at the anchors the generator gave Photoshop; the circle re-renders where Photoshop drew
/// it; and a save after an edit (new text, a moved path) reopens on the layer's path.
#[cfg(feature = "corpus")]
#[test]
fn corpus_photoshop_path_text_opens_on_its_path() {
use photocraft_doc::text::TextShape;
use photocraft_geom::Point;
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/text");
let k = 0.5522847498 * 96.0;
let cases: [(&str, bool, Vec<[f64; 2]>); 2] = [
// Photoshop sets the type from the closed path's last anchor (the bottom).
("path-circle.psd", true, vec![[128.0, 224.0], [32.0, 128.0], [128.0, 32.0], [224.0, 128.0]]),
("path-wave-open.psd", false, vec![[16.0, 100.0], [160.0, 80.0], [304.0, 60.0]]),
];
let mut engine = photocraft_text::TextEngine::with_system_fonts();
for (file, closed, anchors) in cases {
let f = root.join(file);
let bytes = std::fs::read(&f).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --photoshop`", f.display()));
let imp = photocraft_io::import(file, &bytes).unwrap();
assert!(!imp.warnings.iter().any(|w| w.contains("type path")), "{file}: {:?}", imp.warnings);
let doc = imp.document;
let mut all = Vec::new();
walk(&doc.layers, &mut all);
let layer = all.iter().find(|l| matches!(l.content, LayerContent::Text(_))).unwrap();
let LayerContent::Text(t) = &layer.content else { unreachable!() };
let doc_anchors = |t: &photocraft_doc::TextLayer| -> Vec<Point> {
let TextShape::Path { path, start, flip } = &t.shape else { panic!("{file}: not path text: {:?}", t.shape) };
assert_eq!((path.closed, *start, *flip), (closed, 0.0, false), "{file}");
path.knots.iter().map(|k| t.transform.apply(k.anchor)).collect()
};
let got = doc_anchors(t);
assert_eq!(got.len(), anchors.len(), "{file}");
for (g, [x, y]) in got.iter().zip(&anchors) {
assert!((g.x - x).abs() < 1e-3 && (g.y - y).abs() < 1e-3, "{file}: anchor {g:?}, want ({x}, {y})");
}
if closed {
// The circle's handles are the generator's (kappa × r), so the path is the circle.
let TextShape::Path { path, .. } = &t.shape else { unreachable!() };
let h = t.transform.apply(path.knots[0].out_ctrl);
assert!((h.x - (128.0 - k)).abs() < 1e-3 && (h.y - 224.0).abs() < 1e-3, "{file}: handle {h:?}");
}
// Photoshop's pixels (the wave's EngineData text is blank: Photoshop drew nothing).
if let Some(ps) = t.cache.as_ref().filter(|c| !c.content_bounds().is_empty()) {
let ps_rect = ps.content_bounds();
let (_, ours) = engine.render(t, doc.resolution_dpi, doc.pixel_format());
let our_rect = ours.surface.content_bounds();
let u = Rect::new(ps_rect.x0.min(our_rect.x0), ps_rect.y0.min(our_rect.y0), ps_rect.x1.max(our_rect.x1), ps_rect.y1.max(our_rect.y1));
let (a, b) = (alpha(ps, u), alpha(&ours.surface, u));
let inter: f32 = a.iter().zip(&b).map(|(x, y)| x.min(*y)).sum();
let union: f32 = a.iter().zip(&b).map(|(x, y)| x.max(*y)).sum();
let iou = if union > 0.0 { inter / union } else { 1.0 };
let edges = [ps_rect.x0 - our_rect.x0, ps_rect.y0 - our_rect.y0, ps_rect.x1 - our_rect.x1, ps_rect.y1 - our_rect.y1];
println!("{file}: ps={ps_rect:?} ours={our_rect:?} iou={iou:.2} edges={edges:?}");
// Where the type starts on the path and how far it reaches across it hold with any
// font; the far end and the glyph overlap need the font Photoshop used (a wider
// fallback runs further around the circle).
let [x0, y0, _, y1] = edges;
assert!([x0, y0, y1].iter().all(|d| d.abs() <= 8), "{file}: ink bounds {our_rect:?}, Photoshop's {ps_rect:?}");
let family = t.runs.first().map(|r| r.style.font_family.clone()).unwrap_or_default();
if engine.fonts.has_family(&family) {
assert!(edges.iter().all(|d| d.abs() <= 8), "{file}: ink bounds {our_rect:?}, Photoshop's {ps_rect:?}");
assert!(iou > 0.2, "{file}: the glyphs sit where Photoshop drew them (IoU {iou:.2})");
} else {
eprintln!("{file}: {family:?} not installed, far edge and overlap comparison skipped");
}
}
// Edited: new text and the path moved 10 px right; saved and opened again.
let mut edited = doc.clone();
let id = layer.id;
for l in edited.layers.iter_mut() {
if l.id != id {
continue;
}
let LayerContent::Text(t) = &mut l.content else { continue };
t.text = format!("{} again", t.text);
if let Some(r) = t.runs.last_mut() {
r.len += " again".len();
}
t.sync_summary();
if let TextShape::Path { path, .. } = &mut t.shape {
for k in &mut path.knots {
*k = k.transform(&photocraft_geom::Affine::translate(10.0, 0.0));
}
}
t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::build_tysh(t, edited.resolution_dpi, None)));
}
let out = photocraft_io::export(&edited, file, &Default::default()).unwrap().bytes;
let back = photocraft_io::import(file, &out).unwrap().document;
let mut all = Vec::new();
walk(&back.layers, &mut all);
let LayerContent::Text(bt) = &all.iter().find(|l| matches!(l.content, LayerContent::Text(_))).unwrap().content else { unreachable!() };
assert!(bt.text.ends_with(" again"), "{file}: {:?}", bt.text);
for (g, [x, y]) in doc_anchors(bt).iter().zip(&anchors) {
assert!((g.x - (x + 10.0)).abs() < 1e-3 && (g.y - y).abs() < 1e-3, "{file}: saved anchor {g:?}, want ({}, {y})", x + 10.0);
}
}
}
/// A text layer created in PhotoCraft (no PSD data) exports as an editable type layer and
/// imports back with the same model.
#[test]
+733 -11
View File
@@ -34,19 +34,18 @@ fn roundtrip(doc: &Document) -> Document {
// The TIFF is a plain image to any other reader: the composite at the document's size.
let flat = photocraft_codecs::decode(&r.bytes).expect("flat decode");
assert_eq!(flat.dimensions(), (doc.size.width, doc.size.height));
// A TIFF holds one transparency channel: extra alpha channels (and a Quick Mask) are not
// kept, and the export says so.
// Extra alpha channels and a Quick Mask travel as TIFF extra samples (at most MAX_EXTRA_SAMPLES).
let extra = doc.channels.len() + usize::from(doc.quick_mask.is_some());
assert_eq!(r.warnings.iter().any(|w| w.contains("alpha channel(s) are not written")), extra > 0, "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("not written")), "{:?}", r.warnings);
if extra > 0 && extra <= photocraft_codecs::MAX_EXTRA_SAMPLES {
assert_eq!(flat.meta.extra_samples.len(), extra, "{:?}", r.warnings);
}
import("x.tif", &r.bytes).expect("import").document
}
/// `doc` as a layered TIFF round trip returns it: without extra alpha channels or a Quick Mask.
/// `doc` as a layered TIFF round trip returns it, including extra channels.
fn expected(doc: &Document) -> Document {
let mut d = doc.clone();
d.channels.clear();
d.quick_mask = None;
d
doc.clone()
}
macro_rules! rt_case {
@@ -202,6 +201,47 @@ fn transparency_is_straight_in_the_composite() {
assert_docs_eq(&d, &back);
}
/// Photoshop (27.11) saves a layered TIFF with Save Transparency as a composite with a
/// transparency sample, a positive layer count and an `MTrn` block first in the layer data
/// (the specification's key is `Mtrn`; `nrTM` in an `II` file). That sample is the merged
/// transparency: it opens as no alpha channel, and a PSD save doesn't write it twice.
#[test]
fn photoshop_mtrn_marks_the_merged_transparency_of_a_layered_tiff() {
use photocraft_psd::{PsdFile, TaggedBlock};
for depth in [SampleType::U8, SampleType::U16] {
let mut d = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth);
let fmt = d.pixel_format();
d.layers.push(raster("shape", fmt, photocraft_geom::Rect::new(1, 1, 5, 4), 3, true));
let r = tiff(&d, true);
let mut img = photocraft_codecs::decode(&r.bytes).expect("decode");
assert!(img.layout().has_alpha(), "{depth:?}: the composite has a transparency sample");
let (isd, _) = ImageSourceData::from_bytes(&tags(&r.bytes).1.expect("layer data")).expect("parse");
for order in [ByteOrder::Little, ByteOrder::Big] {
let case = format!("{depth:?} {order:?}");
// Photoshop's form of the same layer data.
let mut ps = isd.clone();
ps.layer_info.as_mut().expect("layers").merged_alpha = false;
ps.global_blocks.insert(0, TaggedBlock::new(*b"MTrn", Vec::new()));
let layers = ps.to_bytes(order).expect("layer data").0;
if order == ByteOrder::Little {
assert!(layers.windows(12).any(|w| w == b"MIB8nrTM\0\0\0\0"), "{case}: stored as Photoshop stores it");
}
img.meta.photoshop_layers = Some(layers);
let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &photocraft_codecs::EncodeOptions::default()).expect("encode");
let opened = import("x.tif", &bytes).expect("import").document;
let names: Vec<&str> = opened.channels.iter().map(|c| c.name.as_str()).collect();
assert!(names.is_empty(), "{case}: no alpha channel from the merged transparency: {names:?}");
// The marker is kept for the next save.
let mut want = d.clone();
want.metadata.psd_global_blocks = vec![(*b"8BIM", *b"MTrn", std::sync::Arc::new(Vec::new()))];
assert_docs_eq(&want, &opened);
let psd = PsdFile::from_bytes(&export(&opened, "x.psd", &ExportOptions::default()).expect("PSD save").bytes).expect("parse");
assert_eq!(psd.header.channels, 4, "{case}: colour + merged transparency");
}
}
}
#[test]
fn big_documents_use_the_psb_signature() {
// Forcing PSB stands in for a > 30000 px document.
@@ -261,12 +301,11 @@ fn psdtags_fixtures_open_with_their_layers() {
// tifffile writes a Software tag; nothing else is worth a warning.
assert!(r.warnings.iter().all(|w| w.contains("text metadata")), "{name}: {:?}", r.warnings);
// psdtags wrote the composite's alpha sample without the transparency flag, so by the
// PSD rules it is an alpha channel (which a re-save cannot keep). Re-saving keeps
// everything else; the file is then in the encoder's byte order.
// PSD rules it is an alpha channel. A re-save keeps it as a TIFF extra sample.
assert_eq!(d.channels.len(), 1, "{name}");
let out = tiff(d, true);
let back = import(name, &out.bytes).unwrap().document;
assert_docs_eq(&expected(d), &back);
assert_docs_eq(d, &back);
}
}
@@ -362,6 +401,622 @@ fn damaged_layer_data_opens_flattened() {
}
}
/// Every sample of a TIFF, read by the `tiff` crate rather than PhotoCraft's decoder.
///
/// That decoder drops unspecified extra samples when the photometric interpretation is RGB, so
/// the file is retagged MinIsBlack first. The result is then a multiband image of every sample,
/// which is what libtiff's `TIFFReadScanline` returns and what a RIP walks.
fn independent_samples(bytes: &[u8]) -> (u16, Vec<u16>, Vec<u16>, u32, Vec<u8>) {
let mut tags = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).expect("tiff tags");
let spp: u16 = tags.get_tag_unsigned(tiff::tags::Tag::SamplesPerPixel).expect("spp");
let bps = tags.get_tag_u16_vec(tiff::tags::Tag::BitsPerSample).expect("bps");
let extra = tags.get_tag_u16_vec(tiff::tags::Tag::ExtraSamples).unwrap_or_default();
let rows = tags.find_tag_unsigned::<u32>(tiff::tags::Tag::RowsPerStrip).ok().flatten().unwrap_or(0);
let patched = photometric_min_is_black(bytes);
let mut dec = tiff::decoder::Decoder::new(std::io::Cursor::new(patched.as_slice())).expect("tiff decode");
let tiff::decoder::DecodingResult::U8(samples) = dec.read_image().expect("read image") else {
panic!("expected 8-bit samples");
};
(spp, bps, extra, rows, samples)
}
/// Classic-TIFF photometric tag 262 → 1 (MinIsBlack), value stored inline in the IFD entry.
fn photometric_min_is_black(src: &[u8]) -> Vec<u8> {
let mut bytes = src.to_vec();
let le = bytes.first().copied() == Some(b'I');
let ru16 = |b: &[u8], i: usize| -> Option<u16> {
let s = b.get(i..i + 2)?;
Some(if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) })
};
let ru32 = |b: &[u8], i: usize| -> Option<u32> {
let s = b.get(i..i + 4)?;
Some(if le { u32::from_le_bytes(s.try_into().ok()?) } else { u32::from_be_bytes(s.try_into().ok()?) })
};
let Some(ifd) = ru32(&bytes, 4) else { return bytes };
let Some(n) = ru16(&bytes, ifd as usize) else { return bytes };
for e in 0..n as usize {
let at = ifd as usize + 2 + e * 12;
if ru16(&bytes, at) == Some(262) {
let value = if le { 1u16.to_le_bytes() } else { 1u16.to_be_bytes() };
if let Some(slot) = bytes.get_mut(at + 8..at + 10) {
slot.copy_from_slice(&value);
}
break;
}
}
bytes
}
fn assert_plane(samples: &[u8], spp: usize, plane: usize, expected: &[u8]) {
assert_eq!(samples.len(), expected.len().saturating_mul(spp));
let mut bad = 0usize;
let mut first = None;
for (i, want) in expected.iter().enumerate() {
let got = samples.get(i * spp + plane).copied();
if got != Some(*want) {
bad += 1;
first.get_or_insert((i, got));
}
}
assert_eq!(bad, 0, "{bad} mismatches, first {first:?}");
}
/// A multi-layer transparent document, large enough for several TIFF strips and several
/// raster tiles, with a spot whose every pixel is a known value.
#[test]
fn dtf_like_spot_is_written_in_full() {
use photocraft_color::{Color, PixelFormat};
use photocraft_doc::{AlphaChannel, Layer};
use photocraft_geom::{Rect, Size};
use photocraft_psd::tiff::resources_from_bytes;
use photocraft_raster::Surface;
let (w, h) = (900u32, 360u32);
let mut doc = Document::new("dtf", Size::new(w, h), ColorMode::Rgb, SampleType::U8);
let fmt = doc.pixel_format();
let mut art = Surface::new(fmt);
let px = photocraft_raster::from_rgba(&fmt, [0.2, 0.5, 0.9, 1.0]);
art.fill_rect(Rect::new(30, 20, 870, 340), &px);
doc.layers.push(Layer::new("Art", LayerContent::Raster(art)));
let mut ink = Surface::new(fmt);
let px = photocraft_raster::from_rgba(&fmt, [0.9, 0.1, 0.2, 0.5]);
ink.fill_rect(Rect::new(80, 40, 700, 300), &px);
let mut layer = Layer::new("Ink", LayerContent::Raster(ink));
layer.opacity = 0.8;
doc.layers.push(layer);
let n = (w as usize) * (h as usize);
let mut spot_bytes = vec![0u8; n];
for y in 0..h as usize {
for x in 0..w as usize {
spot_bytes[y * w as usize + x] = ((x * 3 + y * 17) % 251) as u8;
}
}
// Outside the artwork the composite is transparent, but the spot is not zero: a reader that
// binds "Spot Color 1" to the alpha plane fails here.
assert_eq!(spot_bytes[10 * w as usize + 10], 200);
let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false);
let spot_surface = Surface::from_interleaved(gray, Rect::new(0, 0, w as i32, h as i32), &spot_bytes);
let mut channel = AlphaChannel::new("Spot Color 1", spot_surface);
channel.spot = Some((Color::rgb(1.0, 0.0, 0.0), 0.6));
doc.channels.push(channel);
let mut seen = Vec::new();
for (name, layers) in [("flat", false), ("layered", true)] {
let opts = ExportOptions { tiff_layers: layers, ..ExportOptions::default() };
let r = export(&doc, "dtf.tif", &opts).unwrap();
assert!(!r.warnings.iter().any(|w| w.contains("not written")), "{name}: {:?}", r.warnings);
let img = photocraft_codecs::decode(&r.bytes).unwrap();
assert!(!img.layout().has_alpha(), "{name}: {:?}", img.layout());
assert_eq!(img.layout().color_channels(), 3, "{name}");
assert_eq!(img.meta.extra_samples.len(), 1, "{name}");
assert_eq!(img.meta.extra_samples[0].kind, 0, "{name}");
// Photoshop stores spot ink inverted: document 255 (solid) is file 0.
let stored_spot: Vec<u8> = spot_bytes.iter().map(|b| 255 - b).collect();
assert_eq!(img.meta.extra_samples[0].data, stored_spot, "{name} own decoder");
let (spp, bps, extra, rows, samples) = independent_samples(&r.bytes);
assert_eq!(spp, 4, "{name}");
assert_eq!(bps, vec![8, 8, 8, 8], "{name}");
assert_eq!(extra, vec![0], "{name} ExtraSamples");
assert!(rows > 0 && rows < h, "{name} rows_per_strip {rows}");
assert_plane(&samples, 4, 3, &stored_spot);
seen.push(samples.clone());
// Outside the artwork the composite is matted to white (Save Transparency off). The
// spot at that pixel is not zero, so a reader that binds the name to the alpha fails.
let at = (10 * w as usize + 10) * 4;
assert_eq!(&samples[at..at + 3], &[255, 255, 255], "{name} matted rgb");
let (resources, layer_tag) = tags(&r.bytes);
let resources = resources.expect("photoshop resources");
let (blocks, warnings) = resources_from_bytes(&resources).unwrap();
assert!(warnings.is_empty(), "{name} {warnings:?}");
let block = |id: u16| blocks.iter().find(|b| b.id == id).unwrap_or_else(|| panic!("{name} missing {id}"));
// "Spot Color 1" is 12 characters: Photoshop stores 13 bytes and pads the 8BIM block, not the string.
assert_eq!(block(1006).data, b"\x0cSpot Color 1", "{name}");
assert_eq!(block(1045).data.len(), 30, "{name}");
let info = &block(1077).data;
assert_eq!(info.len(), 17, "{name}");
assert_eq!(u16::from_be_bytes([info[14], info[15]]), 60, "{name} solidity");
assert_eq!(info[16], 2, "{name} spot kind");
// Photoshop numbers a new document's channels from 3.
assert_eq!(block(1053).data, 3u32.to_be_bytes(), "{name} channel id");
let back = import("dtf.tif", &r.bytes).unwrap().document;
assert_eq!(back.channels.len(), 1, "{name}");
assert_eq!(back.channels[0].name, "Spot Color 1");
let (ink, sol) = back.channels[0].spot.expect("spot");
assert_eq!(ink.to_rgb(), [1.0, 0.0, 0.0], "{name}");
assert!((sol - 0.6).abs() < 1e-4, "{name}");
let area = Rect::new(0, 0, w as i32, h as i32);
assert_eq!(back.channels[0].surface.to_interleaved(area), spot_bytes, "{name} reimport");
if layers {
let layer_tag = layer_tag.expect("layer tag");
let (isd, w) = ImageSourceData::from_bytes(&layer_tag).unwrap();
assert!(w.is_empty(), "{w:?}");
let info = isd.layer_info.expect("layers");
assert!(!info.merged_alpha, "{name}");
assert!(info.layers.iter().all(|l| l.flags.0 & 0x08 != 0), "{name} photoshop flag");
assert!(info.layers.iter().all(|l| l.blocks.iter().any(|b| &b.key == b"lnsr")), "{name} lnsr");
let mask = isd.global_layer_mask.expect("LMsk");
assert_eq!(mask.data.len(), 14, "{name}");
assert_eq!(mask.opacity(), Some(50));
assert_eq!(mask.kind(), Some(128));
assert_photoshop_layered_tiff(&r.bytes, true);
assert_eq!(back.layers.len(), 2, "{name}");
let ink = back.layers.iter().find(|l| l.name == "Ink").expect("ink layer");
let px = ink.surface().expect("ink pixels").pixel(100, 100);
assert!((px[3] - 0.5).abs() < 0.02, "{name} layer alpha {px:?}");
let outside = ink.surface().expect("ink pixels").pixel(0, 0);
assert!(outside[3] < 0.02, "{name} transparent pixel {outside:?}");
} else {
assert!(layer_tag.is_none(), "{name}");
}
}
assert_eq!(seen[0], seen[1], "flat and layered IFD samples differ");
}
/// A transparent document with no user channels still stores image alpha.
#[test]
fn transparent_tiff_without_channels_keeps_image_alpha() {
use photocraft_doc::Layer;
use photocraft_geom::{Rect, Size};
use photocraft_raster::Surface;
let mut doc = Document::new("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8);
let fmt = doc.pixel_format();
let mut art = Surface::new(fmt);
art.fill_rect(Rect::new(1, 1, 6, 6), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 0.5]));
doc.layers.push(Layer::new("Art", LayerContent::Raster(art)));
for layers in [false, true] {
let r = export(&doc, "t.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap();
let img = photocraft_codecs::decode(&r.bytes).unwrap();
assert!(img.layout().has_alpha(), "{layers}");
assert!(img.meta.extra_samples.is_empty(), "{layers}");
let mut dec = tiff::decoder::Decoder::new(std::io::Cursor::new(r.bytes.as_slice())).unwrap();
let extra = dec.get_tag_u16_vec(tiff::tags::Tag::ExtraSamples).unwrap();
assert_eq!(extra, vec![2], "{layers}");
assert!(!r.warnings.iter().any(|w| w.contains("Save Transparency")), "{layers}: {:?}", r.warnings);
if layers {
assert_photoshop_layered_tiff(&r.bytes, false);
}
}
}
/// A flat TIFF that also stores an alpha or spot channel mattes the composite on white
/// and drops image transparency. The export says so.
#[test]
fn flat_tiff_warns_when_extra_channels_matte_transparency() {
use photocraft_color::{Color, PixelFormat};
use photocraft_doc::{AlphaChannel, Layer};
use photocraft_geom::{Rect, Size};
use photocraft_raster::Surface;
let mut doc = Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8);
let fmt = doc.pixel_format();
let mut art = Surface::new(fmt);
// (0, 0) stays transparent. (1, 1) is half-red, so the matte is halfway to white.
art.fill_rect(Rect::new(1, 1, 2, 2), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 0.5]));
doc.layers.push(Layer::new("Art", LayerContent::Raster(art)));
let mut spot = AlphaChannel::new("Spot Color 1", Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false)));
spot.spot = Some((Color::rgb(0.0, 1.0, 0.0), 1.0));
doc.channels.push(spot);
let r = export(&doc, "t.tif", &ExportOptions { tiff_layers: false, ..ExportOptions::default() }).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("composited over white") && w.contains("Save Transparency")), "{:?}", r.warnings);
let img = photocraft_codecs::decode(&r.bytes).unwrap();
assert!(!img.layout().has_alpha(), "{:?}", img.layout());
assert_eq!(img.meta.extra_samples.len(), 1);
let (_spp, _bps, extra, _rows, samples) = independent_samples(&r.bytes);
assert_eq!(extra, vec![0]);
// 4 samples per pixel: RGB + spot. (0, 0) was transparent and is now white.
assert_eq!(&samples[..3], &[255, 255, 255]);
// Half-red over white is about (255, 128, 128). Pixel (1, 1), 4 samples per pixel.
let (x, y, width, spp) = (1usize, 1usize, 4usize, 4usize);
let at = (y * width + x) * spp;
let rgb = &samples[at..at + 3];
assert!(rgb[0] > 250 && (rgb[1] as i16 - 128).abs() <= 2 && (rgb[2] as i16 - 128).abs() <= 2, "{rgb:?}");
}
/// A 4×4 RGB document whose one layer is transparent outside its middle, plus a "White" spot
/// channel with solid ink in its left half: the shape of a UV print's underbase.
fn transparent_art_with_white_spot() -> Document {
use photocraft_color::{Color, PixelFormat};
use photocraft_doc::{AlphaChannel, Layer};
use photocraft_geom::{Rect, Size};
use photocraft_raster::Surface;
let mut doc = Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8);
let fmt = doc.pixel_format();
let mut art = Surface::new(fmt);
art.fill_rect(Rect::new(1, 1, 3, 3), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0]));
doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(art)));
let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false);
let mut plate = Surface::new(gray);
plate.fill_rect(Rect::new(0, 0, 2, 4), &photocraft_raster::from_rgba(&gray, [1.0, 1.0, 1.0, 1.0]));
let mut white = AlphaChannel::new("White", plate);
white.spot = Some((Color::rgb(0.8, 0.2, 0.2), 1.0));
doc.channels.push(white);
doc
}
/// The first channel's ink at (0, 0), inked, and at (3, 0), bare.
fn ink(d: &Document) -> (f32, f32) {
(d.channels[0].surface.pixel(0, 0)[0], d.channels[0].surface.pixel(3, 0)[0])
}
/// `doc` laid out as Photoshop 25.12 saves a TIFF with Save Transparency on: R, G, B,
/// transparency and spot samples, with "Transparency" first in the channel names and display
/// records. With `layers`, the layer data has a positive layer count and an `MTrn` block (the
/// specification's key is `Mtrn`); without, the file is flat. Built from the PSD model of `doc`.
fn photoshop_transparency_and_spot_tiff(doc: &Document, layers: bool) -> Vec<u8> {
use photocraft_psd::{Compression, TaggedBlock};
let opts = PsdExportOptions { merged_matte: false, merged_compression: Compression::Raw, ..Default::default() };
let (file, _) = document_to_psd_with(doc, &opts);
let planes = file.decode_merged().unwrap();
let n = 16;
assert_eq!(planes.len(), 5 * n);
let rgba: Vec<u8> = (0..n).flat_map(|i| (0..4).map(move |c| (i, c))).map(|(i, c)| planes[c * n + i]).collect();
let mut img = photocraft_codecs::Image::from_raw(4, 4, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, rgba).unwrap();
img.meta.extra_samples = vec![photocraft_codecs::ExtraSample { kind: 0, data: planes[4 * n..].to_vec() }];
img.meta.photoshop_resources = Some(photocraft_psd::tiff::resources_to_bytes(&file.resources).unwrap());
if layers {
let mut isd = ImageSourceData::from_psd(&file);
if let Some(info) = &mut isd.layer_info {
info.merged_alpha = false;
}
isd.global_blocks.insert(0, TaggedBlock::new(*b"MTrn", Vec::new()));
let order = if photocraft_codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big };
img.meta.photoshop_layers = Some(isd.to_bytes(order).unwrap().0);
}
photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &photocraft_codecs::EncodeOptions::default()).unwrap()
}
/// Photoshop's layered TIFF with transparency next to a spot channel opens with the spot as the
/// only channel. A layered re-save leaves the transparency sample out next to the spot, so it
/// must also drop the `MTrn` marker, or the spot would read back as transparency.
#[test]
fn photoshop_mtrn_tiff_opens_without_a_transparency_channel() {
use photocraft_psd::TaggedBlock;
let bytes = photoshop_transparency_and_spot_tiff(&transparent_art_with_white_spot(), true);
let opened = import("t.tif", &bytes).unwrap().document;
let names: Vec<&str> = opened.channels.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["White"]);
assert!(opened.channels[0].spot.is_some());
assert_eq!(ink(&opened), (1.0, 0.0));
let r = tiff(&opened, true);
let (isd, _) = ImageSourceData::from_bytes(&tags(&r.bytes).1.unwrap()).unwrap();
assert!(!isd.global_blocks.iter().any(TaggedBlock::is_merged_transparency_marker));
let back = import("t.tif", &r.bytes).unwrap().document;
let names: Vec<&str> = back.channels.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["White"]);
assert_eq!(ink(&back), (1.0, 0.0));
}
/// The same Photoshop layout in a flat TIFF (no layer data): the image keeps its transparency,
/// and the spot gets its own name and display record, not the leading "Transparency" ones, so
/// it opens as a spot with its ink the right way round.
#[test]
fn photoshop_flat_tiff_with_transparency_opens_its_spot_as_a_spot() {
let bytes = photoshop_transparency_and_spot_tiff(&transparent_art_with_white_spot(), false);
assert!(tags(&bytes).1.is_none(), "flat: no layer data");
let opened = import("t.tif", &bytes).unwrap().document;
let names: Vec<&str> = opened.channels.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["White"]);
assert!(opened.channels[0].spot.is_some(), "a spot, not an alpha channel");
assert_eq!(ink(&opened), (1.0, 0.0));
let LayerContent::Raster(s) = &opened.layers[0].content else { panic!("raster") };
assert_eq!(s.pixel(0, 0)[3], 0.0, "the image keeps its transparency");
}
/// A Lab document with a spot channel keeps the spot as a CIELab extra sample, and its
/// transparency is matted on Lab white (L* 100, a* and b* 0), not on the maximum of every sample.
#[test]
fn lab_tiff_keeps_spot_and_mattes_on_lab_white() {
use photocraft_color::{Color, PixelFormat};
use photocraft_doc::{AlphaChannel, Layer};
use photocraft_geom::{Rect, Size};
use photocraft_raster::Surface;
for sample in [SampleType::U8, SampleType::U16] {
let mut doc = Document::new("t", Size::new(4, 4), ColorMode::Lab, sample);
let fmt = doc.pixel_format();
let mut art = Surface::new(fmt);
// (0, 0) stays transparent.
art.fill_rect(Rect::new(1, 1, 2, 2), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0]));
doc.layers.push(Layer::new("Art", LayerContent::Raster(art)));
let mut plate = Surface::new(PixelFormat::new(ColorMode::Grayscale, sample, false));
plate.fill_rect(Rect::new(0, 0, 2, 4), &photocraft_raster::from_rgba(&plate.format(), [1.0, 1.0, 1.0, 1.0]));
let mut spot = AlphaChannel::new("Spot Color 1", plate);
spot.spot = Some((Color::rgb(0.0, 1.0, 0.0), 1.0));
doc.channels.push(spot);
// Lab is always saved flat, whether layers were asked for or not.
for layers in [false, true] {
let r = export(&doc, "t.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap();
let img = photocraft_codecs::decode(&r.bytes).unwrap();
assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Lab, "{sample:?} {layers}");
assert_eq!(img.meta.extra_samples.len(), 1, "{sample:?} {layers}");
let white = &img.to_normalized()[..3];
assert!((white[0] - 1.0).abs() < 1e-3 && (white[1] - 128.0 / 255.0).abs() < 1e-3 && (white[2] - 128.0 / 255.0).abs() < 1e-3, "{white:?}");
let back = import("t.tif", &r.bytes).unwrap().document;
assert_eq!(back.pixel_format().mode, ColorMode::Lab, "{sample:?} {layers}");
assert_eq!(back.channels.len(), 1, "{sample:?} {layers}");
assert_eq!(back.channels[0].name, "Spot Color 1");
assert!(back.channels[0].spot.is_some(), "{sample:?} {layers}");
let plate = |c: &AlphaChannel| c.surface.to_interleaved(Rect::new(0, 0, 4, 4));
assert_eq!(plate(&back.channels[0]), plate(&doc.channels[0]), "{sample:?} {layers}");
}
}
}
/// Tag 37724 walked as raw bytes (not through [`ImageSourceData`]).
///
/// Matches a Photoshop 27 layered TIFF: classic 4-byte block lengths, `Layr` then a 14-byte
/// `LMsk`, no `MTrn` (that block is only for a composite that keeps both transparency and a
/// spot). A 4320×4320 RGB spot plate is 74_649_600 bytes and still fits those u32 lengths;
/// this check uses the same layout on a smaller image.
fn assert_photoshop_layered_tiff(bytes: &[u8], with_spot: bool) {
assert!(bytes.starts_with(b"II"), "little-endian TIFF");
let u16_at = |i: usize| u16::from_le_bytes([bytes[i], bytes[i + 1]]);
let u32_at = |i: usize| u32::from_le_bytes(bytes[i..i + 4].try_into().unwrap());
let ifd = u32_at(4) as usize;
let n = u16_at(ifd) as usize;
let mut layers = None;
let mut spp = 0u16;
let mut extra = Vec::new();
for e in 0..n {
let at = ifd + 2 + e * 12;
let tag = u16_at(at);
let typ = u16_at(at + 2);
let count = u32_at(at + 4) as usize;
let val = u32_at(at + 8) as usize;
let unit: usize = match typ {
3 => 2,
4 => 4,
_ => 1,
};
let size = unit.saturating_mul(count);
let inline = at + 8;
let raw = if size <= 4 { &bytes[inline..inline + size] } else { &bytes[val..val + size] };
match tag {
277 => spp = u16::from_le_bytes([raw[0], raw[1]]),
338 => extra = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(),
37724 => layers = Some(raw),
_ => {}
}
}
let isd = layers.expect("37724");
let sig = b"Adobe Photoshop Document Data Block\0";
assert!(isd.starts_with(sig), "PSB-sized lengths");
let mut pos = sig.len();
let mut keys = Vec::new();
let mut layr: Option<Vec<u8>> = None;
while pos + 12 <= isd.len() {
assert_eq!(&isd[pos..pos + 4], b"MIB8", "block at {pos}");
let key = [isd[pos + 7], isd[pos + 6], isd[pos + 5], isd[pos + 4]];
let len = u32::from_le_bytes(isd[pos + 8..pos + 12].try_into().unwrap()) as usize;
let body = &isd[pos + 12..pos + 12 + len];
if &key == b"Layr" {
layr = Some(body.to_vec());
}
if &key == b"LMsk" {
assert_eq!(body, &[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0x32, 0, 0x80, 0]);
}
keys.push(key);
pos += 12 + len;
let rest = &isd[pos..];
let pad = (0..=3).find(|&k| k == rest.len() || rest.get(k..k + 4) == Some(b"MIB8")).unwrap_or(0);
pos += pad;
}
assert_eq!(pos, isd.len(), "trailing bytes after the last block");
assert_eq!(keys.first(), Some(b"Layr"));
assert_eq!(keys.get(1), Some(b"LMsk"));
assert!(!keys.iter().any(|k| k == b"MTrn" || k == b"Mtrn"));
let layr = layr.expect("Layr");
let count = i16::from_le_bytes([layr[0], layr[1]]);
if with_spot {
assert!(count > 0, "layer count {count}");
assert_eq!((spp, extra.as_slice()), (4, [0u16].as_slice()));
} else {
assert!(count < 0, "layer count {count}");
assert_eq!(extra, vec![2]);
}
let nch = u16::from_le_bytes([layr[18], layr[19]]) as usize;
let mut ids = Vec::new();
let mut p = 20;
for _ in 0..nch {
ids.push(i16::from_le_bytes([layr[p], layr[p + 1]]));
p += 6;
}
assert!(ids.contains(&-1), "channel ids {ids:?}");
}
/// Photoshop 27's Kato-Karate TIFF stores spot ink inverted: every non-white artwork pixel
/// has spot 0, and the white background is spot 255. An alpha channel is the other way
/// around (255 = selected). Photoshop's PSDs store both the same way, and so does a PSD saved
/// here. Everything round-trips, PSD → TIFF → PSD included.
#[test]
fn spot_ink_is_stored_as_zero_and_alpha_is_not() {
use photocraft_color::{Color, PixelFormat};
use photocraft_doc::{AlphaChannel, Layer};
use photocraft_geom::{Rect, Size};
use photocraft_psd::PsdFile;
use photocraft_raster::Surface;
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let (w, h) = (8u32, 8u32);
let mut doc = Document::new("pol", Size::new(w, h), ColorMode::Rgb, depth);
let fmt = doc.pixel_format();
let mut art = Surface::new(fmt);
art.fill_rect(Rect::new(2, 2, 6, 6), &photocraft_raster::from_rgba(&fmt, [1.0, 0.2, 0.1, 1.0]));
doc.layers.push(Layer::new("Art", LayerContent::Raster(art)));
let gray = PixelFormat::new(ColorMode::Grayscale, depth, false);
let n = (w as usize) * (h as usize);
let mut ink = vec![0.0f32; n];
for y in 2..6 {
for x in 2..6 {
ink[y * w as usize + x] = 1.0;
}
}
let mut spot = AlphaChannel::new("Spot Color 1", Surface::new(gray));
spot.surface.write_region(Rect::new(0, 0, w as i32, h as i32), &ink);
spot.spot = Some((Color::rgb(1.0, 0.0, 0.0), 0.6));
doc.channels.push(spot);
let mut alpha = AlphaChannel::new("Alpha 1", Surface::new(gray));
alpha.surface.write_region(Rect::new(0, 0, w as i32, h as i32), &ink);
doc.channels.push(alpha);
let psd = export(&doc, "pol.psd", &ExportOptions::default()).unwrap();
let file = PsdFile::from_bytes(&psd.bytes).unwrap();
let merged = file.decode_merged().unwrap();
let bps = depth.bytes();
let plane = n * bps;
let first = 3 + usize::from(file.merged_has_alpha());
let spot_plane = &merged[first * plane..(first + 1) * plane];
let alpha_plane = &merged[(first + 1) * plane..(first + 2) * plane];
// Background (0,0) and artwork (3,3). Spot: 0 = solid ink, max = paper.
// Alpha: max = selected, 0 = empty.
assert_stored(spot_plane, 0, depth, false, "psd spot background");
assert_stored(spot_plane, (3 * w as usize + 3) * bps, depth, true, "psd spot art");
assert_stored(alpha_plane, 0, depth, true, "psd alpha background");
assert_stored(alpha_plane, (3 * w as usize + 3) * bps, depth, false, "psd alpha art");
// The merged transparency (0), then the spot and the alpha channel (3, 4).
assert_eq!(file.resource(1053).map(|r| identifiers(&r.data)), Some(vec![0, 3, 4]), "{depth:?}");
let back = import("pol.psd", &psd.bytes).unwrap().document;
let area = Rect::new(0, 0, w as i32, h as i32);
assert_eq!(back.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{depth:?} psd spot");
assert_eq!(back.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{depth:?} psd alpha");
assert!(back.channels[0].spot.is_some(), "{depth:?}");
assert!(back.channels[1].spot.is_none(), "{depth:?}");
if depth == SampleType::U8 {
for layers in [false, true] {
// The document as opened from the PSD, so the PSD's own resources come along.
let r = export(&back, "pol.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap();
let (spp, _bps, extra, _rows, samples) = independent_samples(&r.bytes);
assert_eq!((spp, extra.as_slice()), (5, [0u16, 0].as_slice()), "{layers}");
// Reference TIFF: artwork spot sample is 0, background spot sample is 255.
assert_eq!(samples[3], 255, "{layers} background spot");
assert_eq!(samples[(3 * w as usize + 3) * 5 + 3], 0, "{layers} art spot");
assert_eq!(samples[4], 0, "{layers} background alpha");
assert_eq!(samples[(3 * w as usize + 3) * 5 + 4], 255, "{layers} art alpha");
// One identifier per extra sample, numbered from 3 (no transparency sample).
assert_eq!(tiff_alpha_identifiers(&r.bytes), [3, 4], "{layers}");
let from_tiff = import("pol.tif", &r.bytes).unwrap().document;
assert_eq!(from_tiff.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{layers}");
assert_eq!(from_tiff.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{layers}");
assert!(from_tiff.channels[0].spot.is_some() && from_tiff.channels[1].spot.is_none(), "{layers}");
// Saved as PSD again: the first PSD's planes, and one 1053 of its own. The flat TIFF's
// composite was matted on white, so only the layered one brings transparency back.
let psd = PsdFile::from_bytes(&export(&from_tiff, "pol.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
let again = psd.decode_merged().unwrap();
assert_eq!(again[again.len() - 2 * plane..], merged[(first * plane)..(first + 2) * plane], "{layers} tiff to psd");
assert_eq!(psd.merged_has_alpha(), layers, "{layers} tiff to psd");
let want: Vec<u32> = if layers { vec![0, 3, 4] } else { vec![3, 4] };
assert_eq!(psd.resources.iter().filter(|r| r.id == 1053).map(|r| identifiers(&r.data)).collect::<Vec<_>>(), [want], "{layers} tiff to psd");
let reopened = import("pol.psd", &psd.to_bytes().unwrap()).unwrap().document;
assert_eq!(reopened.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{layers}");
assert_eq!(reopened.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{layers}");
}
}
}
}
/// The big-endian u32s of a resource 1053.
fn identifiers(data: &[u8]) -> Vec<u32> {
data.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()
}
/// The identifiers in a TIFF's resource 1053 (none without one).
fn tiff_alpha_identifiers(bytes: &[u8]) -> Vec<u32> {
let resources = tags(bytes).0.expect("photoshop resources");
let (blocks, _) = photocraft_psd::tiff::resources_from_bytes(&resources).unwrap();
blocks.iter().find(|b| b.id == 1053).map(|b| identifiers(&b.data)).unwrap_or_default()
}
/// `at` addresses one sample of a big-endian PSD plane. `zero` expects 0; otherwise the
/// maximum sample (255 / 65535 / 1.0).
fn assert_stored(plane: &[u8], at: usize, depth: SampleType, zero: bool, what: &str) {
match depth {
SampleType::U8 => {
let v = plane[at];
assert_eq!(v, if zero { 0 } else { 255 }, "{what} {v}");
}
SampleType::U16 => {
let v = u16::from_be_bytes([plane[at], plane[at + 1]]);
assert_eq!(v, if zero { 0 } else { 65535 }, "{what} {v}");
}
SampleType::F32 => {
let v = f32::from_be_bytes([plane[at], plane[at + 1], plane[at + 2], plane[at + 3]]);
assert_eq!(v, if zero { 0.0 } else { 1.0 }, "{what} {v}");
}
}
}
/// Quick Mask stays the last channel after the merged transparency plane is omitted.
#[test]
fn spot_tiff_keeps_quick_mask_when_composite_alpha_is_omitted() {
use photocraft_color::{Color, PixelFormat};
use photocraft_doc::{AlphaChannel, Layer};
use photocraft_geom::{Rect, Size};
use photocraft_raster::Surface;
let mut doc = Document::new("q", Size::new(8, 8), ColorMode::Rgb, SampleType::U8);
let fmt = doc.pixel_format();
let mut art = Surface::new(fmt);
art.fill_rect(Rect::new(1, 1, 6, 6), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 0.5]));
doc.layers.push(Layer::new("Art", LayerContent::Raster(art)));
let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false);
let area = Rect::new(0, 0, 8, 8);
let mut spot_bytes = vec![9u8; 64];
spot_bytes[0] = 3;
let mut channel = AlphaChannel::new("Spot Color 1", Surface::from_interleaved(gray, area, &spot_bytes));
channel.spot = Some((Color::rgb(1.0, 0.0, 0.0), 0.6));
doc.channels.push(channel);
let mut qbytes = vec![7u8; 64];
qbytes[1] = 4;
doc.quick_mask = Some(AlphaChannel::new("Quick Mask", Surface::from_interleaved(gray, area, &qbytes)));
for layers in [false, true] {
let r = export(&doc, "q.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap();
let (spp, _bps, extra, _rows, samples) = independent_samples(&r.bytes);
assert_eq!((spp, extra.as_slice()), (5, [0, 0].as_slice()), "{layers}");
let stored_spot: Vec<u8> = spot_bytes.iter().map(|b| 255 - b).collect();
assert_plane(&samples, 5, 3, &stored_spot);
// A Quick Mask is an alpha channel: stored straight, not as spot ink.
assert_plane(&samples, 5, 4, &qbytes);
let back = import("q.tif", &r.bytes).unwrap().document;
assert_eq!(back.channels.len(), 1, "{layers}");
assert_eq!(back.channels[0].surface.to_interleaved(area), spot_bytes, "{layers}");
let qm = back.quick_mask.as_ref().unwrap_or_else(|| panic!("{layers}: no quick mask"));
assert_eq!(qm.surface.to_interleaved(area), qbytes, "{layers}");
}
}
#[test]
fn default_export_options_write_a_flat_tiff() {
// Scripted and agent saves (CLI, batch, MCP) build on the defaults: layers only on request.
@@ -399,3 +1054,70 @@ fn layered_tiff_nested_past_the_cap_is_rejected() {
let r = import("hundred.tif", &tiff_nested(100)).unwrap();
assert_eq!(r.document.max_group_depth(), 100);
}
#[test]
fn layered_tiff_writes_photoshops_global_layer_mask() {
// #9095: Photoshop reads only the composite of a layered TIFF whose `LMsk` block is empty
// ("There was a problem reading the layer data"); it writes 14 bytes there itself: overlay
// colour space 0, colour (65535, 0, 0, 0), opacity 50, kind 128, one filler byte.
for depth in [SampleType::U8, SampleType::U16] {
let d = gen_doc(ColorMode::Rgb, depth, Features::PIXELS);
let (_, layers) = tags(&tiff(&d, true).bytes);
let (isd, _) = ImageSourceData::from_bytes(&layers.expect("layer data")).expect("parse layer data");
let mask = isd.global_layer_mask.expect("LMsk block");
assert_eq!(mask.data, [0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, 128, 0], "{depth:?}");
}
}
/// Past MAX_EXTRA_SAMPLES (16), a layered TIFF stores the first 16 extra channels. Its channel
/// resources must describe those 16 and no more: 16 names in 1006 and 1045, 16 DisplayInfo
/// records and 16 identifiers, and no Quick Mask record for a Quick Mask that wasn't stored.
#[test]
fn channels_past_sixteen_leave_no_resources_behind() {
use photocraft_color::PixelFormat;
use photocraft_doc::{AlphaChannel, Layer};
use photocraft_geom::{Rect, Size};
use photocraft_raster::Surface;
let mut doc = Document::new("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8);
let fmt = doc.pixel_format();
let mut art = Surface::new(fmt);
art.fill_rect(Rect::new(0, 0, 8, 8), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0]));
doc.layers.push(Layer::new("Art", LayerContent::Raster(art)));
let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false);
for i in 1..=18 {
doc.channels.push(AlphaChannel::new(format!("Alpha {i}"), Surface::new(gray)));
}
doc.quick_mask = Some(AlphaChannel::new("Quick Mask", Surface::new(gray)));
let r = tiff(&doc, true);
assert!(r.warnings.iter().any(|w| w.contains("19 extra channels") && w.contains("first 16")), "{:?}", r.warnings);
let (resources, _) = tags(&r.bytes);
let (resources, _) = photocraft_psd::tiff::resources_from_bytes(&resources.expect("resources")).expect("parse resources");
let data = |id: u16| resources.iter().find(|r| r.id == id).map(|r| r.data.clone()).unwrap_or_default();
let pascal = data(1006);
let (mut at, mut names) = (0usize, 0usize);
while let Some(&len) = pascal.get(at) {
at += 1 + usize::from(len);
names += 1;
}
assert_eq!(names, 16, "1006");
let unicode = data(1045);
let (mut at, mut names) = (0usize, 0usize);
while let Some(b) = unicode.get(at..at + 4) {
at += 4 + 2 * u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize;
names += 1;
}
assert_eq!(names, 16, "1045");
assert_eq!(data(1077).len(), 4 + 16 * 13, "1077");
assert_eq!(data(1053).len(), 16 * 4, "1053");
assert!(resources.iter().all(|r| r.id != 1022), "the Quick Mask was not stored, so its record goes");
let flat = photocraft_codecs::decode(&r.bytes).expect("decode");
assert_eq!(flat.meta.extra_samples.len(), 16);
let back = import("x.tif", &r.bytes).expect("import").document;
let names: Vec<&str> = back.channels.iter().map(|c| c.name.as_str()).collect();
let want: Vec<String> = (1..=16).map(|i| format!("Alpha {i}")).collect();
assert_eq!(names, want.iter().map(String::as_str).collect::<Vec<_>>());
assert!(back.quick_mask.is_none());
}
+49 -1
View File
@@ -5,9 +5,14 @@ use photocraft_doc::text::{CharStyle, TextRun};
use photocraft_doc::{Document, Layer, LayerContent, Size, TextLayer};
use photocraft_geom::Affine;
use photocraft_io::{ExportOptions, PsdExportOptions, document_to_psd_with, export, import, psd_to_document};
use photocraft_psd::descriptor::{Value, VersionedDescriptor};
fn document() -> Document {
let mut doc = Document::new("Synthetic type", Size::new(640, 400), ColorMode::Rgb, SampleType::U8);
document_in(ColorMode::Rgb, SampleType::U8)
}
fn document_in(mode: ColorMode, depth: SampleType) -> Document {
let mut doc = Document::new("Synthetic type", Size::new(640, 400), mode, depth);
doc.resolution_dpi = 300.0;
let mut t = TextLayer {
text: "Synthetic".into(),
@@ -46,6 +51,49 @@ fn photoshop_mixed_order_tiff_import_keeps_editable_geometry_and_pixels() {
assert_eq!(rerendered.content_bounds(), after.cache.as_ref().unwrap().content_bounds());
}
#[test]
fn layered_tiff_export_keeps_tysh_text_descriptor_big_endian() {
for (mode, depth) in [
(ColorMode::Rgb, SampleType::U8),
(ColorMode::Rgb, SampleType::U16),
(ColorMode::Rgb, SampleType::F32),
(ColorMode::Grayscale, SampleType::U8),
(ColorMode::Grayscale, SampleType::U16),
(ColorMode::Grayscale, SampleType::F32),
(ColorMode::Cmyk, SampleType::U8),
(ColorMode::Cmyk, SampleType::U16),
] {
let doc = document_in(mode, depth);
let (psd, _) = document_to_psd_with(&doc, &PsdExportOptions::default());
let canonical = &psd.layers()[0].block(b"TySh").unwrap().data;
let encoded = export(&doc, "test.tif", &ExportOptions { tiff_layers: true, ..Default::default() }).unwrap();
let (_, layers) = photocraft_codecs::tiff_photoshop_tags(&encoded.bytes);
let layers = layers.expect("layered TIFF tag 37724");
let block_at = layers.windows(8).position(|window| window == b"MIB8hSyT").expect("little-endian TySh block");
let length_at = block_at + 8;
let length_bytes: [u8; 4] = layers.get(length_at..length_at + 4).expect("TySh length").try_into().expect("four-byte length");
let length = usize::try_from(u32::from_le_bytes(length_bytes)).expect("test block length fits usize");
let data_at = length_at + 4;
let payload = layers.get(data_at..data_at + length).expect("TySh payload");
let (_, descriptor_len) = VersionedDescriptor::parse_prefix(canonical.get(52..).unwrap()).unwrap();
let text_section_len = 2 + descriptor_len;
assert_eq!(payload.get(50..52), Some(&50u16.to_be_bytes()[..]), "{mode:?} {depth:?}");
assert_eq!(payload.get(52..56), Some(&16u32.to_be_bytes()[..]), "{mode:?} {depth:?}");
assert_eq!(payload.get(50..50 + text_section_len), canonical.get(50..50 + text_section_len), "{mode:?} {depth:?}");
let descriptor = VersionedDescriptor::parse_prefix(payload.get(52..).unwrap()).unwrap().0;
assert!(matches!(descriptor.descriptor.get("Txt "), Some(Value::Text(value)) if value.to_string_lossy() == doc.layers[0].name));
let opened = import("photoshop.tif", &encoded.bytes).unwrap();
assert!(!opened.warnings.iter().any(|warning| warning.contains("TySh")), "{:?}", opened.warnings);
let LayerContent::Text(before) = &doc.layers[0].content else { panic!("source type") };
let LayerContent::Text(after) = &opened.document.layers[0].content else { panic!("imported type") };
assert_eq!(after.transform, before.transform, "{mode:?} {depth:?}");
assert_eq!(after.size_pt, before.size_pt, "{mode:?} {depth:?}");
assert_eq!(after.text, before.text, "{mode:?} {depth:?}");
assert_eq!(after.cache.as_ref().unwrap().content_bounds(), before.cache.as_ref().unwrap().content_bounds(), "{mode:?} {depth:?}");
}
}
#[test]
fn foreign_or_unsafe_type_keeps_cached_pixels_without_editable_geometry() {
let doc = document();
+5
View File
@@ -13,5 +13,10 @@ photocraft-color = { workspace = true }
photocraft-raster = { workspace = true }
serde = { workspace = true }
# Large tips rasterize, accumulate and composite in parallel (the same pixels as serially).
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon = { workspace = true }
[dev-dependencies]
photocraft-cms = { workspace = true }
serde_json = { workspace = true }
+44
View File
@@ -0,0 +1,44 @@
//! Where a large brush's time goes: one dab of a 2000 px tip, by stage.
//! `cargo run --release -p photocraft-paint --example big_dab_bench [size] [hardness]`
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_paint::{BrushContext, BrushSettings, Dab, StrokePoint, StrokeRenderer};
use photocraft_raster::Surface;
use std::time::Instant;
fn main() {
let args: Vec<String> = std::env::args().collect();
let size: f32 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(2000.0);
let hardness: f32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0.0);
let brush = BrushSettings { size, hardness, spacing: 0.25, color: [1.0, 0.0, 0.0, 1.0], ..Default::default() };
let fmt = PixelFormat::RGBA8;
let mut pre = Surface::new(fmt);
pre.fill_rect(Rect::new(0, 0, 4928, 3280), &[0.5, 0.5, 0.5, 1.0]);
let mut target = pre.clone();
// Stage 1: the tip's coverage alone.
let ctx = BrushContext::new(&brush);
let dab = Dab::round(photocraft_geom::Point::new(2464.0, 1640.0), size / 2.0, 1.0);
let rect = ctx.dab_rect(&dab, false);
let mut cov = Vec::new();
let t = Instant::now();
ctx.rasterize(&dab, false, rect, &mut cov);
let raster = t.elapsed();
println!("tip {size} px, hardness {hardness}: dab rect {}x{} = {:.1} MP", rect.width(), rect.height(), rect.width() as f64 * rect.height() as f64 / 1e6);
println!(" rasterize (coverage): {:>7.1} ms", raster.as_secs_f64() * 1e3);
// Stage 2+3: push through the renderer (rasterize + accumulate), then composite.
let mut r = StrokeRenderer::new(&brush, Some(fmt), 1.0);
let t = Instant::now();
// Two points a pixel apart: the generator places the first dab at once (a lone point waits).
r.push(&[StrokePoint::new(2464.0, 1640.0, 1.0), StrokePoint::new(2465.0, 1640.0, 1.0)]);
let push = t.elapsed();
println!(" push (rasterize+accumulate): {:>5.1} ms (accumulate ≈ {:.1} ms)", push.as_secs_f64() * 1e3, push.saturating_sub(raster).as_secs_f64() * 1e3);
let t = Instant::now();
let dmg = r.composite(&pre, &mut target, None, false, false);
let comp = t.elapsed();
println!(" composite (dirty tiles): {:>7.1} ms over {}x{}", comp.as_secs_f64() * 1e3, dmg.width(), dmg.height());
// A second dab half a spacing away, as a stroke does: the same again.
let t = Instant::now();
r.push(&[StrokePoint::new(2464.0 + f64::from(size) * 0.25, 1640.0, 1.0), StrokePoint::new(2466.0 + f64::from(size) * 0.25, 1640.0, 1.0)]);
let dmg = r.composite(&pre, &mut target, None, false, false);
println!(" next dab push+composite: {:>7.1} ms over {}x{}", t.elapsed().as_secs_f64() * 1e3, dmg.width(), dmg.height());
}
+64
View File
@@ -0,0 +1,64 @@
//! A long stroke of one tip, timed per dab: a zigzag over a 4928×3280 RGBA8 document that sweeps
//! the whole page, `push` then `composite` after every point, as painting does per frame. Small
//! tips are the everyday case; a long stroke grows the stroke's tile map to thousands of tiles,
//! and the time per dab must not grow with it.
//! `cargo run --release -p photocraft-paint --example stroke_bench -- [size] [hardness] [dabs]`
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_paint::{BrushSettings, StrokePoint, StrokeRenderer};
use photocraft_raster::Surface;
use std::time::Instant;
fn main() {
let args: Vec<String> = std::env::args().collect();
let size: f32 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(20.0);
let hardness: f32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0.0);
let dabs: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(6000);
let (w, h) = (4928.0f64, 3280.0f64);
let brush = BrushSettings { size, hardness, spacing: 0.25, color: [1.0, 0.0, 0.0, 1.0], ..Default::default() };
let fmt = PixelFormat::RGBA8;
let mut pre = Surface::new(fmt);
pre.fill_rect(Rect::new(0, 0, w as i32, h as i32), &[0.5, 0.5, 0.5, 1.0]);
let mut target = pre.clone();
// Segments from the top margin to the bottom one and back, advancing across the width so that
// `dabs` dabs at the brush's spacing sweep the document once.
let step = f64::from(size) * 0.25;
let margin = f64::from(size);
let span = h - 2.0 * margin;
let segments = ((dabs as f64 * step) / span).ceil().max(1.0) as usize;
let dx = (w - 2.0 * margin) / segments as f64;
let mut r = StrokeRenderer::new(&brush, Some(fmt), 1.0);
let (mut t_push, mut t_comp) = (0.0f64, 0.0f64);
let t_all = Instant::now();
let mut pos = (margin, margin);
r.push(&[StrokePoint::new(pos.0, pos.1, 1.0)]);
let mut pushes = 0usize;
'stroke: for seg in 0..segments {
let (x0, y0) = pos;
let (x1, y1) = (margin + (seg + 1) as f64 * dx, if seg % 2 == 0 { h - margin } else { margin });
let n = (((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt() / step).ceil().max(1.0) as usize;
for i in 1..=n {
let f = i as f64 / n as f64;
let t = Instant::now();
r.push(&[StrokePoint::new(x0 + (x1 - x0) * f, y0 + (y1 - y0) * f, 1.0)]);
let tp = t.elapsed().as_secs_f64();
r.composite(&pre, &mut target, None, false, false);
t_comp += t.elapsed().as_secs_f64() - tp;
t_push += tp;
pushes += 1;
if r.dab_count() >= dabs {
break 'stroke;
}
}
pos = (x1, y1);
}
let total = t_all.elapsed().as_secs_f64();
let placed = r.dab_count();
println!(
"tip {size} px, hardness {hardness}: {placed} dabs in {pushes} pushes over {segments} segments: {:.0} ms (push {:.0}, composite {:.0}), {:.1} µs per dab",
total * 1e3,
t_push * 1e3,
t_comp * 1e3,
total * 1e6 / placed.max(1) as f64
);
}
+152
View File
@@ -278,6 +278,58 @@ fn stroke_has_no_seams() {
}
}
#[test]
fn hard_round_presets_have_smooth_large_stroke_outlines() {
// #8842: the centre can be solid while widely spaced discs scallop the outline.
// Measure the rendered 50% edge of a 150 px stroke, including its sub-pixel position.
for preset in crate::presets::builtin().into_iter().filter(|p| p.name.starts_with("Hard Round")) {
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
for smoothing in [0.0, 0.49] {
let mut b = preset.brush.clone();
b.size = 150.0;
b.smoothing.amount = smoothing;
let mut s = Surface::new(fmt);
render_stroke(&mut s, &b, &line(100.0, 800.0, 120.0), None, false, 1.0);
let (mut min, mut max) = (f32::INFINITY, f32::NEG_INFINITY);
for x in 220..680 {
let edge = (0..120)
.find_map(|y| {
let (a, next) = (s.rgba(x, y)[3], s.rgba(x, y + 1)[3]);
(a <= 0.5 && next > 0.5).then(|| y as f32 + (0.5 - a) / (next - a))
})
.expect("a full-pressure stroke has a half-coverage edge");
min = min.min(edge);
max = max.max(edge);
}
assert!(max - min < 0.25, "{} {fmt:?} smoothing {smoothing}: scalloped edge spans {} px", preset.name, max - min);
}
}
}
}
#[test]
fn hard_round_presets_have_continuous_pressure_opacity_tapers() {
// #8842 also shows nested disc boundaries when pressure controls opacity and size.
// A smooth input ramp must not turn into several-percent steps between adjacent pixels.
let points: Vec<_> =
(0..=100).map(|i| StrokePoint { time: i as f64 * 8.0, ..StrokePoint::new(100.0 + i as f64 * 7.0, 120.0, 0.95 - i as f32 * 0.009) }).collect();
for preset in crate::presets::builtin().into_iter().filter(|p| p.name.starts_with("Hard Round")) {
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
for smoothing in [0.0, 0.49] {
let mut b = preset.brush.clone();
b.size = 150.0;
b.pressure_opacity = true;
b.smoothing.amount = smoothing;
let mut s = Surface::new(fmt);
render_stroke(&mut s, &b, &points, None, false, 1.0);
assert!(s.rgba(200, 120)[3] > 0.7 && s.rgba(699, 120)[3] < 0.3, "{}: the stroke must still taper", preset.name);
let max_jump = (200..699).map(|x| (s.rgba(x + 1, 120)[3] - s.rgba(x, 120)[3]).abs()).fold(0.0f32, f32::max);
assert!(max_jump < 0.02, "{} {fmt:?} smoothing {smoothing}: opacity jumps by {max_jump}", preset.name);
}
}
}
}
// ---------- dynamics ----------
#[test]
@@ -1688,6 +1740,106 @@ fn an_aliased_stroke_at_partial_opacity_is_one_flat_colour() {
}
}
/// Large tips rasterize, accumulate and composite in parallel (rows of a dab, tiles of a stroke);
/// the pixels are the ones the serial path gives, with 8-bit dithering, a selection, Dissolve and
/// per-dab colour included.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn large_tips_render_the_same_in_parallel_and_serially() {
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(Rect::new(0, 0, 900, 700), &[1.0]);
sel.fill_rect(Rect::new(300, 0, 400, 700), &[0.0]);
let strokes: Vec<(BrushSettings, Vec<StrokePoint>)> = vec![
(BrushSettings { size: 700.0, hardness: 0.0, flow: 0.6, color: [0.9, 0.2, 0.1, 1.0], ..brush() }, line(200.0, 700.0, 350.0)),
(
BrushSettings { size: 420.0, hardness: 1.0, opacity: 0.5, mode: BlendMode::Dissolve, color: [0.1, 0.5, 0.9, 1.0], ..brush() },
line(150.0, 750.0, 300.0),
),
(
BrushSettings {
size: 500.0,
hardness: 0.3,
color_dynamics: ColorDynamics { enabled: true, hue_jitter: 0.5, per_tip: true, ..Default::default() },
color: [0.2, 0.8, 0.3, 1.0],
..brush()
},
line(100.0, 800.0, 400.0),
),
];
let render = |parallel_from: usize| -> Vec<Vec<f32>> {
crate::render::set_parallel_min_pixels(parallel_from);
strokes
.iter()
.map(|(b, pts)| {
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 900, 700), &[0.5, 0.5, 0.5, 1.0]);
let (mut with_sel, mut plain) = (s.clone(), s);
render_stroke(&mut with_sel, b, pts, Some(&sel), false, 1.0);
render_stroke(&mut plain, b, pts, None, false, 1.0);
let mut px = with_sel.read_region(Rect::new(0, 0, 900, 700));
px.extend(plain.read_region(Rect::new(0, 0, 900, 700)));
px
})
.collect()
};
// The threshold is process-wide: put the default back however the test ends, a panic
// inside a render included.
struct Restore;
impl Drop for Restore {
fn drop(&mut self) {
crate::render::set_parallel_min_pixels(crate::render::DEFAULT_PARALLEL_MIN_PIXELS);
}
}
let _restore = Restore;
let parallel = render(0);
let serial = render(usize::MAX);
for (i, (p, s)) in parallel.iter().zip(&serial).enumerate() {
assert_eq!(p.len(), s.len());
let diff = p.iter().zip(s).filter(|(a, b)| a != b).count();
assert_eq!(diff, 0, "stroke {i}: {diff} pixels differ between the parallel and the serial render");
assert!(p.iter().any(|v| (*v - 0.5).abs() > 0.1), "stroke {i} painted something");
}
}
/// A CMYK document with its own profile: blend modes other than Normal convert each pixel through
/// that profile, which is active per thread, so a stroke composited in parallel gives the pixels the
/// serial path gives (the workers enter the caller's CMYK space too).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_parallel_composite_converts_through_the_documents_cmyk_profile() {
use photocraft_cms::synth::{CmykParams, cmyk_profile};
use photocraft_color::convert::{CmykSpace, with_cmyk_space};
let profile =
cmyk_profile(&CmykParams { description: "Brush Test CMYK".into(), tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 5, grid_b2a: 9, ..Default::default() });
let bytes = profile.to_bytes();
let Some(space) = CmykSpace::for_profile(Some(&bytes)) else { panic!("the synthetic profile is a CMYK space") };
let fmt = PixelFormat::new(ColorMode::Cmyk, SampleType::U16, true);
// A 40 px Multiply line over 1100 px dirties more than 16 tiles: the composite's batch is
// worth the threads at the default threshold, and always at threshold 0.
let b = BrushSettings { size: 40.0, hardness: 0.5, mode: BlendMode::Multiply, color: [0.2, 0.4, 0.9, 1.0], ..brush() };
let pts = line(40.0, 1140.0, 60.0);
let full = Rect::new(0, 0, 1200, 120);
let render = |parallel_from: usize| -> Vec<f32> {
crate::render::set_parallel_min_pixels(parallel_from);
let mut s = Surface::new(fmt);
s.fill_rect(full, &[0.1, 0.5, 0.3, 0.05, 1.0]);
with_cmyk_space(Some(&space), || render_stroke(&mut s, &b, &pts, None, false, 1.0));
s.read_region(full)
};
struct Restore;
impl Drop for Restore {
fn drop(&mut self) {
crate::render::set_parallel_min_pixels(crate::render::DEFAULT_PARALLEL_MIN_PIXELS);
}
}
let _restore = Restore;
let parallel = render(0);
let serial = render(usize::MAX);
let diff = parallel.iter().zip(&serial).filter(|(a, b)| a != b).count();
assert_eq!(diff, 0, "{diff} samples differ between the parallel and the serial composite in a profiled CMYK document");
assert!(serial.chunks(5).any(|p| (p[1] - 0.5).abs() > 0.05), "the stroke painted something");
}
/// Photoshop (measured): a 100 px brush with a 20 px Dual Brush tip resized to 50 / 2 / 1 px has a
/// 10 / 0.4 / 0.2 px dual tip, unrounded and with no floor, and back at 100 px it is 20 px again; past
/// 5000 px the dual tip is capped there and the proportion is lost (#4809).
+189 -56
View File
@@ -6,7 +6,7 @@
//! weight. The reservoir amount drains per dab unless `load` is 100 %, so low Load strokes dry out.
//! A dry brush (`wet = 0`) paints only reservoir colour; an empty, wet brush smears canvas colour.
use photocraft_raster::{Surface, from_rgba, to_rgba};
use photocraft_raster::{Surface, from_rgba, from_rgba_into, to_rgba};
use serde::{Deserialize, Serialize};
use crate::Stroke;
@@ -27,6 +27,7 @@ pub struct MixerSettings {
/// Pick up colour from all visible layers instead of the target layer only.
pub sample_all_layers: bool,
/// Load the brush with the stroke color after each stroke (the automatic Load option).
#[serde(alias = "reloadAfterStroke")]
pub load_after_stroke: bool,
/// Empty the brush after each stroke (the automatic Clean option).
pub clean_after_stroke: bool,
@@ -34,7 +35,7 @@ pub struct MixerSettings {
impl Default for MixerSettings {
fn default() -> Self {
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false, load_after_stroke: true, clean_after_stroke: true }
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false, clean_after_stroke: true, load_after_stroke: true }
}
}
@@ -48,6 +49,8 @@ pub struct MixerState {
pub amount: f32,
/// Colour picked up from the canvas (straight RGBA), None = clean.
pub pickup: Option<[f32; 4]>,
/// A bounded 64 x 64 spatial load in tip-local coordinates. Empty cells carry no paint.
pub spatial: Option<Vec<[f32; 4]>>,
/// The color recorded by the last stroke (its color) or the last Load Brush or Clean Brush
/// (the foreground). None = no stroke, load or clean yet.
pub color: Option<[f32; 4]>,
@@ -56,11 +59,13 @@ pub struct MixerState {
impl MixerState {
/// Load Brush: fill the reservoir with a color (full).
pub fn load(&mut self, color: [f32; 4]) {
self.spatial = None;
self.reservoir = Some(color);
self.amount = 1.0;
}
/// Clean Brush: empty the reservoir and the pickup well.
pub fn clean(&mut self) {
self.spatial = None;
self.reservoir = None;
self.amount = 0.0;
self.pickup = None;
@@ -76,11 +81,16 @@ impl MixerState {
/// After a stroke in `color`: the automatic Clean empties the brush, then the automatic Load
/// fills the reservoir again.
pub fn end_stroke(&mut self, color: [f32; 4], clean: bool, load: bool) {
// Automatic Load replenishes a sampled tip as well as a solid-color load.
// Move its bounded buffer through Clean instead of allocating a copy.
let spatial = if load { self.spatial.take() } else { None };
let paint = if spatial.is_some() { self.reservoir.unwrap_or(color) } else { color };
if clean {
self.clean();
}
if load {
self.load(color);
self.load(paint);
self.spatial = spatial;
}
self.color = Some(color);
}
@@ -91,6 +101,34 @@ impl MixerState {
}
}
/// Sample a spatial load at a dab pixel using the dab's rotation, aspect and flips.
/// Keeping the reservoir in tip coordinates lets it follow subsequent brush poses.
pub fn load_at(load: &[[f32; 4]], dab: &crate::Dab, x: f32, y: f32) -> [f32; 4] {
let mut dx = x - dab.center.x as f32;
let mut dy = y - dab.center.y as f32;
if dab.proj_scale < 0.999 {
let (sin, cos) = dab.proj_angle.sin_cos();
let along = (dx * cos - dy * sin) * (1.0 / dab.proj_scale.max(0.05) - 1.0);
dx += along * cos;
dy -= along * sin;
}
let (sin, cos) = dab.angle.sin_cos();
let mut u = (dx * cos - dy * sin) / dab.radius.max(0.01);
let mut v = (dx * sin + dy * cos) / (dab.radius * dab.roundness).max(0.5);
if dab.flip_x {
u = -u;
}
if dab.flip_y {
v = -v;
}
if !(-1.0..1.0).contains(&u) || !(-1.0..1.0).contains(&v) {
return [0.0; 4];
}
let col = ((u + 1.0) * 32.0) as usize;
let row = ((v + 1.0) * 32.0) as usize;
load.get(row * 64 + col).copied().unwrap_or([0.0; 4])
}
/// Paint a Mixer Brush stroke. `sample` is the surface colour is picked up from (the layer itself
/// when None; a flattened composite for Sample All Layers). Returns the damaged rectangle.
pub fn apply_mixer_stroke(
@@ -103,6 +141,50 @@ pub fn apply_mixer_stroke(
lock_transparency: bool,
) -> Rect {
let fmt = target.format();
let mut st = stroke.clone();
st.brush.opacity = 1.0;
apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| {
apply_mixer_dab(work, fp, fmt, sample, m, state, lock_transparency);
})
}
/// One incremental wet-paint dab, shared by recorded strokes and the live document transaction.
pub fn apply_mixer_dab(
work: &mut crate::retouch::Region,
fp: &crate::retouch::Footprint,
fmt: photocraft_color::PixelFormat,
sample: Option<&Surface>,
m: &MixerSettings,
state: &mut MixerState,
lock_transparency: bool,
) {
let sample = sample.map(|surface| {
let source = crate::retouch::Region::read(surface, work.rect);
if surface.format().mode == fmt.mode && surface.format().alpha == fmt.alpha {
source
} else {
let mut native = crate::retouch::Region::new(work.rect, fmt.channels());
for (src, dst) in source.data.chunks_exact(source.ch).zip(native.data.chunks_exact_mut(native.ch)) {
from_rgba_into(&fmt, to_rgba(&surface.format(), src), dst);
}
native
}
});
apply_mixer_dab_region(work, fp, fmt, sample.as_ref(), m, state, lock_transparency);
}
/// Contiguous native-channel sample variant. `sample` must have the same rectangle and
/// channel layout as `work`. The live compositor supplies it directly, avoiding a tiled
/// temporary surface and a heap allocation for every sampled/deposited pixel.
pub fn apply_mixer_dab_region(
work: &mut crate::retouch::Region,
fp: &crate::retouch::Footprint,
fmt: photocraft_color::PixelFormat,
sample: Option<&crate::retouch::Region>,
m: &MixerSettings,
state: &mut MixerState,
lock_transparency: bool,
) {
let n = fmt.channels();
let a = alpha_index(&fmt);
let nc = fmt.mode.color_channels();
@@ -117,70 +199,81 @@ pub fn apply_mixer_stroke(
let mut amount = if res.is_some() { state.amount.clamp(0.0, 1.0) } else { 0.0 };
let mut pickup: Option<Vec<f32>> = state.pickup.map(native);
let (wet, mix, flow, load) = (m.wet.clamp(0.0, 1.0), m.mix.clamp(0.0, 1.0), m.flow.clamp(0.0, 1.0), m.load.clamp(0.0, 1.0));
let mut st = stroke.clone();
st.brush.opacity = 1.0;
let mut acc = vec![0.0f32; n];
let mut paint = vec![0.0f32; n];
let dmg = apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| {
// Transfer › Wetness / Mix Jitter scale the settings per dab.
let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0));
// Sample the canvas under the dab.
acc.iter_mut().for_each(|v| *v = 0.0);
let (mut wsum, mut asum) = (0.0f32, 0.0f32);
let r = fp.rect.intersect(&work.rect);
// Transfer › Wetness / Mix Jitter scale the settings per dab.
let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0));
// Sample the canvas under the dab.
acc.iter_mut().for_each(|v| *v = 0.0);
let (mut wsum, mut asum) = (0.0f32, 0.0f32);
let r = fp.rect.intersect(&work.rect);
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let w = fp.at(x, y);
if w <= 0.0 {
continue;
}
let px = sample.unwrap_or(work).px(x, y);
let al = a.map_or(1.0, |ai| px[ai]);
for c in 0..nc {
acc[c] += px[c] * w * al;
}
wsum += w;
asum += w * al;
}
}
if asum > 1e-6 && wet > 0.0 {
let ca = asum / wsum.max(1e-6);
let canvas: Vec<f32> = (0..n).map(|c| if c < nc { acc[c] / asum } else { 1.0 }).collect();
pickup = Some(match pickup.take() {
None => canvas,
Some(p) => p.iter().zip(&canvas).map(|(p, c)| p + (c - p) * PICKUP_RATE * wet * ca).collect(),
});
}
let wr = if res.is_some() { amount * (1.0 - mix * wet) } else { 0.0 };
let wp = if pickup.is_some() { mix * wet } else { 0.0 };
let total = wr + wp;
if total > 1e-6 {
for c in 0..n {
let rv = res.as_ref().map_or(0.0, |v| v[c]);
let pv = pickup.as_ref().map_or(0.0, |v| v[c]);
paint[c] = (rv * wr + pv * wp) / total;
}
let dep = total.min(1.0) * flow;
let mut local = vec![0.0; n];
let mut mixed = vec![0.0; n];
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let w = fp.at(x, y);
if w <= 0.0 {
continue;
}
let px: Vec<f32> = match sample {
Some(s) => {
let v = s.pixel(x, y);
if s.format() == fmt { v } else { from_rgba(&fmt, s.rgba(x, y)) }
}
None => work.px(x, y).to_vec(),
};
let al = a.map_or(1.0, |ai| px[ai]);
for c in 0..nc {
acc[c] += px[c] * w * al;
}
wsum += w;
asum += w * al;
}
}
if asum > 1e-6 && wet > 0.0 {
let ca = asum / wsum.max(1e-6);
let canvas: Vec<f32> = (0..n).map(|c| if c < nc { acc[c] / asum } else { 1.0 }).collect();
pickup = Some(match pickup.take() {
None => canvas,
Some(p) => p.iter().zip(&canvas).map(|(p, c)| p + (c - p) * PICKUP_RATE * wet * ca).collect(),
});
}
let wr = if res.is_some() { amount * (1.0 - mix * wet) } else { 0.0 };
let wp = if pickup.is_some() { mix * wet } else { 0.0 };
let total = wr + wp;
if total > 1e-6 {
for c in 0..n {
let rv = res.as_ref().map_or(0.0, |v| v[c]);
let pv = pickup.as_ref().map_or(0.0, |v| v[c]);
paint[c] = (rv * wr + pv * wp) / total;
}
let dep = total.min(1.0) * flow;
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let k = fp.at(x, y) * dep;
if k > 0.0 {
let k = fp.at(x, y) * dep;
if k > 0.0 {
if let Some(load) = &state.spatial {
let loaded = load_at(load, &fp.dab, x as f32 + 0.5, y as f32 + 0.5);
from_rgba_into(&fmt, loaded, &mut local);
let local_wr = wr * loaded[3];
let local_total = local_wr + wp;
if local_total > 1e-6 {
for (c, value) in mixed.iter_mut().enumerate() {
*value = (local.get(c).copied().unwrap_or(0.0) * local_wr
+ pickup.as_ref().and_then(|p| p.get(c)).copied().unwrap_or(0.0) * wp)
/ local_total;
}
if let Some(ai) = a
&& let Some(alpha) = mixed.get_mut(ai)
{
*alpha = 1.0;
}
over_native(&fmt, work.px_mut(x, y), &mixed, fp.at(x, y) * local_total.min(1.0) * flow, lock_transparency);
}
} else {
over_native(&fmt, work.px_mut(x, y), &paint, k, lock_transparency);
}
}
}
}
amount *= 1.0 - 0.08 * (1.0 - load);
});
}
amount *= 1.0 - 0.08 * (1.0 - load);
state.amount = amount;
state.pickup = pickup.map(|p| to_rgba(&fmt, &p));
dmg
}
#[cfg(test)]
@@ -284,6 +377,42 @@ mod tests {
assert_eq!((st.reservoir, st.amount, st.pickup), (Some(green), 1.0, None));
}
/// Spatial paint reservoirs honor independent automatic clean and reload choices.
#[test]
fn spatial_load_obeys_automatic_clean_and_load() {
let foreground = [0.0, 0.0, 1.0, 1.0];
let sampled = [1.0, 0.0, 0.0, 1.0];
let tip = vec![sampled; 4096];
let mut state = MixerState::default();
state.load(sampled);
state.spatial = Some(tip.clone());
state.color = Some(foreground);
state.amount = 0.1;
state.end_stroke(foreground, true, true);
assert_eq!(state.spatial.as_ref(), Some(&tip));
assert_eq!((state.reservoir, state.amount, state.pickup), (Some(sampled), 1.0, None));
state.begin_stroke(foreground);
assert_eq!(state.spatial.as_ref(), Some(&tip));
state.end_stroke(foreground, true, false);
assert!(state.spatial.is_none() && state.reservoir.is_none());
state.begin_stroke(foreground);
assert!(state.reservoir.is_none(), "Clean stays empty until a new color or Load");
}
/// Older serialized reload settings remain compatible with the automatic load field.
#[test]
fn earlier_reload_setting_deserializes_as_automatic_load() {
let settings: MixerSettings = serde_json::from_value(serde_json::json!({"reloadAfterStroke":false})).unwrap();
assert!(!settings.load_after_stroke);
let saved = serde_json::to_value(settings).unwrap();
assert_eq!(saved["loadAfterStroke"], false);
assert!(saved.get("reloadAfterStroke").is_none());
}
#[test]
fn dry_empty_brush_paints_nothing() {
let mut s = Surface::new(PixelFormat::RGBA8);
@@ -313,3 +442,7 @@ mod tests {
assert!(run(1.0).is_some_and(|p| p[2] > 0.2));
}
}
#[cfg(test)]
#[path = "mixer_reference_tests.rs"]
mod reference_tests;
+156
View File
@@ -0,0 +1,156 @@
// Frozen pre-optimization dab kernel. Keep independent of the optimized implementation.
use super::*;
/// Keep the allocation-heavy scalar kernel independent so optimizations cannot silently change pickup or blending.
fn reference(
work: &mut crate::retouch::Region,
fp: &crate::retouch::Footprint,
fmt: photocraft_color::PixelFormat,
sample: Option<&Surface>,
m: &MixerSettings,
state: &mut MixerState,
lock_transparency: bool,
) {
let n = fmt.channels();
let a = alpha_index(&fmt);
let nc = fmt.mode.color_channels();
let native = |c: [f32; 4]| {
let mut v = from_rgba(&fmt, c);
if let Some(ai) = a {
v[ai] = 1.0;
}
v
};
let res = state.reservoir.map(native);
let mut amount = if res.is_some() { state.amount.clamp(0.0, 1.0) } else { 0.0 };
let mut pickup: Option<Vec<f32>> = state.pickup.map(native);
let (wet, mix, flow, load) = (m.wet.clamp(0.0, 1.0), m.mix.clamp(0.0, 1.0), m.flow.clamp(0.0, 1.0), m.load.clamp(0.0, 1.0));
let mut acc = vec![0.0f32; n];
let mut paint = vec![0.0f32; n];
// Transfer › Wetness / Mix Jitter scale the settings per dab.
let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0));
// Sample the canvas under the dab.
acc.iter_mut().for_each(|v| *v = 0.0);
let (mut wsum, mut asum) = (0.0f32, 0.0f32);
let r = fp.rect.intersect(&work.rect);
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let w = fp.at(x, y);
if w <= 0.0 {
continue;
}
let px: Vec<f32> = match sample {
Some(s) => {
let v = s.pixel(x, y);
if s.format() == fmt { v } else { from_rgba(&fmt, s.rgba(x, y)) }
}
None => work.px(x, y).to_vec(),
};
let al = a.map_or(1.0, |ai| px[ai]);
for c in 0..nc {
acc[c] += px[c] * w * al;
}
wsum += w;
asum += w * al;
}
}
if asum > 1e-6 && wet > 0.0 {
let ca = asum / wsum.max(1e-6);
let canvas: Vec<f32> = (0..n).map(|c| if c < nc { acc[c] / asum } else { 1.0 }).collect();
pickup = Some(match pickup.take() {
None => canvas,
Some(p) => p.iter().zip(&canvas).map(|(p, c)| p + (c - p) * PICKUP_RATE * wet * ca).collect(),
});
}
let wr = if res.is_some() { amount * (1.0 - mix * wet) } else { 0.0 };
let wp = if pickup.is_some() { mix * wet } else { 0.0 };
let total = wr + wp;
if total > 1e-6 {
for c in 0..n {
let rv = res.as_ref().map_or(0.0, |v| v[c]);
let pv = pickup.as_ref().map_or(0.0, |v| v[c]);
paint[c] = (rv * wr + pv * wp) / total;
}
let dep = total.min(1.0) * flow;
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let k = fp.at(x, y) * dep;
if k > 0.0 {
if let Some(load) = &state.spatial {
let loaded = load_at(load, &fp.dab, x as f32 + 0.5, y as f32 + 0.5);
let local = from_rgba(&fmt, loaded);
let local_wr = wr * loaded[3];
let local_total = local_wr + wp;
if local_total > 1e-6 {
let mut mixed = paint.clone();
for (c, value) in mixed.iter_mut().enumerate() {
*value = (local.get(c).copied().unwrap_or(0.0) * local_wr
+ pickup.as_ref().and_then(|p| p.get(c)).copied().unwrap_or(0.0) * wp)
/ local_total;
}
if let Some(ai) = a
&& let Some(alpha) = mixed.get_mut(ai)
{
*alpha = 1.0;
}
over_native(&fmt, work.px_mut(x, y), &mixed, fp.at(x, y) * local_total.min(1.0) * flow, lock_transparency);
}
} else {
over_native(&fmt, work.px_mut(x, y), &paint, k, lock_transparency);
}
}
}
}
}
amount *= 1.0 - 0.08 * (1.0 - load);
state.amount = amount;
state.pickup = pickup.map(|p| to_rgba(&fmt, &p));
}
/// The incremental kernel matches the frozen reference across color models, bit depths and spatial loads.
#[test]
fn contiguous_mixer_matches_frozen_kernel_across_models_depths_and_spatial_loads() {
use crate::retouch::Region;
use crate::{BrushContext, BrushSettings, Stroke, StrokePoint};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
let rect = Rect::new(-16, -16, 32, 32);
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for alpha in [false, true] {
let fmt = PixelFormat::new(mode, depth, alpha);
let mut surface = Surface::new(fmt);
let values: Vec<_> = (0..rect.width() * rect.height()).flat_map(|i| from_rgba(&fmt, [(i % 31) as f32 / 30.0, 0.3, 0.7, 0.6])).collect();
surface.write_region(rect, &values);
let mut source = Surface::new(PixelFormat::RGBA32F);
source.fill_rect(rect, &[0.8, 0.2, 0.1, 0.75]);
let brush = BrushSettings { size: 24.0, angle: 33.0, roundness: 0.6, pressure_size: false, ..Default::default() };
let dabs = crate::dabs(&Stroke { brush: brush.clone(), points: vec![StrokePoint::new(4.0, 4.0, 1.0)] });
let ctx = BrushContext::new(&brush);
for spatial in [false, true] {
for sample in [None, Some(&source), Some(&surface)] {
for lock in [false, true] {
let mut state = MixerState::default();
state.load([0.1, 0.8, 0.2, 1.0]);
if spatial {
state.spatial =
Some((0..4096).map(|i| [if i % 64 < 32 { 1.0 } else { 0.0 }, 0.2, 0.8, if i % 7 == 0 { 0.0 } else { 1.0 }]).collect());
}
let mut expected_state = state.clone();
let mut actual = Region::read(&surface, rect);
let mut expected = actual.clone();
let settings = MixerSettings::default();
for (index, dab) in dabs.iter().enumerate() {
let fp = ctx.footprint(dab, index);
reference(&mut expected, &fp, fmt, sample, &settings, &mut expected_state, lock);
apply_mixer_dab(&mut actual, &fp, fmt, sample, &settings, &mut state, lock);
}
assert_eq!(actual, expected, "{mode:?} {depth:?} alpha={alpha} spatial={spatial} lock={lock}");
assert_eq!(state, expected_state);
}
}
}
}
}
}
}
+7 -8
View File
@@ -36,19 +36,18 @@ fn paper(seed: u32, depth: f32) -> Texture {
fn general(base: &BrushSettings) -> Vec<BrushPreset> {
let g = |n: &str, b: BrushSettings| preset("General", n, b);
// Dense dabs keep large hard-round outlines and pressure-opacity tapers continuous (#8842).
let hard_round = BrushSettings { spacing: 0.05, ..base.clone() };
vec![
g("Hard Round", BrushSettings { size: 30.0, hardness: 1.0, ..base.clone() }),
g("Hard Round", BrushSettings { size: 30.0, hardness: 1.0, ..hard_round.clone() }),
g("Soft Round", BrushSettings { size: 45.0, hardness: 0.0, ..base.clone() }),
g("Hard Round Fine", BrushSettings { size: 5.0, hardness: 1.0, spacing: 0.1, ..base.clone() }),
g("Hard Round Fine", BrushSettings { size: 5.0, hardness: 1.0, ..hard_round.clone() }),
g("Soft Round Large", BrushSettings { size: 300.0, hardness: 0.0, spacing: 0.15, ..base.clone() }),
g("Hard Round Pressure Size", BrushSettings { size: 30.0, hardness: 1.0, shape_dynamics: size_dyn(pressure()), ..base.clone() }),
g("Hard Round Pressure Size", BrushSettings { size: 30.0, hardness: 1.0, shape_dynamics: size_dyn(pressure()), ..hard_round.clone() }),
g("Soft Round Pressure Size", BrushSettings { size: 45.0, hardness: 0.0, shape_dynamics: size_dyn(pressure()), ..base.clone() }),
g("Hard Round Pressure Opacity", BrushSettings { size: 30.0, hardness: 1.0, transfer: transfer(pressure(), Dynamic::default()), ..base.clone() }),
g("Hard Round Pressure Opacity", BrushSettings { size: 30.0, hardness: 1.0, transfer: transfer(pressure(), Dynamic::default()), ..hard_round.clone() }),
g("Soft Round Pressure Opacity", BrushSettings { size: 45.0, hardness: 0.0, transfer: transfer(pressure(), Dynamic::default()), ..base.clone() }),
g(
"Hard Round Pressure Opacity and Flow",
BrushSettings { size: 30.0, hardness: 0.9, transfer: transfer(pressure(), pressure_min(0.2)), ..base.clone() },
),
g("Hard Round Pressure Opacity and Flow", BrushSettings { size: 30.0, hardness: 0.9, transfer: transfer(pressure(), pressure_min(0.2)), ..hard_round }),
g("Airbrush Soft", BrushSettings { size: 80.0, hardness: 0.0, flow: 0.1, spacing: 0.1, build_up: true, build_up_rate: 25.0, ..base.clone() }),
g(
"Airbrush Pressure Flow",
+295 -114
View File
@@ -39,6 +39,57 @@ fn dither8(px: &mut [f32], x: i32, y: i32) {
/// Edge of a stroke-buffer tile.
pub const COV_TILE: i32 = 64;
/// Work on at least this many pixels runs in parallel (rows of a dab, tiles of a stroke): below
/// it the threads cost more than they save. A large soft tip is tens of megapixels per dab, and
/// every stage of it is independent per row or tile, so the pixels come out the same.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) const DEFAULT_PARALLEL_MIN_PIXELS: usize = 256 * 256;
/// The threshold in use: [`DEFAULT_PARALLEL_MIN_PIXELS`] (tests move it).
#[cfg(not(target_arch = "wasm32"))]
static PARALLEL_MIN_PIXELS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(DEFAULT_PARALLEL_MIN_PIXELS);
/// Whether `pixels` of independent work are worth the thread pool (the web has none: there every
/// stage takes its serial path).
#[cfg(not(target_arch = "wasm32"))]
fn parallel(pixels: usize) -> bool {
pixels >= PARALLEL_MIN_PIXELS.load(std::sync::atomic::Ordering::Relaxed)
}
/// Tests: the pixel count from which work runs in parallel (`usize::MAX` keeps everything serial).
/// The static is process-wide, so brush tests on other threads see the change too; that is
/// harmless, because every stage gives the same pixels serially and in parallel (the point of
/// the test that uses this), so no test can tell which way it ran.
#[cfg(all(test, not(target_arch = "wasm32")))]
pub(crate) fn set_parallel_min_pixels(n: usize) {
PARALLEL_MIN_PIXELS.store(n, std::sync::atomic::Ordering::Relaxed);
}
/// `f` over `items`, in parallel when `pixels` are worth it.
fn for_each_item<T: Send>(items: &mut [T], pixels: usize, f: impl Fn(&mut T) + Sync) {
#[cfg(not(target_arch = "wasm32"))]
if parallel(pixels) {
use rayon::prelude::*;
items.par_iter_mut().for_each(&f);
return;
}
#[cfg(target_arch = "wasm32")]
let _ = pixels;
items.iter_mut().for_each(&f);
}
/// `f` over `items`, collecting its results in order, in parallel when `pixels` are worth it.
fn map_items<T: Sync, R: Send>(items: &[T], pixels: usize, f: impl Fn(&T) -> R + Sync) -> Vec<R> {
#[cfg(not(target_arch = "wasm32"))]
if parallel(pixels) {
use rayon::prelude::*;
return items.par_iter().map(&f).collect();
}
#[cfg(target_arch = "wasm32")]
let _ = pixels;
items.iter().map(&f).collect()
}
/// Computed tips with a smaller radius (in pixels) are rasterised by area, not at pixel centres.
const SMALL_TIP_RADIUS: f32 = 3.0;
/// Sub-pixel sample offsets (a 4×4 grid) for the area coverage of small tips.
@@ -466,61 +517,62 @@ impl CoverageMap {
let (t0x, t0y) = (rect.x0.div_euclid(COV_TILE), rect.y0.div_euclid(COV_TILE));
let (t1x, t1y) = ((rect.x1 - 1).div_euclid(COV_TILE), (rect.y1 - 1).div_euclid(COV_TILE));
let want_col = color.is_some() && nc > 0;
// The tiles this dab touches (a tile with no value in it is skipped), each independent of
// the others: a large dab's tiles accumulate in parallel, to the same values.
let mut keys: Vec<((i32, i32), Rect)> = Vec::new();
for ty in t0y..=t1y {
for tx in t0x..=t1x {
let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&rect);
if tr.is_empty() {
continue;
}
// Skip tiles this dab doesn't touch.
let any = (tr.y0..tr.y1).any(|y| {
let row = (y - rect.y0) as usize * w;
vals[row + (tr.x0 - rect.x0) as usize..row + (tr.x1 - rect.x0) as usize].iter().any(|&v| v > 0.0)
});
if !any {
continue;
}
let tile = self.tiles.entry((tx, ty)).or_insert_with(|| CovTile {
cov: vec![0.0; (COV_TILE * COV_TILE) as usize],
depth: Vec::new(),
col: if want_col { vec![0.0; (COV_TILE * COV_TILE) as usize * nc] } else { Vec::new() },
});
keys.push(((tx, ty), tr));
}
}
let touched = map_items(&keys, keys.len() * (COV_TILE * COV_TILE) as usize, |(_, tr)| {
(tr.y0..tr.y1).any(|y| {
let row = (y - rect.y0) as usize * w;
vals[row + (tr.x0 - rect.x0) as usize..row + (tr.x1 - rect.x0) as usize].iter().any(|&v| v > 0.0)
})
});
// The touched tiles leave the map while they accumulate and go back after: the dab's cost
// follows the tiles under it (one to four for a small tip), not the size of the map, which a
// long stroke grows to thousands of tiles.
let n = (COV_TILE * COV_TILE) as usize;
let mut tiles: Vec<(Rect, (i32, i32), CovTile)> = keys
.into_iter()
.zip(touched)
.filter(|(_, t)| *t)
.map(|((key, tr), _)| {
let mut tile = self.tiles.remove(&key).unwrap_or_else(|| CovTile { cov: vec![0.0; n], depth: Vec::new(), col: Vec::new() });
if want_col && tile.col.is_empty() {
tile.col = vec![0.0; (COV_TILE * COV_TILE) as usize * nc];
tile.col = vec![0.0; n * nc];
}
if depth.is_some() && tile.depth.is_empty() {
tile.depth = vec![0.0; (COV_TILE * COV_TILE) as usize];
}
self.dirty.insert((tx, ty));
for y in tr.y0..tr.y1 {
let row = (y - rect.y0) as usize * w;
let trow = ((y - ty * COV_TILE) * COV_TILE) as usize;
for x in tr.x0..tr.x1 {
let v = vals[row + (x - rect.x0) as usize];
if v <= 0.0 {
continue;
}
let ti = trow + (x - tx * COV_TILE) as usize;
if let (Some(dv), Some(slot)) = (depth.and_then(|dp| dp.get(row + (x - rect.x0) as usize)), tile.depth.get_mut(ti)) {
*slot = slot.max(*dv);
}
let c = tile.cov[ti];
let nv = if ceil > c { c + v * (ceil - c) } else { c };
if nv <= c {
continue;
}
if let (true, Some(col)) = (want_col, color) {
let k = if c < 1.0 { (nv - c) / (1.0 - c) } else { 0.0 };
let p = &mut tile.col[ti * nc..(ti + 1) * nc];
for i in 0..nc {
p[i] = col[i] * k + p[i] * (1.0 - k);
}
}
tile.cov[ti] = nv;
}
tile.depth = vec![0.0; n];
}
self.dirty.insert(key);
(tr, key, tile)
})
.collect();
let dab = DabValues { vals, rect, w, ceil, color: if want_col { color } else { None }, depth, nc };
// The serial path stays a plain loop over an inlined step (see `rasterize_round`).
#[cfg(not(target_arch = "wasm32"))]
if parallel(tiles.len() * n) {
use rayon::prelude::*;
tiles.par_iter_mut().for_each(|(tr, key, tile)| dab.tile(*tr, *key, tile));
} else {
for (tr, key, tile) in &mut tiles {
dab.tile(*tr, *key, tile);
}
}
#[cfg(target_arch = "wasm32")]
for (tr, key, tile) in &mut tiles {
dab.tile(*tr, *key, tile);
}
for (_, key, tile) in tiles {
self.tiles.insert(key, tile);
}
}
fn take_dirty(&mut self) -> Vec<(i32, i32)> {
@@ -587,6 +639,55 @@ impl UnionLive {
}
}
/// One dab's coverage (`vals` over `rect`, `w` wide) as [`CoverageMap::accumulate`] adds it to
/// a tile: the dab's opacity ceiling, its colour when the stroke has per-dab colour (`nc` channels),
/// its Wet Edges depth.
#[derive(Clone, Copy)]
struct DabValues<'a> {
vals: &'a [f32],
rect: Rect,
w: usize,
ceil: f32,
color: Option<&'a [f32]>,
depth: Option<&'a [f32]>,
nc: usize,
}
impl DabValues<'_> {
/// Adds the dab over `tr` (within tile `(tx, ty)`) to `tile`.
#[inline(always)]
fn tile(self, tr: Rect, (tx, ty): (i32, i32), tile: &mut CovTile) {
let DabValues { vals, rect, w, ceil, color, depth, nc } = self;
for y in tr.y0..tr.y1 {
let row = (y - rect.y0) as usize * w;
let trow = ((y - ty * COV_TILE) * COV_TILE) as usize;
for x in tr.x0..tr.x1 {
let v = vals[row + (x - rect.x0) as usize];
if v <= 0.0 {
continue;
}
let ti = trow + (x - tx * COV_TILE) as usize;
if let (Some(dv), Some(slot)) = (depth.and_then(|dp| dp.get(row + (x - rect.x0) as usize)), tile.depth.get_mut(ti)) {
*slot = slot.max(*dv);
}
let c = tile.cov[ti];
let nv = if ceil > c { c + v * (ceil - c) } else { c };
if nv <= c {
continue;
}
if let Some(col) = color {
let k = if c < 1.0 { (nv - c) / (1.0 - c) } else { 0.0 };
let p = &mut tile.col[ti * nc..(ti + 1) * nc];
for i in 0..nc {
p[i] = col[i] * k + p[i] * (1.0 - k);
}
}
tile.cov[ti] = nv;
}
}
}
}
/// Incremental stroke rendering: feed points, composite dirty tiles from the pre-stroke pixels.
#[derive(Clone, Debug)]
pub struct StrokeRenderer {
@@ -603,6 +704,19 @@ pub struct StrokeRenderer {
dab_buf: Vec<Dab>,
dual_buf: Vec<Dab>,
all_dabs: Option<Vec<Dab>>,
regions: RegionPool,
}
/// The tile buffers [`StrokeRenderer::composite`] works in, kept from one composite to the next:
/// a small stroke's few tiles reuse theirs instead of growing and trimming the heap every frame
/// (that cost a 50 px stroke a third of its time). A clone starts with none.
#[derive(Debug, Default)]
struct RegionPool(Vec<Vec<f32>>);
impl Clone for RegionPool {
fn clone(&self) -> Self {
Self::default()
}
}
impl StrokeRenderer {
@@ -695,6 +809,7 @@ impl StrokeRenderer {
dab_buf: Vec::new(),
dual_buf: Vec::new(),
all_dabs: None,
regions: RegionPool::default(),
})
}
Some(m) => {
@@ -746,6 +861,7 @@ impl StrokeRenderer {
dab_buf: Vec::new(),
dual_buf: Vec::new(),
all_dabs: None,
regions: RegionPool::default(),
}
}
@@ -876,6 +992,7 @@ impl StrokeRenderer {
dab_buf: dabs,
dual_buf: duals,
all_dabs: None,
regions: RegionPool::default(),
};
t.raster_pending();
// Tiles whose coverage the tail doesn't change still composite the same: redraw them all.
@@ -989,77 +1106,106 @@ impl StrokeRenderer {
// colour, as in Photoshop, not grain that a tolerance-0 Magic Wand or Paint Bucket trips on.
let dither = fmt.sample == SampleType::U8 && !b.aliased;
let mut dmg = Rect::EMPTY;
let mut region = Vec::new();
for (tx, ty) in keys {
let Some(tile) = self.cov.tiles.get(&(tx, ty)) else { continue };
let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&bounds);
if tr.is_empty() {
continue;
}
pre.read_region_into(tr, &mut region);
let sel = selection.map(|s| (s.channels(), s.read_region(tr)));
let w = tr.width() as usize;
for y in tr.y0..tr.y1 {
for x in tr.x0..tr.x1 {
let ti = ((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize;
let c = tile.cov[ti];
if c <= 0.0 {
continue;
}
let i = (y - tr.y0) as usize * w + (x - tr.x0) as usize;
let m = self.ctx.stroke_mask_wet(c, tile.depth.get(ti).copied(), self.dual.as_ref().map(|d| d.get(x, y)), x, y);
let s = sel.as_ref().map_or(1.0, |(sc, v)| v[i * sc]);
let mut k = (m * opacity * s).min(1.0);
if k <= 0.0 {
continue;
}
if b.mode == BlendMode::Dissolve {
k = if hash2(x, y, b.seed) < k { 1.0 } else { 0.0 };
if k == 0.0 {
// Each tile composites into a buffer of its own (a large stroke's tiles in parallel, to
// the same pixels); the writes to the target go one by one, in order. Batches keep the
// pending buffers few, and the pool keeps them from one composite to the next.
const BATCH: usize = 256;
let mut pool = std::mem::take(&mut self.regions.0);
// The document's CMYK profile is active per thread: the workers enter the caller's, or a
// blend mode other than Normal would convert their tiles through the built-in coated CMYK.
let cmyk = photocraft_color::convert::active_cmyk_space();
for batch in keys.chunks(BATCH) {
let mut items: Vec<((i32, i32), Rect, Vec<f32>)> = batch.iter().map(|&key| (key, Rect::EMPTY, pool.pop().unwrap_or_default())).collect();
let composite_tile = |(key, done, region): &mut ((i32, i32), Rect, Vec<f32>)| {
let (tx, ty) = *key;
let Some(tile) = self.cov.tiles.get(&(tx, ty)) else { return };
let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&bounds);
if tr.is_empty() {
return;
}
pre.read_region_into(tr, region);
let sel = selection.map(|s| (s.channels(), s.read_region(tr)));
// Per-dab colour rewrites the source per pixel, so each tile works on its own copy
// that starts from the brush colour. (Before the tiles ran in parallel, one `src`
// carried over from tile to tile; a tile without per-dab colour then painted with
// whatever colour the previous tile's last pixel had left in it. Such a tile only
// occurs when the stroke has no per-dab colour at all, and then `src` is never
// rewritten, so the pixels are the same either way; the copy per tile is the
// intended reading.)
let mut src = src;
let w = tr.width() as usize;
for y in tr.y0..tr.y1 {
for x in tr.x0..tr.x1 {
let ti = ((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize;
let c = tile.cov[ti];
if c <= 0.0 {
continue;
}
}
let px = &mut region[i * n..(i + 1) * n];
if b.erase {
if let (Some(a), false) = (a_idx, lock_transparency) {
px[a] *= 1.0 - k;
let i = (y - tr.y0) as usize * w + (x - tr.x0) as usize;
let m = self.ctx.stroke_mask_wet(c, tile.depth.get(ti).copied(), self.dual.as_ref().map(|d| d.get(x, y)), x, y);
let s = sel.as_ref().map_or(1.0, |(sc, v)| v[i * sc]);
let mut k = (m * opacity * s).min(1.0);
if k <= 0.0 {
continue;
}
if b.mode == BlendMode::Dissolve {
k = if hash2(x, y, b.seed) < k { 1.0 } else { 0.0 };
if k == 0.0 {
continue;
}
}
let px = &mut region[i * n..(i + 1) * n];
if b.erase {
if let (Some(a), false) = (a_idx, lock_transparency) {
px[a] *= 1.0 - k;
}
if dither {
dither8(px, x, y);
}
continue;
}
if lock_transparency && a_idx.is_some_and(|a| px[a] <= 0.0) {
continue;
}
if self.per_dab_color && !tile.col.is_empty() {
let p = &tile.col[ti * nc..(ti + 1) * nc];
for ch in 0..nc {
src[ch] = p[ch] / c;
}
}
if matches!(b.mode, BlendMode::Normal | BlendMode::Dissolve) {
over_native(&fmt, px, &src[..n], k, lock_transparency);
} else {
let d = to_rgba(&fmt, px);
let sr = to_rgba(&fmt, &src[..n]);
let o = if lock_transparency {
photocraft_color::blend::composite_atop(b.mode, d, sr, k)
} else {
photocraft_color::blend::composite(b.mode, d, sr, k)
};
let mut enc = [0.0f32; 8];
from_rgba_into(&fmt, o, &mut enc);
px.copy_from_slice(&enc[..n]);
}
if dither {
dither8(px, x, y);
}
continue;
}
if lock_transparency && a_idx.is_some_and(|a| px[a] <= 0.0) {
continue;
}
if self.per_dab_color && !tile.col.is_empty() {
let p = &tile.col[ti * nc..(ti + 1) * nc];
for ch in 0..nc {
src[ch] = p[ch] / c;
}
}
if matches!(b.mode, BlendMode::Normal | BlendMode::Dissolve) {
over_native(&fmt, px, &src[..n], k, lock_transparency);
} else {
let d = to_rgba(&fmt, px);
let sr = to_rgba(&fmt, &src[..n]);
let o = if lock_transparency {
photocraft_color::blend::composite_atop(b.mode, d, sr, k)
} else {
photocraft_color::blend::composite(b.mode, d, sr, k)
};
let mut enc = [0.0f32; 8];
from_rgba_into(&fmt, o, &mut enc);
px.copy_from_slice(&enc[..n]);
}
if dither {
dither8(px, x, y);
}
}
*done = tr;
};
for_each_item(&mut items, batch.len() * (COV_TILE * COV_TILE) as usize, |item| {
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || composite_tile(item))
});
for (_, tr, region) in items {
if !tr.is_empty() {
target.write_region(tr, &region);
dmg = dmg.union(&tr);
}
pool.push(region);
}
target.write_region(tr, &region);
dmg = dmg.union(&tr);
}
self.regions.0 = pool;
if all { bounds } else { dmg }
}
}
@@ -1090,22 +1236,57 @@ fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased:
let ro = d.roundness.max(0.5 / d.radius).min(1.0);
let rm = (d.radius * ro).max(0.5);
let w = rect.width() as usize;
if w == 0 {
return;
}
let reach = if aliased { rm } else { tip_reach(rm, hardness).max(rm) };
let reach2 = (reach + 0.5) * (reach + 0.5);
let tip = RoundTip { cx, cy, ro, rm, reach2: (reach + 0.5) * (reach + 0.5), hardness, alpha: d.alpha, aliased, x0: rect.x0, y0: rect.y0 };
// Rows are independent: a large tip's rows run in parallel, to the same values. The serial
// path stays a plain loop over an inlined row: the same code as before the threads (a closure
// passed to a helper cost a small tip half its rasterization time).
#[cfg(not(target_arch = "wasm32"))]
if parallel(out.len()) {
use rayon::prelude::*;
out.par_chunks_exact_mut(w).enumerate().for_each(|(yy, row)| tip.row(yy, row));
return;
}
for (yy, row) in out.chunks_exact_mut(w).enumerate() {
let dy = (rect.y0 + yy as i32) as f32 + 0.5 - cy;
if aliased {
tip.row(yy, row);
}
}
/// A round tip's coverage, computed row by row (see [`rasterize_round`]).
#[derive(Clone, Copy)]
struct RoundTip {
cx: f32,
cy: f32,
ro: f32,
rm: f32,
reach2: f32,
hardness: f32,
alpha: f32,
aliased: bool,
x0: i32,
y0: i32,
}
impl RoundTip {
/// Row `yy` of the tip's rectangle into `row`.
#[inline(always)]
fn row(self, yy: usize, row: &mut [f32]) {
let dy = (self.y0 + yy as i32) as f32 + 0.5 - self.cy;
if self.aliased {
for (xx, dst) in row.iter_mut().enumerate() {
let dx = (rect.x0 + xx as i32) as f32 + 0.5 - cx;
let d2 = (dx * ro).powi(2) + dy * dy;
*dst = if d2 < reach2 && d2.sqrt() <= rm { d.alpha } else { 0.0 };
let dx = (self.x0 + xx as i32) as f32 + 0.5 - self.cx;
let d2 = (dx * self.ro).powi(2) + dy * dy;
*dst = if d2 < self.reach2 && d2.sqrt() <= self.rm { self.alpha } else { 0.0 };
}
} else {
for (xx, dst) in row.iter_mut().enumerate() {
let dx = (rect.x0 + xx as i32) as f32 + 0.5 - cx;
let d2 = (dx * ro).powi(2) + dy * dy;
let val = if d2 >= reach2 { 0.0 } else { tip_falloff(d2.sqrt(), rm, hardness) };
*dst = if val <= 0.0 { 0.0 } else { val * d.alpha };
let dx = (self.x0 + xx as i32) as f32 + 0.5 - self.cx;
let d2 = (dx * self.ro).powi(2) + dy * dy;
let val = if d2 >= self.reach2 { 0.0 } else { tip_falloff(d2.sqrt(), self.rm, self.hardness) };
*dst = if val <= 0.0 { 0.0 } else { val * self.alpha };
}
}
}
+5 -1
View File
@@ -348,7 +348,11 @@ fn a_fifo_in_place_of_a_module_file_does_not_block_loading() {
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("evil.wasm");
let _ = std::fs::remove_file(&path);
rustix::fs::mknodat(rustix::fs::CWD, &path, rustix::fs::FileType::Fifo, rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR, 0).unwrap();
// `rustix::fs::mknodat` is unavailable on Apple targets. `mkfifo` is the
// POSIX utility for creating this special file and keeps this regression
// test available on macOS as well as other Unix platforms.
let status = std::process::Command::new("mkfifo").arg(&path).status().unwrap();
assert!(status.success(), "mkfifo failed with {status}");
let (tx, rx) = std::sync::mpsc::channel();
let p = path.clone();
std::thread::spawn(move || {
+38 -7
View File
@@ -39,6 +39,14 @@ pub struct GlobalLayerMask {
}
impl GlobalLayerMask {
/// The mask Photoshop writes for a layered file that has no global mask of its own:
/// an RGB red overlay at 50% opacity, kind 128 (use the per-layer setting), plus the
/// filler byte. A zero-length `LMsk` block in a TIFF makes Photoshop give up on the
/// layer data (it reads this 14-byte record from the block).
pub fn photoshop_default() -> Self {
GlobalLayerMask { data: vec![0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, 128, 0] }
}
fn be16(&self, at: usize) -> Option<u16> {
self.data.get(at..at + 2).map(|b| u16::from_be_bytes([b[0], b[1]]))
}
@@ -352,12 +360,12 @@ impl PsdFile {
}
/// `true` if the merged image carries a transparency channel: its first extra channel, when
/// the layer count was stored negative or a "Saving Merged Transparency" global block (`Mtrn`,
/// `Mt16`, `Mt32`) is present. Photoshop marks 32-bit documents with `Mt32` and a positive
/// layer count (psd-tools transparentbg.psd). Photoshop also names the merged transparency
/// "Transparency" and lists it first among the alpha channel names, and some of its 16-bit
/// PSBs carry no other mark (#4654): when the layers sit in a global `Lr16`/`Lr32` block, that
/// name with one name per extra channel marks it too.
/// the layer count was stored negative or a "Saving Merged Transparency" global block (see
/// [`TaggedBlock::is_merged_transparency_marker`]) is present. Photoshop marks 32-bit documents
/// with `Mt32` and a positive layer count (psd-tools transparentbg.psd). Photoshop also names
/// the merged transparency "Transparency" and lists it first among the alpha channel names,
/// and some of its 16-bit PSBs carry no other mark (#4654): when the layers sit in a global
/// `Lr16`/`Lr32` block, that name with one name per extra channel marks it too.
pub fn merged_has_alpha(&self) -> bool {
let Some(color) = self.header.color_mode.color_channels() else {
return false;
@@ -365,7 +373,7 @@ impl PsdFile {
if self.header.channels <= color {
return false;
}
let marked = self.global_blocks.iter().any(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32"));
let marked = self.global_blocks.iter().any(TaggedBlock::is_merged_transparency_marker);
match &self.layer_info {
Some(li) => {
let in_block = matches!(self.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. });
@@ -583,4 +591,27 @@ mod write_tests {
}
}
}
/// A positive layer count plus a merged-transparency marker still makes the first extra
/// channel the transparency, for the specification's keys and for the `MTrn` Photoshop
/// writes in a layered TIFF.
#[test]
fn merged_transparency_markers_mark_the_first_extra_channel() {
let mut file = testgen::small(Version::Psd, Compression::Raw);
if let Some(li) = &mut file.layer_info {
li.merged_alpha = false;
}
assert!(!file.merged_has_alpha());
for key in [*b"Mtrn", *b"MTrn", *b"Mt16", *b"Mt32"] {
let mut marked = file.clone();
marked.global_blocks.push(TaggedBlock::new(key, Vec::new()));
assert!(marked.merged_has_alpha(), "{}", String::from_utf8_lossy(&key));
}
// Other keys, and the marker's letters in another case, do not.
for key in [*b"MTRN", *b"mtrn", *b"Lr16"] {
let mut other = file.clone();
other.global_blocks.push(TaggedBlock::new(key, Vec::new()));
assert!(!other.merged_has_alpha(), "{}", String::from_utf8_lossy(&key));
}
}
}
+8
View File
@@ -155,6 +155,14 @@ impl TaggedBlock {
Self::is_foreign_order_signature(&self.signature)
}
/// `true` for a "Saving Merged Transparency" marker: the merged image's first extra channel
/// is its transparency. The specification's keys are `Mtrn`, `Mt16` and `Mt32`; Photoshop
/// (27.11) writes `MTrn`, with a positive layer count, in a layered TIFF saved with Save
/// Transparency next to spot and alpha channels.
pub fn is_merged_transparency_marker(&self) -> bool {
matches!(&self.key, b"Mtrn" | b"MTrn" | b"Mt16" | b"Mt32")
}
/// Key as a string (lossy).
pub fn key_str(&self) -> String {
String::from_utf8_lossy(&self.key).into_owned()
+130 -4
View File
@@ -1162,16 +1162,16 @@ pub mod transcode {
fn type_layer(&mut self) -> Result<()> {
self.u16()?;
self.f64s(6)?;
self.type_descriptor(50)?;
self.type_descriptor(1)?;
self.type_descriptor(50, ByteOrder::Big)?;
self.type_descriptor(1, self.to)?;
self.i32s(4)
}
fn type_descriptor(&mut self, version: u16) -> Result<()> {
fn type_descriptor(&mut self, version: u16, to: ByteOrder) -> Result<()> {
let [hi, lo] = version.to_be_bytes();
let from = if self.from.is_little() && self.r.peek_rest().starts_with(&[hi, lo, 0, 0, 0, 16]) { ByteOrder::Big } else { self.from };
let data = self.r.peek_rest();
let mut descriptor = Tx::new(data, self.version, from, self.to);
let mut descriptor = Tx::new(data, self.version, from, to);
descriptor.depth = self.depth;
descriptor.u16()?;
descriptor.versioned_descriptor()?;
@@ -1836,6 +1836,132 @@ mod tests {
block_rt(*b"lfx2", data);
}
fn type_layer_data() -> Vec<u8> {
let mut data = Vec::new();
data.put_u16(1);
for value in [1.5, 0.0, -0.25, 1.75, 12.5, -3.25] {
data.put_f64(value);
}
data.put_u16(50);
let text = Descriptor::new("TxLr").with("Txt ", Value::Text(UnicodeString::new_nul("Synthetic Ω 🦀")));
data.extend(VersionedDescriptor::new(text).to_bytes());
data.put_u16(1);
data.extend(VersionedDescriptor::new(Descriptor::new("warp")).to_bytes());
for value in [11, -22, 333, 4_444] {
data.put_i32(value);
}
data
}
fn tagged_payload<'a>(bytes: &'a [u8], signature_and_key: &[u8; 8], little: bool) -> &'a [u8] {
let block_at = bytes.windows(8).position(|window| window == signature_and_key).expect("tagged block");
let length_at = block_at + 8;
let length_bytes: [u8; 4] = bytes.get(length_at..length_at + 4).expect("tagged block length").try_into().expect("four-byte length");
let length = if little { u32::from_le_bytes(length_bytes) } else { u32::from_be_bytes(length_bytes) };
let data_at = length_at + 4;
let data_end = data_at + usize::try_from(length).expect("test block length fits usize");
bytes.get(data_at..data_end).expect("tagged block payload")
}
fn legacy_little_endian_type_block() -> Vec<u8> {
let mut tysh = Vec::new();
tysh.extend_from_slice(&1u16.to_le_bytes());
for value in [1.5f64, 0.0, -0.25, 1.75, 12.5, -3.25] {
tysh.extend_from_slice(&value.to_le_bytes());
}
tysh.extend_from_slice(&50u16.to_le_bytes());
// Independently encode a little-endian `VersionedDescriptor` with a single TEXT value.
tysh.extend_from_slice(&16u32.to_le_bytes());
tysh.extend_from_slice(&0u32.to_le_bytes()); // empty descriptor name
tysh.extend_from_slice(&0u32.to_le_bytes()); // four-character class id
tysh.extend_from_slice(b"rLxT"); // little-endian four-character class id
tysh.extend_from_slice(&1u32.to_le_bytes()); // one item
tysh.extend_from_slice(&0u32.to_le_bytes()); // four-character key
tysh.extend_from_slice(b" txT"); // little-endian four-character key
tysh.extend_from_slice(b"TXET"); // little-endian OSType
let mut units: Vec<u16> = "Synthetic Ω 🦀".encode_utf16().collect();
units.push(0); // `new_nul`, matching the canonical fixture
tysh.extend_from_slice(&u32::try_from(units.len()).expect("short test string").to_le_bytes());
for unit in units {
tysh.extend_from_slice(&unit.to_le_bytes());
}
tysh.extend_from_slice(&1u16.to_le_bytes());
// Empty warp descriptor, also encoded independently in little-endian order.
tysh.extend_from_slice(&16u32.to_le_bytes());
tysh.extend_from_slice(&0u32.to_le_bytes()); // empty descriptor name
tysh.extend_from_slice(&4u32.to_le_bytes()); // `warp` is a four-byte string id
tysh.extend_from_slice(b"warp");
tysh.extend_from_slice(&0u32.to_le_bytes()); // no items
for value in [11i32, -22, 333, 4_444] {
tysh.extend_from_slice(&value.to_le_bytes());
}
let mut image_source = super::SIGNATURE.to_vec();
image_source.extend_from_slice(b"MIB8hSyT");
image_source.extend_from_slice(&u32::try_from(tysh.len()).expect("short test block").to_le_bytes());
image_source.extend_from_slice(&tysh);
let padding = (4 - tysh.len() % 4) % 4;
image_source.resize(image_source.len() + padding, 0);
image_source
}
#[test]
fn type_block_write_keeps_text_descriptor_in_psd_order() {
let canonical = type_layer_data();
let source = ImageSourceData { global_blocks: vec![TaggedBlock::new(*b"TySh", canonical.clone())], ..Default::default() };
let (little, warnings) = source.to_bytes(ByteOrder::Little).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let payload = tagged_payload(&little, b"MIB8hSyT", true);
assert_eq!(payload.get(..2), Some(&[1, 0][..]), "TySh header stays in TIFF order");
for (index, expected) in [1.5f64, 0.0, -0.25, 1.75, 12.5, -3.25].into_iter().enumerate() {
let start = 2 + index * 8;
let bytes: [u8; 8] = payload.get(start..start + 8).unwrap().try_into().unwrap();
assert_eq!(f64::from_le_bytes(bytes), expected, "matrix element {index}");
}
let (text_descriptor, descriptor_len) = VersionedDescriptor::parse_prefix(canonical.get(52..).unwrap()).unwrap();
let text_section_len = 2 + descriptor_len;
assert_eq!(payload.get(50..52), Some(&50u16.to_be_bytes()[..]));
assert_eq!(payload.get(52..56), Some(&16u32.to_be_bytes()[..]));
assert_eq!(payload.get(50..50 + text_section_len), canonical.get(50..50 + text_section_len));
assert!(matches!(text_descriptor.descriptor.get("Txt "), Some(Value::Text(value)) if value.to_string_lossy() == "Synthetic Ω 🦀"));
let text_descriptor_from_output = VersionedDescriptor::parse_prefix(payload.get(52..).unwrap()).unwrap().0;
assert!(matches!(text_descriptor_from_output.descriptor.get("Txt "), Some(Value::Text(value)) if value.to_string_lossy() == "Synthetic Ω 🦀"));
let warp_at = 50 + text_section_len;
assert_eq!(payload.get(warp_at..warp_at + 2), Some(&1u16.to_le_bytes()[..]));
let (_, warp_len) = VersionedDescriptor::parse_prefix(canonical.get(warp_at + 2..).unwrap()).unwrap();
let warp_descriptor_at = warp_at + 2;
assert_eq!(payload.get(warp_descriptor_at..warp_descriptor_at + 4), Some(&16u32.to_le_bytes()[..]));
assert_eq!(payload.get(warp_descriptor_at + 4..warp_descriptor_at + 8), Some(&0u32.to_le_bytes()[..]));
assert_eq!(payload.get(warp_descriptor_at + 8..warp_descriptor_at + 12), Some(&4u32.to_le_bytes()[..]));
assert_eq!(payload.get(warp_descriptor_at + 12..warp_descriptor_at + 16), Some(b"warp".as_slice()));
assert_eq!(payload.get(warp_descriptor_at + 16..warp_descriptor_at + 20), Some(&0u32.to_le_bytes()[..]));
let bounds_at = warp_descriptor_at + warp_len;
for (index, expected) in [11i32, -22, 333, 4_444].into_iter().enumerate() {
let start = bounds_at + index * 4;
assert_eq!(payload.get(start..start + 4), Some(&expected.to_le_bytes()[..]), "bound {index}");
}
let (big, warnings) = source.to_bytes(ByteOrder::Big).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(tagged_payload(&big, b"8BIMTySh", false), canonical);
let (reopened, warnings) = ImageSourceData::from_bytes(&little).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(reopened.global_blocks[0].data, canonical);
assert_eq!(reopened.to_bytes(ByteOrder::Big).unwrap().0, big);
}
#[test]
fn legacy_little_endian_type_descriptor_still_imports() {
let input = legacy_little_endian_type_block();
let (reopened, warnings) = ImageSourceData::from_bytes(&input).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(reopened.global_blocks[0].data, type_layer_data());
}
#[test]
fn type_block_layout() {
let mut data = Vec::new();
+1
View File
@@ -232,6 +232,7 @@ fn global_mask_accessors() {
assert_eq!(gm.opacity(), Some(50));
assert_eq!(gm.kind(), Some(128));
assert_eq!(GlobalLayerMask::default().kind(), None);
assert_eq!(GlobalLayerMask::photoshop_default().data, gm.data);
}
#[test]

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